From 1471fa33cae2246b807953c6bed13c693478df73 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 15:34:26 -0400 Subject: [PATCH 1/2] feat: support configuring login methods --- package-lock.json | 4 +- package.json | 4 +- src/hooks/useSystemConfig.ts | 4 + src/hooks/useUpdateSystemConfig.ts | 3 + src/pages/SystemConfig.test.tsx | 68 ++++++++++++ src/pages/SystemConfig.tsx | 164 ++++++++++++++++++++++++++++- 6 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 src/pages/SystemConfig.test.tsx diff --git a/package-lock.json b/package-lock.json index 7304f77..59c2a44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "seamless-auth-admin-dashboard", - "version": "0.0.9", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seamless-auth-admin-dashboard", - "version": "0.0.9", + "version": "0.1.0", "dependencies": { "@seamless-auth/react": "^0.1.1", "@seamless-auth/types": "^0.1.3", diff --git a/package.json b/package.json index 6cba61c..366ba84 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "seamless-auth-admin-dashboard", "private": true, - "version": "0.0.9", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", @@ -52,4 +52,4 @@ "vite": "^8.0.1", "vitest": "^4.1.5" } -} \ No newline at end of file +} 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..5da6710 100644 --- a/src/pages/SystemConfig.tsx +++ b/src/pages/SystemConfig.tsx @@ -5,14 +5,45 @@ */ 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 +134,10 @@ export default function SystemConfigPage() { label="Dirty state" value={isDirty ? "Unsaved changes" : "In sync"} /> + @@ -127,12 +162,19 @@ export default function SystemConfigPage() { value={`${form.origins.length}`} description="Trusted origins currently allowed for WebAuthn and related flows." /> + + -
+
+
+
+
+ updateField("login_methods", value)} + /> + + + updateField("passkey_login_fallback_enabled", checked) + } + /> +
+
+
void; +}) { + const toggle = (method: LoginMethod) => { + const enabled = value.includes(method); + + if (enabled) { + if (value.length === 1) return; + onChange(value.filter((current) => current !== method)); + return; + } + + onChange([...value, method]); + }; + + return ( + +
+ {LOGIN_METHOD_OPTIONS.map((option) => { + const checked = value.includes(option.value); + const disabled = checked && value.length === 1; + + return ( + + ); + })} +
+
+ ); +} + +function CheckboxField({ + label, + description, + checked, + onChange, +}: { + label: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + function OriginsEditor({ origins, setOrigins, From 51b60b97f9006b73cc78ba29bad2947e24c964d6 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 17 May 2026 16:42:22 -0400 Subject: [PATCH 2/2] ci: linting --- src/components/Section.tsx | 4 +- src/components/Sidebar.tsx | 5 +- src/components/Table.tsx | 132 +++++++++++++++++++------------------ src/pages/SystemConfig.tsx | 3 +- 4 files changed, 73 insertions(+), 71 deletions(-) diff --git a/src/components/Section.tsx b/src/components/Section.tsx index dcc3afc..d0ce3b3 100644 --- a/src/components/Section.tsx +++ b/src/components/Section.tsx @@ -26,7 +26,9 @@ export function Section({ )}
- {actions &&
{actions}
} + {actions && ( +
{actions}
+ )} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 20bbff8..cae9b42 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -104,10 +104,7 @@ function SidebarContent({ ); } -export default function Sidebar({ - mobileOpen = false, - onClose, -}: SidebarProps) { +export default function Sidebar({ mobileOpen = false, onClose }: SidebarProps) { return ( <>