Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
03aee26
nav item
j-chmielewski Aug 14, 2026
bf5f3b0
empty state
j-chmielewski Aug 14, 2026
4386a7e
editor - navigation and basic form
j-chmielewski Aug 14, 2026
eee0c69
rename MfaFlowPage -> MfaFormPage
j-chmielewski Aug 14, 2026
d95ecb7
add the steps editor
j-chmielewski Aug 14, 2026
66678e6
save mfa flow
j-chmielewski Aug 14, 2026
4b9a9e7
edit MFA flow
j-chmielewski Aug 14, 2026
2fac7b0
fix mfa flow editor in dark theme
j-chmielewski Aug 15, 2026
1db8d50
display validation error only after submit
j-chmielewski Aug 17, 2026
616131e
lock "email code" mfa method if smtp is not configured
j-chmielewski Aug 17, 2026
99b90c3
lock external oidc if license < business
j-chmielewski Aug 17, 2026
90f4898
mfa flow table
j-chmielewski Aug 17, 2026
592d165
filter and search mfa flows table
j-chmielewski Aug 17, 2026
2577e68
only allow one single-step mfa flow for free tier
j-chmielewski Aug 17, 2026
d4cb768
fix icons
j-chmielewski Aug 17, 2026
1990a86
bump ui
j-chmielewski Aug 17, 2026
2128b07
mark mfa-flow as business feature
j-chmielewski Aug 17, 2026
5ff3515
add biometric method
j-chmielewski Aug 17, 2026
c75756c
fix clippy issue
j-chmielewski Aug 17, 2026
e86eb1f
add single-flow test and more specific error for free license
j-chmielewski Aug 17, 2026
53f8eeb
additional flow check before db transaction
j-chmielewski Aug 17, 2026
a2e8fc1
use the /mfa-flow/method-availability api endpoint
j-chmielewski Aug 17, 2026
f482182
cleanup
j-chmielewski Aug 17, 2026
279a5f2
remove comments
j-chmielewski Aug 17, 2026
2419eff
show "upgrade to business" modal
j-chmielewski Aug 17, 2026
5f2ad86
review fixes
j-chmielewski Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/defguard_core/src/handlers/mfa_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ pub async fn list_mfa_flows(
(status = 201, description = "MFA flow created.", body = MfaFlowDetailResponse),
(status = 400, description = "Invalid request data: structured `validation_failed` with `fields[]`, e.g. `required`, `min_items`, `max_items`, `max_length`, `duplicate`, `smtp_not_configured`, `oidc_provider_missing`.", body = ApiErrorResponse, example = json!({"error": "validation_failed", "fields": [{"field": "steps[0].methods", "code": "oidc_provider_missing"}]})),
(status = 401, description = "Session is missing or invalid.", body = ApiErrorResponse, example = json!({"msg": "Session is required"})),
(status = 403, description = "Requires admin privileges, or the request needs a higher licence tier (`business_license_required` for a multi-step flow or an OIDC method). A licence refusal carries the same `fields[]` contract as validation errors under an `error` of `license_required`.", body = ApiErrorResponse, example = json!({"error": "license_required", "fields": [{"field": "steps", "code": "business_license_required"}]})),
(status = 403, description = "Requires admin privileges, or the request needs a higher licence tier (`additional_flow_business_license_required` for an additional flow; `business_license_required` for a multi-step flow or an OIDC method). A licence refusal carries the same `fields[]` contract as validation errors under an `error` of `license_required`.", body = ApiErrorResponse, example = json!({"error": "license_required", "fields": [{"field": "flow", "code": "additional_flow_business_license_required"}]})),
(status = 500, description = "Unable to create MFA flow.", body = ApiErrorResponse, example = json!({"msg": "Internal server error"}))
),
security(
Expand Down Expand Up @@ -450,6 +450,13 @@ pub async fn create_mfa_flow(
return Ok(resp);
}

if !is_business_license_active() && MfaFlow::any_exist(&appstate.pool).await? {
return Ok(license_error_response(
"flow".into(),
"additional_flow_business_license_required",
));
}

let mut tx = appstate.pool.begin().await?;
let (flow, steps) = MfaFlow::create(&mut tx, data.title, step_methods).await?;
tx.commit().await?;
Expand Down
54 changes: 54 additions & 0 deletions crates/defguard_core/tests/integration/api/mfa_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,60 @@ async fn test_mfa_flow_single_step_no_license(_: PgPoolOptions, options: PgConne
set_cached_license(saved);
}

/// A free instance may create one flow, while subsequent flows require Business.
#[sqlx::test]
async fn test_additional_mfa_flow_requires_business(_: PgPoolOptions, options: PgConnectOptions) {
let pool = setup_pool(options).await;
let (mut client, _) = make_test_client(pool).await;
authenticate_admin(&mut client).await;
let saved = get_cached_license().clone();
let first_flow = json!({
"title": "Free Flow",
"steps": [{ "methods": ["totp"] }]
});
let second_flow = json!({
"title": "Business Flow",
"steps": [{ "methods": ["biometric"] }]
});

set_cached_license(None);
let response = client
.post("/api/v1/mfa-flow")
.json(&first_flow)
.send()
.await;
assert_eq!(response.status(), StatusCode::CREATED);
client.drain_all_events();

let response = client
.post("/api/v1/mfa-flow")
.json(&second_flow)
.send()
.await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body: serde_json::Value = response.json().await;
assert_eq!(body["error"], "license_required");
assert_eq!(body["fields"][0]["field"], "flow");
assert_eq!(
body["fields"][0]["code"],
"additional_flow_business_license_required"
);
assert!(
client.drain_all_events().is_empty(),
"refused request must not emit an audit event"
);

set_cached_license(saved.clone());
let response = client
.post("/api/v1/mfa-flow")
.json(&second_flow)
.send()
.await;
assert_eq!(response.status(), StatusCode::CREATED);

set_cached_license(saved);
}

/// Multi-step flow (2+ steps) requires a business license.
#[sqlx::test]
async fn test_mfa_flow_multi_step_requires_business(_: PgPoolOptions, options: PgConnectOptions) {
Expand Down
2 changes: 1 addition & 1 deletion web/messages/en/acl.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"acl_aliases_table_title_deployed": "Deployed aliases",
"acl_aliases_table_title_pending": "Pending aliases",
"acl_aliases_empty_deployed_title": "You haven't created any aliases yet.",
"acl_aliases_empty_deployed_subtitle": "Click the first alias by clicking button below.",
"acl_aliases_empty_deployed_subtitle": "Add the first alias by clicking button below.",
"acl_aliases_search_empty_title": "No aliases found.",
"acl_aliases_search_empty_subtitle": "Try different search.",
"acl_destination_col_name": "Destination name",
Expand Down
1 change: 1 addition & 0 deletions web/messages/en/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"cmp_nav_item_destinations": "Destinations",
"cmp_nav_item_aliases": "Aliases",
"cmp_nav_item_posture_checks": "Posture Checks",
"cmp_nav_item_mfa": "MFA Flow",
"cmp_nav_item_users": "Users",
"cmp_nav_item_groups": "Groups",
"cmp_nav_item_enrollment": "Enrollment",
Expand Down
47 changes: 47 additions & 0 deletions web/messages/en/mfa_flow.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"mfa_flows_empty_title": "You don’t have any workflows yet.",
"mfa_flows_empty_subtitle": "Add the first rule by clicking button below.",
"mfa_flows_button_add": "Add new MFA flow",
"mfa_flows_table_heading": "All flows",
"mfa_flows_table_title": "Title",
"mfa_flows_table_steps": "MFA Steps",
"mfa_flow_delete_title": "Delete MFA flow",
"mfa_flow_delete_body": "Are you sure you want to delete MFA flow **{name}**? This action cannot be undone.",
"mfa_flow_deleted": "MFA flow deleted successfully.",
"mfa_flow_delete_failed": "Failed to delete MFA flow.",
"mfa_flow_breadcrumb_create": "Create new MFA flow",
"mfa_flow_breadcrumb_edit": "Edit MFA flow",
"mfa_flow_form_title_create": "Create new workflow",
"mfa_flow_form_title_edit": "Edit workflow",
"mfa_flow_form_subtitle": "Configure different access parameters, including multi-factor authentication requirements, for various user groups.",
"mfa_flow_form_general_settings": "General settings",
"mfa_flow_form_name": "Workflow name",
"mfa_flow_form_methods_title": "Multi-Factor Authentication Methods",
"mfa_flow_form_methods_description": "Define at least one authentication step for users, which will later be used in access conditions for locations.",
"mfa_flow_step_title": "Step {number}",
"mfa_flow_step_add": "Add MFA step",
"mfa_flow_step_required": "Add at least one MFA step.",
"mfa_flow_step_helper": "All factors in a step must be verified. Steps are completed in order.",
"mfa_flow_step_reorder": "Reorder step {number}",
"mfa_flow_step_remove": "Remove step {number}",
"mfa_flow_method_add": "+ Add factor",
"mfa_flow_method_remove": "Remove {method}",
"mfa_flow_method_mobile_client": "Defguard Mobile Client",
"mfa_flow_method_authenticator_app": "Authenticator App",
"mfa_flow_method_external_provider": "External ID Provider",
"mfa_flow_method_email_code": "Email Verification Code",
"mfa_flow_method_smtp_required": "Configure SMTP server in Settings (Notifications tab) to activate this MFA method",
"mfa_flow_method_business_required": "Upgrade your plan to Business to use this MFA method",
"mfa_flow_methods_available_in_plan": "Available in your plan",
"mfa_flow_methods_available_in_higher_plans": "Available in higher plans",
"mfa_flow_method_biometric": "Biometrics",
"mfa_flow_form_action_create": "Create MFA flow",
"mfa_flow_created": "MFA flow created successfully.",
"mfa_flow_updated": "MFA flow updated successfully.",
"mfa_flow_save_failed": "Failed to save MFA flow.",
"mfa_flow_error_additional_flow_business_license": "A Business license is required to create more than one MFA flow.",
"mfa_flow_error_business_license": "A Business license is required for multiple steps and external identity providers.",
"mfa_flow_error_smtp_not_configured": "Configure SMTP before using email verification codes.",
"mfa_flow_error_oidc_provider_missing": "Configure an OpenID provider before using external identity provider verification."
}
3 changes: 2 additions & 1 deletion web/project.inlang/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"./messages/{locale}/flow_end.json",
"./messages/{locale}/support.json",
"./messages/{locale}/acl.json",
"./messages/{locale}/postures.json"
"./messages/{locale}/postures.json",
"./messages/{locale}/mfa_flow.json"
]
}
}
172 changes: 172 additions & 0 deletions web/src/pages/MfaPage/MfaFlowsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useNavigate } from '@tanstack/react-router';
import {
type ColumnFiltersState,
createColumnHelper,
type FilterFn,
getCoreRowModel,
getFilteredRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useMemo, useState } from 'react';
import { m } from '../../paraglide/messages';
import api from '../../shared/api/api';
import type { MfaFlowListItemResponse } from '../../shared/api/types';
import type { SelectionOption } from '../../shared/components/SelectionSection/type';
import { Button } from '../../shared/defguard-ui/components/Button/Button';
import type { ButtonProps } from '../../shared/defguard-ui/components/Button/types';
import { EmptyStateFlexible } from '../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible';
import type { MenuItemsGroup } from '../../shared/defguard-ui/components/Menu/types';
import { Search } from '../../shared/defguard-ui/components/Search/Search';
import { tableEditColumnSize } from '../../shared/defguard-ui/components/table/consts';
import { TableBody } from '../../shared/defguard-ui/components/table/TableBody/TableBody';
import { TableCell } from '../../shared/defguard-ui/components/table/TableCell/TableCell';
import { TableEditCell } from '../../shared/defguard-ui/components/table/TableEditCell/TableEditCell';
import { TableTop } from '../../shared/defguard-ui/components/table/TableTop/TableTop';
import type { TableFilterMessages } from '../../shared/defguard-ui/components/table/types';
import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar';
import { openModal } from '../../shared/hooks/modalControls/modalsSubjects';
import { ModalName } from '../../shared/hooks/modalControls/modalTypes';

