>({
) : (
- {sortedData.map((row, i) => {
- const isSelected = selected.has(i);
+ {sortedData.map((row, i) => {
+ const isSelected = selected.has(i);
- return (
-
- {selectable && (
-
toggleSelect(i)}
- className="accent-[var(--primary)]"
- />
- )}
+ return (
+
+ {selectable && (
+
toggleSelect(i)}
+ className="accent-[var(--primary)]"
+ />
+ )}
- {columns.map((col) => (
-
- {col.render
- ? col.render(row[col.key], row)
- : (row[col.key] as React.ReactNode)}
-
- ))}
-
- {actions.length > 0 && (
-
- {actions.map((action, idx) => {
- const Icon = action.icon;
-
- return (
-
- );
- })}
-
- )}
-
- );
- })}
+ {columns.map((col) => (
+
+ {col.render
+ ? col.render(row[col.key], row)
+ : (row[col.key] as React.ReactNode)}
+
+ ))}
+
+ {actions.length > 0 && (
+
+ {actions.map((action, idx) => {
+ const Icon = action.icon;
+
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
)}
diff --git a/src/hooks/useSystemConfig.ts b/src/hooks/useSystemConfig.ts
index bbc08c2..f51766f 100644
--- a/src/hooks/useSystemConfig.ts
+++ b/src/hooks/useSystemConfig.ts
@@ -8,6 +8,8 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "../lib/api";
+export type LoginMethod = "passkey" | "magic_link" | "email_otp" | "phone_otp";
+
export type SystemConfig = {
app_name: string;
available_roles: string[];
@@ -16,6 +18,8 @@ export type SystemConfig = {
refresh_token_ttl: string;
rate_limit: number;
delay_after: number;
+ login_methods: LoginMethod[];
+ passkey_login_fallback_enabled: boolean;
rpid: string;
origins: string[];
};
diff --git a/src/hooks/useUpdateSystemConfig.ts b/src/hooks/useUpdateSystemConfig.ts
index 2a4aa0b..255babc 100644
--- a/src/hooks/useUpdateSystemConfig.ts
+++ b/src/hooks/useUpdateSystemConfig.ts
@@ -6,6 +6,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "../lib/api";
+import type { LoginMethod } from "./useSystemConfig";
export type SystemConfig = {
app_name: string;
@@ -15,6 +16,8 @@ export type SystemConfig = {
refresh_token_ttl: string;
rate_limit: number;
delay_after: number;
+ login_methods: LoginMethod[];
+ passkey_login_fallback_enabled: boolean;
rpid: string;
origins: string[];
};
diff --git a/src/pages/SystemConfig.test.tsx b/src/pages/SystemConfig.test.tsx
new file mode 100644
index 0000000..277a87f
--- /dev/null
+++ b/src/pages/SystemConfig.test.tsx
@@ -0,0 +1,68 @@
+/*
+ * Copyright © 2026 Fells Code, LLC
+ * Licensed under the GNU Affero General Public License v3.0
+ * See LICENSE file in the project root for full license information
+ */
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import SystemConfigPage from "./SystemConfig";
+
+const mocks = vi.hoisted(() => ({
+ mutate: vi.fn(),
+ useSystemConfig: vi.fn(),
+ useUpdateSystemConfig: vi.fn(),
+}));
+
+vi.mock("../hooks/useSystemConfig", () => ({
+ useSystemConfig: mocks.useSystemConfig,
+}));
+
+vi.mock("../hooks/useUpdateSystemConfig", () => ({
+ useUpdateSystemConfig: mocks.useUpdateSystemConfig,
+}));
+
+const baseConfig = {
+ app_name: "Seamless Auth",
+ available_roles: ["user", "admin"],
+ default_roles: ["user"],
+ access_token_ttl: "15m",
+ refresh_token_ttl: "30d",
+ rate_limit: 100,
+ delay_after: 10,
+ login_methods: ["passkey", "magic_link"],
+ passkey_login_fallback_enabled: true,
+ rpid: "example.com",
+ origins: ["https://example.com"],
+};
+
+describe("SystemConfigPage", () => {
+ beforeEach(() => {
+ mocks.mutate.mockReset();
+ mocks.useSystemConfig.mockReturnValue({
+ data: baseConfig,
+ isLoading: false,
+ });
+ mocks.useUpdateSystemConfig.mockReturnValue({
+ mutate: mocks.mutate,
+ isPending: false,
+ });
+ });
+
+ it("saves selected login policy fields", () => {
+ render(
);
+
+ fireEvent.click(screen.getByRole("checkbox", { name: /email otp/i }));
+ fireEvent.click(
+ screen.getByRole("checkbox", { name: /passkey login fallback/i }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
+
+ expect(mocks.mutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ login_methods: ["passkey", "magic_link", "email_otp"],
+ passkey_login_fallback_enabled: false,
+ }),
+ );
+ });
+});
diff --git a/src/pages/SystemConfig.tsx b/src/pages/SystemConfig.tsx
index fb74761..47e91bb 100644
--- a/src/pages/SystemConfig.tsx
+++ b/src/pages/SystemConfig.tsx
@@ -5,14 +5,46 @@
*/
import { useMemo, useState } from "react";
-import { ShieldCheck, TimerReset, Waypoints } from "lucide-react";
-import { useSystemConfig, type SystemConfig } from "../hooks/useSystemConfig";
+import { KeyRound, ShieldCheck, TimerReset, Waypoints } from "lucide-react";
+import {
+ useSystemConfig,
+ type LoginMethod,
+ type SystemConfig,
+} from "../hooks/useSystemConfig";
import { useUpdateSystemConfig } from "../hooks/useUpdateSystemConfig";
import Skeleton from "../components/Skeleton";
import RoleChips from "../components/RoleChips";
import { Section } from "../components/Section";
import StatCard from "../components/StatCard";
+const LOGIN_METHOD_OPTIONS: {
+ value: LoginMethod;
+ label: string;
+ description: string;
+}[] = [
+ {
+ value: "passkey",
+ label: "Passkeys",
+ description:
+ "WebAuthn passkey login for users with registered credentials.",
+ },
+ {
+ value: "magic_link",
+ label: "Magic Links",
+ description: "Email sign-in links for passwordless login fallback.",
+ },
+ {
+ value: "email_otp",
+ label: "Email OTP",
+ description: "One-time email codes after login initiation.",
+ },
+ {
+ value: "phone_otp",
+ label: "SMS OTP",
+ description: "One-time SMS codes after login initiation.",
+ },
+];
+
export default function SystemConfigPage() {
const { data, isLoading } = useSystemConfig();
const update = useUpdateSystemConfig();
@@ -103,6 +135,10 @@ export default function SystemConfigPage() {
label="Dirty state"
value={isDirty ? "Unsaved changes" : "In sync"}
/>
+