type Props = {
flows: MfaFlowListItemResponse[];
addButtonProps: ButtonProps;
};

const columnHelper = createColumnHelper<MfaFlowListItemResponse>();

/** Matches rows whose step count is one of the selected values. */
const filterByStepCount: FilterFn<MfaFlowListItemResponse> = (
row,
columnId,
selectedCounts: number[],
) => selectedCounts.includes(row.getValue<number>(columnId));
filterByStepCount.autoRemove = (value) => !Array.isArray(value) || value.length === 0;

export const MfaFlowsTable = ({ flows, addButtonProps }: Props) => {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const visibleFlows = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
if (!normalizedSearch) return flows;

return flows.filter((flow) => flow.title.toLowerCase().includes(normalizedSearch));
}, [flows, search]);
const stepCountOptions = useMemo(
(): SelectionOption<number>[] =>
[...new Set(flows.map((flow) => flow.step_count))]
.sort((left, right) => left - right)
.map((count) => ({ id: count, label: String(count) })),
[flows],
);
const filterMessages: TableFilterMessages = {
searchPlaceholder: m.controls_search(),
clearButton: m.controls_reset(),
applyButton: m.controls_submit(),
emptyState: m.search_empty_common_title(),
};
const columns = useMemo(
() => [
columnHelper.accessor('title', {
header: m.mfa_flows_table_title(),
minSize: 306,
meta: { flex: true },
cell: (info) => (
<TableCell>
<span>{info.getValue()}</span>
</TableCell>
),
}),
columnHelper.accessor('step_count', {
header: m.mfa_flows_table_steps(),
size: 140,
enableColumnFilter: true,
filterFn: filterByStepCount,
meta: { filterOptions: stepCountOptions },
cell: (info) => (
<TableCell>
<span>{info.getValue()}</span>
</TableCell>
),
}),
columnHelper.display({
id: 'edit',
header: '',
size: tableEditColumnSize,
enableResizing: false,
cell: (info) => {
const flow = info.row.original;
const menuItems: MenuItemsGroup[] = [
{
items: [
{
text: m.controls_edit(),
icon: 'edit',
onClick: () => {
void navigate({
to: '/mfa-flow/$id/edit',
params: { id: String(flow.id) },
});
},
},
],
},
{
items: [
{
text: m.controls_delete(),
icon: 'delete',
variant: 'danger',
onClick: () => {
openModal(ModalName.ConfirmAction, {
title: m.mfa_flow_delete_title(),
contentMd: m.mfa_flow_delete_body({ name: flow.title }),
actionPromise: () => api.mfaFlow.delete(flow.id),
invalidateKeys: [['mfa-flow']],
submitProps: { text: m.controls_delete(), variant: 'critical' },
onSuccess: () => Snackbar.default(m.mfa_flow_deleted()),
onError: () => Snackbar.error(m.mfa_flow_delete_failed()),
});
},
},
],
},
];

return <TableEditCell menuItems={menuItems} />;
},
}),
],
[navigate, stepCountOptions],
);
const table = useReactTable({
state: { columnFilters },
meta: { filterMessages },
columns,
data: visibleFlows,
enableRowSelection: false,
columnResizeMode: 'onChange',
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
getCoreRowModel: getCoreRowModel(),
});
const rows = table.getRowModel().rows;
const hasActiveFilters = search.trim().length > 0 || columnFilters.length > 0;

return (
<>
<TableTop text={m.mfa_flows_table_heading()}>
<Search placeholder={m.controls_search()} value={search} onChange={setSearch} />
<Button {...addButtonProps} />
</TableTop>
<TableBody table={table} />
{rows.length === 0 && hasActiveFilters && (
<EmptyStateFlexible
icon="search"
title={m.search_empty_common_title()}
subtitle={m.search_empty_common_subtitle()}
/>
)}
</>
);
};
Loading
Loading