From 2bca5e26b57507f531744a849bcafcc86808094c Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Mon, 1 Jun 2026 22:25:50 +0100 Subject: [PATCH 1/5] feat(api,admin-ui): support manual email verification --- packages/admin-ui/src/pages/UserEdit.tsx | 14 ++++- .../src/pages/UserKeyManagementAdmin.test.js | 9 +++ packages/admin-ui/src/services/api.ts | 3 +- .../api/src/controllers/admin/userUpdate.ts | 3 + packages/api/src/controllers/admin/users.ts | 1 + packages/api/src/models/users.test.ts | 61 ++++++++++++++++++- packages/api/src/models/users.ts | 28 ++++++++- 7 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/admin-ui/src/pages/UserEdit.tsx b/packages/admin-ui/src/pages/UserEdit.tsx index 5f5d44db..73e31e7d 100644 --- a/packages/admin-ui/src/pages/UserEdit.tsx +++ b/packages/admin-ui/src/pages/UserEdit.tsx @@ -1,6 +1,7 @@ import { AlertTriangle, KeyRound, ShieldCheck, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import CheckboxRow from "@/components/form/checkbox-row"; import FormActions from "@/components/layout/form-actions"; import { FormField, FormGrid } from "@/components/layout/form-grid"; import PageHeader from "@/components/layout/page-header"; @@ -38,6 +39,7 @@ export default function UserEdit() { const [keyStatusUnavailable, setKeyStatusUnavailable] = useState(false); const [resetEmailSending, setResetEmailSending] = useState(false); const [resetEmailMessage, setResetEmailMessage] = useState(null); + const [emailVerified, setEmailVerified] = useState(false); const load = useCallback(async () => { try { @@ -53,6 +55,7 @@ export default function UserEdit() { setUser(u); setEmail(u.email); setName(u.name || ""); + setEmailVerified(!!u.emailVerifiedAt); try { const s = await adminApiService.getUserOtpStatus(u.sub); setOtpStatus(s); @@ -81,7 +84,7 @@ export default function UserEdit() { try { setSubmitting(true); setError(null); - await adminApiService.updateUser(user.sub, { email, name: name || null }); + await adminApiService.updateUser(user.sub, { email, name: name || null, emailVerified }); navigate("/users"); } catch (e) { setError(e instanceof Error ? e.message : "Failed to save user"); @@ -185,6 +188,15 @@ export default function UserEdit() { disabled={submitting} /> + Email Status}> + + Name}> setName(e.target.value)} disabled={submitting} /> diff --git a/packages/admin-ui/src/pages/UserKeyManagementAdmin.test.js b/packages/admin-ui/src/pages/UserKeyManagementAdmin.test.js index 64ed7ea5..67f9b954 100644 --- a/packages/admin-ui/src/pages/UserKeyManagementAdmin.test.js +++ b/packages/admin-ui/src/pages/UserKeyManagementAdmin.test.js @@ -58,6 +58,15 @@ test("user detail includes key status inventory and revoke hooks", () => { assert.notEqual(userSource.indexOf("revokeUserTrustedDevice"), -1); }); +test("user detail supports manual email verification", () => { + assert.notEqual(apiSource.indexOf("emailVerifiedAt?: string | null"), -1); + assert.notEqual(apiSource.indexOf("emailVerified?: boolean"), -1); + assert.notEqual(userSource.indexOf("Email Status"), -1); + assert.notEqual(userSource.indexOf('label="Verified"'), -1); + assert.notEqual(userSource.indexOf("setEmailVerified"), -1); + assert.notEqual(userSource.indexOf("emailVerified })"), -1); +}); + test("federation page exposes enterprise federation policy controls", () => { assert.notEqual(apiSource.indexOf("FederationPolicyControls"), -1); assert.notEqual(federationSource.indexOf("defaultFederationPolicy"), -1); diff --git a/packages/admin-ui/src/services/api.ts b/packages/admin-ui/src/services/api.ts index cb624ba2..6676bd99 100644 --- a/packages/admin-ui/src/services/api.ts +++ b/packages/admin-ui/src/services/api.ts @@ -89,6 +89,7 @@ export interface User { name?: string; createdAt: string; lastActivityAt?: string | null; + emailVerifiedAt?: string | null; passwordResetRequired?: boolean; permissions?: string[]; } @@ -760,7 +761,7 @@ class AdminApiService { async updateUser( userSub: string, - updates: { email?: string | null; name?: string | null } + updates: { email?: string | null; name?: string | null; emailVerified?: boolean } ): Promise { return this.request(`/users/${userSub}`, { method: "PUT", diff --git a/packages/api/src/controllers/admin/userUpdate.ts b/packages/api/src/controllers/admin/userUpdate.ts index 66ea5c27..9dcaf864 100644 --- a/packages/api/src/controllers/admin/userUpdate.ts +++ b/packages/api/src/controllers/admin/userUpdate.ts @@ -11,6 +11,7 @@ const Req = z .object({ email: z.string().email().nullable().optional(), name: z.string().nullable().optional(), + emailVerified: z.boolean().optional(), }) .partial(); @@ -19,6 +20,7 @@ const Resp = z sub: z.string(), email: z.string().nullable().optional(), name: z.string().nullable().optional(), + emailVerifiedAt: z.date().or(z.string()).nullable().optional(), }) .partial(); @@ -53,6 +55,7 @@ async function updateUserHandler( : payload.name === null ? undefined : payload.name.trim(), + emailVerified: payload.emailVerified, }); sendJson(response, 200, updated); } diff --git a/packages/api/src/controllers/admin/users.ts b/packages/api/src/controllers/admin/users.ts index 75d9d91f..3049dbc2 100644 --- a/packages/api/src/controllers/admin/users.ts +++ b/packages/api/src/controllers/admin/users.ts @@ -15,6 +15,7 @@ const UserSchema = z.object({ name: z.string().nullable().optional(), createdAt: z.date().or(z.string()), lastActivityAt: z.date().or(z.string()).nullable().optional(), + emailVerifiedAt: z.date().or(z.string()).nullable().optional(), passwordResetRequired: z.boolean().optional(), organizationRoles: z .array( diff --git a/packages/api/src/models/users.test.ts b/packages/api/src/models/users.test.ts index 9a13470e..1de01ef3 100644 --- a/packages/api/src/models/users.test.ts +++ b/packages/api/src/models/users.test.ts @@ -7,7 +7,7 @@ import { eq } from "drizzle-orm"; import { createPglite } from "../db/pglite.ts"; import { opaqueRecords, organizationMembers, organizations, users } from "../db/schema.ts"; import type { Context } from "../types.ts"; -import { createUser, getUserOpaqueRecordByEmail } from "./users.ts"; +import { createUser, getUserOpaqueRecordByEmail, listUsers, updateUserBasic } from "./users.ts"; function createLogger() { return { @@ -120,3 +120,62 @@ test("createUser can assign an existing organization without creating a personal fs.rmSync(directory, { recursive: true, force: true }); } }); + +test("listUsers returns email verification state", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-users-list-verified-test-")); + const { db, close } = await createPglite(directory); + const context = { db, logger: createLogger() } as Context; + + try { + const emailVerifiedAt = new Date("2026-05-30T10:00:00.000Z"); + await db.insert(users).values({ + sub: "verified-list-user", + email: "verified-list@example.com", + opaqueLoginIdentity: "verified-list@example.com", + name: "Verified List", + emailVerifiedAt, + }); + + const result = await listUsers(context, { search: "verified-list@example.com" }); + assert.equal(result.users.length, 1); + assert.equal(result.users[0]?.emailVerifiedAt?.toISOString(), emailVerifiedAt.toISOString()); + } finally { + await close(); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("admin user update toggles email verification", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-users-mark-verified-test-")); + const { db, close } = await createPglite(directory); + const context = { db, logger: createLogger() } as Context; + + try { + await db.insert(users).values({ + sub: "manual-verify-user", + email: "old-verified@example.com", + opaqueLoginIdentity: "old-verified@example.com", + name: "Manual Verify", + emailVerifiedAt: new Date("2026-05-30T10:00:00.000Z"), + }); + + const updatedEmail = await updateUserBasic(context, "manual-verify-user", { + email: "new-verified@example.com", + }); + assert.equal(updatedEmail.email, "new-verified@example.com"); + assert.equal(updatedEmail.emailVerifiedAt, null); + + const verified = await updateUserBasic(context, "manual-verify-user", { + emailVerified: true, + }); + assert.ok(verified.emailVerifiedAt); + + const unverified = await updateUserBasic(context, "manual-verify-user", { + emailVerified: false, + }); + assert.equal(unverified.emailVerifiedAt, null); + } finally { + await close(); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/api/src/models/users.ts b/packages/api/src/models/users.ts index 5e59f361..8186e58f 100644 --- a/packages/api/src/models/users.ts +++ b/packages/api/src/models/users.ts @@ -145,6 +145,7 @@ export async function listUsers( name: users.name, createdAt: users.createdAt, lastActivityAt: users.lastActivityAt, + emailVerifiedAt: users.emailVerifiedAt, passwordResetRequired: users.passwordResetRequired, }) .from(users); @@ -340,7 +341,7 @@ export async function getUserByOpaqueLoginIdentity(context: Context, identity: s export async function updateUserBasic( context: Context, sub: string, - data: { email?: string | null; name?: string | null } + data: { email?: string | null; name?: string | null; emailVerified?: boolean } ) { const existing = await context.db.query.users.findFirst({ where: eq(users.sub, sub) }); if (!existing) throw new NotFoundError("User not found"); @@ -348,7 +349,11 @@ export async function updateUserBasic( email?: string | null; name?: string | null; opaqueLoginIdentity?: string | null; + emailVerifiedAt?: Date | null; + pendingEmail?: string | null; + pendingEmailSetAt?: Date | null; } = {}; + let emailChanged = false; if ("email" in data) { if (data.email === null || data.email === "") { updates.email = null; @@ -365,12 +370,33 @@ export async function updateUserBasic( } else { throw new ValidationError("Invalid email value"); } + emailChanged = updates.email !== existing.email; + if (emailChanged) { + updates.emailVerifiedAt = null; + updates.pendingEmail = null; + updates.pendingEmailSetAt = null; + } } if ("name" in data) { if (data.name === null) updates.name = null; else if (typeof data.name === "string") updates.name = data.name.trim(); else throw new ValidationError("Invalid name value"); } + if ("emailVerified" in data) { + if (data.emailVerified === true) { + if (!("email" in updates ? updates.email : existing.email)) { + throw new ValidationError("User email is required"); + } + updates.emailVerifiedAt = + existing.emailVerifiedAt && !emailChanged ? existing.emailVerifiedAt : new Date(); + updates.pendingEmail = null; + updates.pendingEmailSetAt = null; + } else if (data.emailVerified === false) { + updates.emailVerifiedAt = null; + } else { + throw new ValidationError("Invalid email verification value"); + } + } if (Object.keys(updates).length === 0) return existing; if ("email" in updates && updates.email) { const existingIdentity = existing.opaqueLoginIdentity || existing.email; From f2e2520a85b8eb0a77c652ee721a76fb37b76144 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Mon, 1 Jun 2026 22:26:04 +0100 Subject: [PATCH 2/5] feat(admin-ui): refine organization admin flows --- .../form/organization-combobox.module.css | 61 +++++ .../components/form/organization-combobox.tsx | 210 ++++++++++++++++++ .../src/components/ui/tabs.module.css | 6 +- packages/admin-ui/src/pages/AuditLogs.tsx | 22 +- .../src/pages/FederationConnections.tsx | 38 +--- .../src/pages/OrganizationEdit.module.css | 14 ++ .../admin-ui/src/pages/OrganizationEdit.tsx | 20 +- .../src/pages/OrganizationRefactorUi.test.js | 25 +++ packages/admin-ui/src/pages/ScimTokens.tsx | 44 +--- packages/admin-ui/src/services/api.ts | 3 + .../src/controllers/admin/auditLogExport.ts | 2 + .../api/src/controllers/admin/auditLogs.ts | 4 + packages/api/src/models/auditLogs.ts | 1 + 13 files changed, 363 insertions(+), 87 deletions(-) create mode 100644 packages/admin-ui/src/components/form/organization-combobox.module.css create mode 100644 packages/admin-ui/src/components/form/organization-combobox.tsx diff --git a/packages/admin-ui/src/components/form/organization-combobox.module.css b/packages/admin-ui/src/components/form/organization-combobox.module.css new file mode 100644 index 00000000..9eff32d0 --- /dev/null +++ b/packages/admin-ui/src/components/form/organization-combobox.module.css @@ -0,0 +1,61 @@ +.root { + position: relative; + width: 100%; +} + +.trigger { + width: 100%; + justify-content: space-between; + font-weight: 400; +} + +.value { + min-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.popover { + position: absolute; + z-index: 60; + top: calc(100% + 8px); + left: 0; + width: 100%; + min-width: min(420px, 100vw); + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--popover)); + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.42); +} + +.item { + justify-content: space-between; +} + +.itemLabel { + min-width: 0; + display: grid; + gap: 2px; +} + +.itemLabel strong, +.itemLabel small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemLabel small { + color: hsl(var(--muted-foreground)); +} + +.footer { + display: flex; + justify-content: center; + padding: 8px; + border-top: 1px solid hsl(var(--border)); +} diff --git a/packages/admin-ui/src/components/form/organization-combobox.tsx b/packages/admin-ui/src/components/form/organization-combobox.tsx new file mode 100644 index 00000000..79d40fd1 --- /dev/null +++ b/packages/admin-ui/src/components/form/organization-combobox.tsx @@ -0,0 +1,210 @@ +import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import adminApiService, { type Organization } from "@/services/api"; +import styles from "./organization-combobox.module.css"; + +interface OrganizationComboboxProps { + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; +} + +function labelForOrganization(organization: Organization) { + return organization.slug ? `${organization.name} (${organization.slug})` : organization.name; +} + +export default function OrganizationCombobox({ + value, + onValueChange, + placeholder = "Select an organization", + disabled, +}: OrganizationComboboxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [organizations, setOrganizations] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [loading, setLoading] = useState(false); + const [selectedOrganization, setSelectedOrganization] = useState(null); + const rootRef = useRef(null); + + useEffect(() => { + const handle = window.setTimeout(() => setDebouncedSearch(search.trim()), 250); + return () => window.clearTimeout(handle); + }, [search]); + + const selected = useMemo( + () => + organizations.find((organization) => organization.organizationId === value) || + selectedOrganization, + [organizations, selectedOrganization, value] + ); + + const loadOrganizations = useCallback( + async (nextPage: number, query: string, replace: boolean) => { + try { + setLoading(true); + const response = await adminApiService.getOrganizationsPaged({ + page: nextPage, + limit: 25, + search: query || undefined, + sortBy: "name", + sortOrder: "asc", + }); + setPage(response.pagination.page); + setTotalPages(response.pagination.totalPages); + setOrganizations((current) => + replace + ? response.organizations + : [ + ...current, + ...response.organizations.filter( + (organization) => + !current.some( + (existing) => existing.organizationId === organization.organizationId + ) + ), + ] + ); + } finally { + setLoading(false); + } + }, + [] + ); + + useEffect(() => { + if (!open) return; + loadOrganizations(1, debouncedSearch, true).catch(() => { + setOrganizations([]); + setPage(1); + setTotalPages(1); + }); + }, [debouncedSearch, loadOrganizations, open]); + + useEffect(() => { + if (!value) { + setSelectedOrganization(null); + return; + } + if (organizations.some((organization) => organization.organizationId === value)) return; + let cancelled = false; + adminApiService + .getOrganization(value) + .then((organization) => { + if (!cancelled) setSelectedOrganization(organization); + }) + .catch(() => { + if (!cancelled) setSelectedOrganization(null); + }); + return () => { + cancelled = true; + }; + }, [organizations, value]); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node | null; + if (target && rootRef.current && !rootRef.current.contains(target)) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + window.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + const loadMore = () => { + if (loading || page >= totalPages) return; + loadOrganizations(page + 1, debouncedSearch, false).catch(() => {}); + }; + + return ( +
+ + {open ? ( +
+ + + + + {loading ? "Loading organizations..." : "No organizations found"} + + + {organizations.map((organization) => { + const active = organization.organizationId === value; + return ( + { + onValueChange(organization.organizationId); + setSelectedOrganization(organization); + setOpen(false); + }} + > + + {organization.name} + {organization.slug ? {organization.slug} : null} + + {active ? : null} + + ); + })} + + + {loading || page < totalPages ? ( +
+ +
+ ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/packages/admin-ui/src/components/ui/tabs.module.css b/packages/admin-ui/src/components/ui/tabs.module.css index fc0bf852..b51a3cc8 100644 --- a/packages/admin-ui/src/components/ui/tabs.module.css +++ b/packages/admin-ui/src/components/ui/tabs.module.css @@ -4,7 +4,7 @@ height: auto; justify-content: flex-start; align-items: center; - gap: 4px; + gap: 0; padding: 0; background-color: transparent; border-bottom: 1px solid hsl(var(--border)); @@ -19,8 +19,8 @@ height: 40px; padding: 0 1.25rem; white-space: nowrap; - font-size: 14px; - font-weight: 500; + font-size: 0.875rem; + font-weight: 600; transition: all 0.2s; background: transparent; border: 1px solid transparent; diff --git a/packages/admin-ui/src/pages/AuditLogs.tsx b/packages/admin-ui/src/pages/AuditLogs.tsx index aaf291b5..ef045ebf 100644 --- a/packages/admin-ui/src/pages/AuditLogs.tsx +++ b/packages/admin-ui/src/pages/AuditLogs.tsx @@ -1,6 +1,6 @@ import { Download, Eye, FileText, Filter, X } from "lucide-react"; import { useCallback, useEffect, useId, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import EmptyState from "@/components/empty-state"; import ErrorBanner from "@/components/feedback/error-banner"; import PageHeader from "@/components/layout/page-header"; @@ -59,6 +59,8 @@ const EVENT_TYPES = [ export default function AuditLogs() { const uid = useId(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const organizationIdFilter = searchParams.get("organizationId") || ""; const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -78,6 +80,7 @@ export default function AuditLogs() { limit: pageSize, sortBy: "timestamp", sortOrder: "desc", + organizationId: organizationIdFilter || undefined, }); const loadAuditLogs = useCallback(async () => { @@ -101,6 +104,14 @@ export default function AuditLogs() { loadAuditLogs(); }, [loadAuditLogs]); + useEffect(() => { + setFilters((prev) => ({ + ...prev, + page: 1, + organizationId: organizationIdFilter || undefined, + })); + }, [organizationIdFilter]); + useEffect(() => { const handle = setTimeout(() => setDebouncedSearch(searchQuery.trim()), 300); return () => clearTimeout(handle); @@ -129,6 +140,11 @@ export default function AuditLogs() { setSuccessFilter(""); setStartDate(""); setEndDate(""); + setSearchParams((current) => { + const next = new URLSearchParams(current); + next.delete("organizationId"); + return next; + }); setFilters({ page: 1, limit: pageSize, sortBy: "timestamp", sortOrder: "desc" }); }; @@ -305,7 +321,9 @@ export default function AuditLogs() { setShowFilters(!showFilters)}> diff --git a/packages/admin-ui/src/pages/FederationConnections.tsx b/packages/admin-ui/src/pages/FederationConnections.tsx index 63ceb918..0248451d 100644 --- a/packages/admin-ui/src/pages/FederationConnections.tsx +++ b/packages/admin-ui/src/pages/FederationConnections.tsx @@ -2,6 +2,7 @@ import { Edit, GitBranch, Plus, Search, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import EmptyState from "@/components/empty-state"; import ErrorBanner from "@/components/feedback/error-banner"; +import OrganizationCombobox from "@/components/form/organization-combobox"; import FormActions from "@/components/layout/form-actions"; import { FormField, FormGrid } from "@/components/layout/form-grid"; import PageHeader from "@/components/layout/page-header"; @@ -42,7 +43,6 @@ import adminApiService, { type FederationConnection, type FederationConnectionRequest, type FederationPolicyControls, - type Organization, type SortOrder, } from "@/services/api"; @@ -210,7 +210,6 @@ function buildPayload(form: FormState, isEdit: boolean): FederationConnectionReq export default function FederationConnections() { const [connections, setConnections] = useState([]); - const [organizations, setOrganizations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -256,21 +255,6 @@ export default function FederationConnections() { loadConnections(); }, [loadConnections]); - useEffect(() => { - let cancelled = false; - adminApiService - .getOrganizationsPaged({ page: 1, limit: 100, sortBy: "name", sortOrder: "asc" }) - .then((response) => { - if (!cancelled) setOrganizations(response.organizations); - }) - .catch(() => { - if (!cancelled) setOrganizations([]); - }); - return () => { - cancelled = true; - }; - }, []); - useEffect(() => { const handle = setTimeout(() => { setDebouncedSearch(searchQuery); @@ -597,28 +581,12 @@ export default function FederationConnections() { /> Organization{editing ? "" : " *"}}> - + /> Issuer}>
diff --git a/packages/admin-ui/src/pages/OrganizationEdit.module.css b/packages/admin-ui/src/pages/OrganizationEdit.module.css index 80e7d709..5f9a1eb2 100644 --- a/packages/admin-ui/src/pages/OrganizationEdit.module.css +++ b/packages/admin-ui/src/pages/OrganizationEdit.module.css @@ -4,6 +4,12 @@ gap: 6px; } +.tabCard { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: none; +} + .roleTag { display: inline-flex; align-items: center; @@ -143,3 +149,11 @@ min-width: 0; overflow-wrap: anywhere; } + +@media (max-width: 640px) { + .tabCard { + border-top-left-radius: 0.75rem; + border-top-right-radius: 0.75rem; + border-top: 1px solid hsl(var(--border)); + } +} diff --git a/packages/admin-ui/src/pages/OrganizationEdit.tsx b/packages/admin-ui/src/pages/OrganizationEdit.tsx index 41ce6d6b..fd4be191 100644 --- a/packages/admin-ui/src/pages/OrganizationEdit.tsx +++ b/packages/admin-ui/src/pages/OrganizationEdit.tsx @@ -456,7 +456,7 @@ export default function OrganizationEdit() { - + Members Manage organization members and roles @@ -544,7 +544,7 @@ export default function OrganizationEdit() { - + Roles Review role templates available to this organization @@ -580,7 +580,7 @@ export default function OrganizationEdit() { - + Enterprise Connections Organization-owned SSO and SCIM surfaces @@ -655,7 +655,7 @@ export default function OrganizationEdit() { - + Organization Details @@ -699,13 +699,21 @@ export default function OrganizationEdit() { - + Audit Organization-scoped audit events will appear here. - diff --git a/packages/admin-ui/src/pages/OrganizationRefactorUi.test.js b/packages/admin-ui/src/pages/OrganizationRefactorUi.test.js index b2f30883..85c6c2a9 100644 --- a/packages/admin-ui/src/pages/OrganizationRefactorUi.test.js +++ b/packages/admin-ui/src/pages/OrganizationRefactorUi.test.js @@ -11,6 +11,13 @@ const roleEditSource = readFileSync(resolve(here, "RoleEdit.tsx"), "utf8"); const rolesSource = readFileSync(resolve(here, "Roles.tsx"), "utf8"); const userCreateSource = readFileSync(resolve(here, "UserCreate.tsx"), "utf8"); const orgEditSource = readFileSync(resolve(here, "OrganizationEdit.tsx"), "utf8"); +const scimTokensSource = readFileSync(resolve(here, "ScimTokens.tsx"), "utf8"); +const federationSource = readFileSync(resolve(here, "FederationConnections.tsx"), "utf8"); +const organizationComboboxSource = readFileSync( + resolve(here, "../components/form/organization-combobox.tsx"), + "utf8" +); +const auditLogsSource = readFileSync(resolve(here, "AuditLogs.tsx"), "utf8"); test("admin role UI supports organization role flags", () => { const source = [apiSource, roleCreateSource, roleEditSource, rolesSource].join("\n"); @@ -33,4 +40,22 @@ test("admin organization detail exposes tabs and enterprise placeholders", () => assert.notEqual(orgEditSource.indexOf("getScimTokens"), -1); assert.notEqual(orgEditSource.indexOf("Open Federation"), -1); assert.notEqual(orgEditSource.indexOf("Open SCIM Tokens"), -1); + assert.notEqual(orgEditSource.indexOf("/audit?organizationId="), -1); + assert.equal(orgEditSource.indexOf('navigate("/audit-logs")'), -1); +}); + +test("admin organization selectors use async typeahead", () => { + assert.notEqual(organizationComboboxSource.indexOf("getOrganizationsPaged"), -1); + assert.notEqual(organizationComboboxSource.indexOf("limit: 25"), -1); + assert.notEqual(organizationComboboxSource.indexOf("Load more"), -1); + assert.notEqual(scimTokensSource.indexOf("OrganizationCombobox"), -1); + assert.notEqual(federationSource.indexOf("OrganizationCombobox"), -1); + assert.equal(scimTokensSource.indexOf("limit: 100"), -1); + assert.equal(federationSource.indexOf("limit: 100"), -1); +}); + +test("admin audit logs preserve organization filters", () => { + assert.notEqual(auditLogsSource.indexOf("organizationIdFilter"), -1); + assert.notEqual(apiSource.indexOf("organizationId?: string"), -1); + assert.notEqual(apiSource.indexOf('params.append("organizationId"'), -1); }); diff --git a/packages/admin-ui/src/pages/ScimTokens.tsx b/packages/admin-ui/src/pages/ScimTokens.tsx index e665248d..a16813c4 100644 --- a/packages/admin-ui/src/pages/ScimTokens.tsx +++ b/packages/admin-ui/src/pages/ScimTokens.tsx @@ -2,6 +2,7 @@ import { Copy, KeyRound, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import EmptyState from "@/components/empty-state"; import ErrorBanner from "@/components/feedback/error-banner"; +import OrganizationCombobox from "@/components/form/organization-combobox"; import FormActions from "@/components/layout/form-actions"; import { FormField, FormGrid } from "@/components/layout/form-grid"; import PageHeader from "@/components/layout/page-header"; @@ -20,13 +21,6 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Table, TableBody, @@ -35,7 +29,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import adminApiService, { type Organization, type ScimBearerToken } from "@/services/api"; +import adminApiService, { type ScimBearerToken } from "@/services/api"; function isActive(token: ScimBearerToken) { if (token.revokedAt) return false; @@ -51,7 +45,6 @@ export default function ScimTokens() { const [name, setName] = useState(""); const [expiresAt, setExpiresAt] = useState(""); const [organizationId, setOrganizationId] = useState(""); - const [organizations, setOrganizations] = useState([]); const [creating, setCreating] = useState(false); const [createdToken, setCreatedToken] = useState(null); @@ -72,21 +65,6 @@ export default function ScimTokens() { loadTokens(); }, [loadTokens]); - useEffect(() => { - let cancelled = false; - adminApiService - .getOrganizationsPaged({ page: 1, limit: 100, sortBy: "name", sortOrder: "asc" }) - .then((response) => { - if (!cancelled) setOrganizations(response.organizations); - }) - .catch(() => { - if (!cancelled) setOrganizations([]); - }); - return () => { - cancelled = true; - }; - }, []); - const create = async () => { if (!name.trim() || !organizationId) return; try { @@ -278,23 +256,7 @@ export default function ScimTokens() { Organization *}> - + Name}> setName(event.target.value)} /> diff --git a/packages/admin-ui/src/services/api.ts b/packages/admin-ui/src/services/api.ts index 6676bd99..6311977f 100644 --- a/packages/admin-ui/src/services/api.ts +++ b/packages/admin-ui/src/services/api.ts @@ -350,6 +350,7 @@ export interface AuditLogFilters extends ListQueryParams { success?: boolean; startDate?: string; endDate?: string; + organizationId?: string; } export interface AdminSetting { @@ -1393,6 +1394,7 @@ class AdminApiService { if (filters?.success !== undefined) params.append("success", filters.success.toString()); if (filters?.startDate) params.append("startDate", filters.startDate); if (filters?.endDate) params.append("endDate", filters.endDate); + if (filters?.organizationId) params.append("organizationId", filters.organizationId); if (filters?.search) params.append("search", filters.search); if (filters?.page) params.append("page", filters.page.toString()); if (filters?.limit) params.append("limit", filters.limit.toString()); @@ -1416,6 +1418,7 @@ class AdminApiService { if (filters?.success !== undefined) params.append("success", filters.success.toString()); if (filters?.startDate) params.append("startDate", filters.startDate); if (filters?.endDate) params.append("endDate", filters.endDate); + if (filters?.organizationId) params.append("organizationId", filters.organizationId); const queryString = params.toString(); const endpoint = queryString ? `/audit-logs/export?${queryString}` : "/audit-logs/export"; diff --git a/packages/api/src/controllers/admin/auditLogExport.ts b/packages/api/src/controllers/admin/auditLogExport.ts index 7e64303d..0337573f 100644 --- a/packages/api/src/controllers/admin/auditLogExport.ts +++ b/packages/api/src/controllers/admin/auditLogExport.ts @@ -25,6 +25,7 @@ export async function getAuditLogExport( userId: z.string().optional(), adminId: z.string().optional(), clientId: z.string().optional(), + organizationId: z.string().uuid().optional(), resourceType: z.string().optional(), resourceId: z.string().optional(), success: z.string().optional(), @@ -54,6 +55,7 @@ const Query = z.object({ userId: z.string().optional(), adminId: z.string().optional(), clientId: z.string().optional(), + organizationId: z.string().optional(), resourceType: z.string().optional(), resourceId: z.string().optional(), success: z.boolean().optional(), diff --git a/packages/api/src/controllers/admin/auditLogs.ts b/packages/api/src/controllers/admin/auditLogs.ts index 6f965fb2..b9903ce6 100644 --- a/packages/api/src/controllers/admin/auditLogs.ts +++ b/packages/api/src/controllers/admin/auditLogs.ts @@ -51,6 +51,7 @@ export const AuditLogsListResponseSchema = z.object({ userId: z.string().optional(), adminId: z.string().optional(), clientId: z.string().optional(), + organizationId: z.string().uuid().optional(), resourceType: z.string().optional(), resourceId: z.string().optional(), success: z.boolean().optional(), @@ -81,6 +82,7 @@ export async function getAuditLogs( userId: z.string().optional(), adminId: z.string().optional(), clientId: z.string().optional(), + organizationId: z.string().uuid().optional(), resourceType: z.string().optional(), resourceId: z.string().optional(), success: z @@ -110,6 +112,7 @@ export async function getAuditLogs( userId: parsed.userId, adminId: parsed.adminId, clientId: parsed.clientId, + organizationId: parsed.organizationId, resourceType: parsed.resourceType, resourceId: parsed.resourceId, success: parsed.success, @@ -168,6 +171,7 @@ export const schema = { userId: z.string().optional(), adminId: z.string().optional(), clientId: z.string().optional(), + organizationId: z.string().optional(), resourceType: z.string().optional(), resourceId: z.string().optional(), success: z.boolean().optional(), diff --git a/packages/api/src/models/auditLogs.ts b/packages/api/src/models/auditLogs.ts index 1bd36cae..0b007e84 100644 --- a/packages/api/src/models/auditLogs.ts +++ b/packages/api/src/models/auditLogs.ts @@ -15,6 +15,7 @@ export interface AuditLogFilters { userId?: string; adminId?: string; clientId?: string; + organizationId?: string; resourceType?: string; resourceId?: string; success?: boolean; From f91b25684bfb839327eeec8f38c13b6cacfdf0b1 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Mon, 1 Jun 2026 22:26:19 +0100 Subject: [PATCH 3/5] feat(user-ui): add account organization switcher --- packages/user-ui/src/App.tsx | 665 +++++++++++------- .../src/components/ChangePasswordView.tsx | 1 - .../user-ui/src/components/Dashboard.test.js | 9 +- packages/user-ui/src/components/Dashboard.tsx | 6 +- packages/user-ui/src/components/Login.tsx | 13 +- packages/user-ui/src/components/LoginView.tsx | 2 + .../user-ui/src/components/OtpSetupView.tsx | 1 - .../user-ui/src/components/Profile.test.js | 10 +- packages/user-ui/src/components/Profile.tsx | 56 +- packages/user-ui/src/components/Register.tsx | 4 + .../user-ui/src/components/RegisterView.tsx | 2 + .../src/components/SettingsSecurityView.tsx | 1 - .../src/components/UserLayout.module.css | 182 ++++- .../user-ui/src/components/UserLayout.tsx | 139 +++- .../src/components/UserPortalContext.tsx | 28 + 15 files changed, 728 insertions(+), 391 deletions(-) create mode 100644 packages/user-ui/src/components/UserPortalContext.tsx diff --git a/packages/user-ui/src/App.tsx b/packages/user-ui/src/App.tsx index 600863ee..e8321adf 100644 --- a/packages/user-ui/src/App.tsx +++ b/packages/user-ui/src/App.tsx @@ -20,8 +20,9 @@ import Profile from "./components/Profile"; import RegisterView from "./components/RegisterView"; import SettingsSecurityView from "./components/SettingsSecurityView"; import SwitchOrg from "./components/SwitchOrg"; +import { UserPortalProvider } from "./components/UserPortalContext"; import VerifyEmailView from "./components/VerifyEmailView"; -import apiService from "./services/api"; +import apiService, { type UserOrganization } from "./services/api"; import { clearAllDrk } from "./services/drkStorage"; import { clearAllExportKeys } from "./services/sessionKey"; import { clearAllUnlockedArks } from "./services/unlockedArk"; @@ -72,6 +73,9 @@ function AppContent() { const navigate = useNavigate(); const location = useLocation(); const [sessionData, setSessionData] = useState(null); + const [organizations, setOrganizations] = useState([]); + const [organizationsLoading, setOrganizationsLoading] = useState(false); + const [activeOrganizationLabel, setActiveOrganizationLabel] = useState(null); const [authRequest, setAuthRequest] = useState(null); const [loading, setLoading] = useState(true); const [authRequestSearch, setAuthRequestSearch] = useState(null); @@ -205,6 +209,45 @@ function AppContent() { initializeApp(); }, [initializeApp]); + const refreshOrganizations = useCallback(async () => { + try { + setOrganizationsLoading(true); + const response = await apiService.getOrganizations(); + setOrganizations(response.organizations || []); + return response.organizations || []; + } finally { + setOrganizationsLoading(false); + } + }, []); + + const sessionSub = sessionData?.sub || ""; + const activeOrganizationId = sessionData?.organizationId || ""; + + useEffect(() => { + if (!sessionSub) { + setOrganizations([]); + setOrganizationsLoading(false); + setActiveOrganizationLabel(null); + return; + } + let cancelled = false; + setOrganizationsLoading(true); + apiService + .getOrganizations() + .then((response) => { + if (!cancelled) setOrganizations(response.organizations || []); + }) + .catch(() => { + if (!cancelled) setOrganizations([]); + }) + .finally(() => { + if (!cancelled) setOrganizationsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [sessionSub]); + useEffect(() => { const handleSessionExpired = () => { setSessionData(null); @@ -219,6 +262,46 @@ function AppContent() { const authRequestId = authRequest?.requestId || ""; const authClientId = authRequest?.clientId || ""; const authScopesValue = authRequest?.scopes.join(" ") || ""; + const activeOrganizations = useMemo( + () => + organizations.filter((organization) => + organization.status ? organization.status === "active" : true + ), + [organizations] + ); + const currentOrganization = useMemo(() => { + if (!sessionSub) return null; + return ( + activeOrganizations.find( + (organization) => organization.organizationId === activeOrganizationId + ) || + (!activeOrganizationId && activeOrganizations.length === 1 ? activeOrganizations[0] : null) + ); + }, [activeOrganizationId, activeOrganizations, sessionSub]); + const organizationLabel = activeOrganizationLabel; + + useEffect(() => { + if (currentOrganization?.name) { + setActiveOrganizationLabel(currentOrganization.name); + return; + } + if (!sessionData?.organizationId && sessionData?.organizationSlug) { + setActiveOrganizationLabel(sessionData.organizationSlug); + } + }, [currentOrganization, sessionData?.organizationId, sessionData?.organizationSlug]); + + useEffect(() => { + if (!sessionSub || !currentOrganization || activeOrganizationId) return; + setSessionData((current) => + current + ? { + ...current, + organizationId: currentOrganization.organizationId, + organizationSlug: currentOrganization.slug, + } + : current + ); + }, [activeOrganizationId, currentOrganization, sessionSub]); useEffect(() => { if (!authRequestId || !authClientId || !authScopesValue) { @@ -255,6 +338,10 @@ function AppContent() { organizationId: string; organizationSlug?: string; }) => { + const matchingOrganization = activeOrganizations.find( + (item) => item.organizationId === organization.organizationId + ); + if (matchingOrganization) setActiveOrganizationLabel(matchingOrganization.name); setSessionData((current) => current ? { @@ -264,8 +351,59 @@ function AppContent() { } : current ); + refreshOrganizations().catch(() => {}); }; + const addCreatedOrganization = useCallback((organization: UserOrganization) => { + setOrganizations((current) => [ + ...current.filter((item) => item.organizationId !== organization.organizationId), + organization, + ]); + setActiveOrganizationLabel(organization.name); + }, []); + + const switchOrganization = useCallback( + async (organizationId: string) => { + const matchingOrganization = activeOrganizations.find( + (organization) => organization.organizationId === organizationId + ); + if (matchingOrganization) setActiveOrganizationLabel(matchingOrganization.name); + const response = await apiService.setSessionOrganization(organizationId); + setSessionData((current) => + current + ? { + ...current, + organizationId: response.organizationId, + organizationSlug: response.organizationSlug, + } + : current + ); + await refreshOrganizations(); + }, + [activeOrganizations, refreshOrganizations] + ); + + const userPortalContext = useMemo( + () => ({ + organizations, + organizationsLoading, + activeOrganizationId: sessionData?.organizationId, + activeOrganizationLabel: organizationLabel, + switchOrganization, + refreshOrganizations, + addCreatedOrganization, + }), + [ + addCreatedOrganization, + organizations, + organizationsLoading, + organizationLabel, + refreshOrganizations, + sessionData?.organizationId, + switchOrganization, + ] + ); + const updateSessionProfile = (profile: { name?: string | null; email?: string | null; @@ -316,294 +454,295 @@ function AppContent() { } }; return ( - - -
-
-
-

Loading...

+ + + +
+
+
+

Loading...

+
-
- ) : sessionData ? ( - hasPendingRequest || authRequest ? ( - + ) : sessionData ? ( + hasPendingRequest || authRequest ? ( + + ) : ( + + ) ) : ( - + ) - ) : ( - - ) - } - /> - - ) : ( - navigate(appendSearch("/signup"))} - /> - ) - } - /> - } /> - } /> - - ) : selfRegistrationEnabled ? ( - navigate(appendSearch("/login"))} - /> - ) : ( - - ) - } - /> - } /> - -
-
-
-

Loading...

+ } + /> + + ) : ( + navigate(appendSearch("/signup"))} + /> + ) + } + /> + } /> + } /> + + ) : selfRegistrationEnabled ? ( + navigate(appendSearch("/login"))} + /> + ) : ( + + ) + } + /> + } /> + +
+
+
+

Loading...

+
-
- ) : !sessionData ? ( - - ) : ( - - ) - } - /> - } /> - } /> - } /> - -
-
-
-

Loading...

+ ) : !sessionData ? ( + + ) : ( + + ) + } + /> + } /> + } /> + } /> + +
+
+
+

Loading...

+
-
- ) : !sessionData ? ( - - ) : !authRequest && hasPendingRequest ? ( -
-
-

Loading...

-
- ) : !authRequest ? ( - - ) : sessionData.passwordResetRequired ? ( - - ) : ( -
-
-
-
- - {branding.getTitle()} - -

{branding.getTitle()}

-
-
- + ) : !sessionData ? ( + + ) : !authRequest && hasPendingRequest ? ( +
+
+

Loading...

+
+ ) : !authRequest ? ( + + ) : sessionData.passwordResetRequired ? ( + + ) : ( +
+
+
+
+ + {branding.getTitle()} + +

{branding.getTitle()}

+
+
+ +
+
-
-
- ) - } - /> - -
-
-
-

Loading...

+ ) + } + /> + +
+
+
+

Loading...

+
-
- ) : !sessionData ? ( - - ) : sessionData.passwordResetRequired ? ( - - ) : ( -
-
-
-
- - {branding.getTitle()} - -

{branding.getTitle()}

-
-
- + ) : !sessionData ? ( + + ) : sessionData.passwordResetRequired ? ( + + ) : ( +
+
+
+
+ + {branding.getTitle()} + +

{branding.getTitle()}

+
+
+ +
+
-
-
- ) - } - /> - -
-
-
-

Loading...

+ ) + } + /> + +
+
+
+

Loading...

+
-
- ) : !sessionData ? ( - - ) : sessionData.passwordResetRequired ? ( - - ) : hasPendingRequest || authRequest ? ( - - ) : ( - - - - ) - } - /> - } /> - -
-
-
-

Loading...

+ ) : !sessionData ? ( + + ) : sessionData.passwordResetRequired ? ( + + ) : hasPendingRequest || authRequest ? ( + + ) : ( + + + + ) + } + /> + } /> + +
+
+
+

Loading...

+
-
- ) : !sessionData ? ( - - ) : ( - - - - ) - } - /> - } /> - -
-
-
-

Loading...

+ ) : !sessionData ? ( + + ) : ( + + + + ) + } + /> + } /> + +
+
+
+

Loading...

+
-
- ) : !sessionData ? ( - - ) : ( - - - - ) - } - /> - -
-
-
-

Loading...

+ ) : !sessionData ? ( + + ) : ( + + + + ) + } + /> + +
+
+
+

Loading...

+
-
- ) : !sessionData ? ( - - ) : ( - - - - ) - } - /> - -
-
-
-

Loading...

+ ) : !sessionData ? ( + + ) : ( + + + + ) + } + /> + +
+
+
+

Loading...

+
-
- ) : !sessionData ? ( - - ) : ( - - - - ) - } - /> - } /> - } /> - + ) : !sessionData ? ( + + ) : ( + + + + ) + } + /> + } /> + } /> + + ); } diff --git a/packages/user-ui/src/components/ChangePasswordView.tsx b/packages/user-ui/src/components/ChangePasswordView.tsx index 8b15d888..1d9857ad 100644 --- a/packages/user-ui/src/components/ChangePasswordView.tsx +++ b/packages/user-ui/src/components/ChangePasswordView.tsx @@ -25,7 +25,6 @@ export default function ChangePasswordView({ sessionData, onLogout }: ChangePass navigate("/security/password")} onLogout={onLogout} > diff --git a/packages/user-ui/src/components/Dashboard.test.js b/packages/user-ui/src/components/Dashboard.test.js index cc6f7c45..87eb2937 100644 --- a/packages/user-ui/src/components/Dashboard.test.js +++ b/packages/user-ui/src/components/Dashboard.test.js @@ -16,11 +16,10 @@ test("apps landing keeps app launch primary and moves account tasks to navigatio assert.notEqual(dashboardSource.indexOf("No apps available"), -1); assert.notEqual(layoutSource.indexOf("/security"), -1); assert.notEqual(layoutSource.indexOf("/profile"), -1); - assert.notEqual(layoutSource.indexOf("Active org"), -1); - assert.notEqual( - dashboardSource.indexOf("organizationLabel={sessionData.organizationSlug || null}"), - -1 - ); + assert.notEqual(layoutSource.indexOf("Organizations"), -1); + assert.notEqual(layoutSource.indexOf("useUserPortal"), -1); + assert.notEqual(layoutSource.indexOf("activeOrganizationLabel"), -1); + assert.equal(dashboardSource.indexOf("organizationLabel"), -1); assert.notEqual(dashboardSource.indexOf("KeyUnlockPanel"), -1); assert.notEqual(unlockSource.indexOf("Unlock with Password"), -1); assert.equal(dashboardSource.indexOf("Profile and security controls"), -1); diff --git a/packages/user-ui/src/components/Dashboard.tsx b/packages/user-ui/src/components/Dashboard.tsx index cb3c9414..0568e4df 100644 --- a/packages/user-ui/src/components/Dashboard.tsx +++ b/packages/user-ui/src/components/Dashboard.tsx @@ -95,11 +95,7 @@ export default function Dashboard({ sessionData }: DashboardProps) { const showSearch = apps.length >= 7; return ( - + void; onSwitchToRegister: () => void; preloadClientCheckOnly?: boolean; @@ -245,6 +247,8 @@ export default function Login({ email: session.email, passwordResetRequired: !!session.passwordResetRequired, keyState: finish.unlock ? "unlocked" : session.keyState || finish.key_state || "locked", + organizationId: session.organizationId, + organizationSlug: session.organizationSlug, }); } catch (error) { logger.error(error, "Passkey login failed"); @@ -322,13 +326,16 @@ export default function Login({ } await saveExportKey(loginFinishResponse.sub, loginFinish.exportKey); + const session = await apiService.getSession().catch(() => null); onLogin({ sub: loginFinishResponse.sub, - name: loginFinishResponse.user?.name || undefined, - email: loginFinishResponse.user?.email || formData.email, - passwordResetRequired: false, + name: session?.name || loginFinishResponse.user?.name || undefined, + email: session?.email || loginFinishResponse.user?.email || formData.email, + passwordResetRequired: !!session?.passwordResetRequired, keyState: "unlocked", + organizationId: session?.organizationId, + organizationSlug: session?.organizationSlug, }); try { diff --git a/packages/user-ui/src/components/LoginView.tsx b/packages/user-ui/src/components/LoginView.tsx index 96c68e8c..a92b0bd3 100644 --- a/packages/user-ui/src/components/LoginView.tsx +++ b/packages/user-ui/src/components/LoginView.tsx @@ -8,6 +8,8 @@ type SessionData = { email?: string; passwordResetRequired?: boolean; keyState?: "locked" | "unlocked" | "setup_required"; + organizationId?: string; + organizationSlug?: string; }; export default function LoginView(props?: { diff --git a/packages/user-ui/src/components/OtpSetupView.tsx b/packages/user-ui/src/components/OtpSetupView.tsx index 261731c7..69b3434c 100644 --- a/packages/user-ui/src/components/OtpSetupView.tsx +++ b/packages/user-ui/src/components/OtpSetupView.tsx @@ -60,7 +60,6 @@ export default function OtpSetupView({ navigate("/security/password")} onManageSecurity={() => navigate("/security")} onLogout={onLogout} diff --git a/packages/user-ui/src/components/Profile.test.js b/packages/user-ui/src/components/Profile.test.js index aac4b592..5ea4b6e2 100644 --- a/packages/user-ui/src/components/Profile.test.js +++ b/packages/user-ui/src/components/Profile.test.js @@ -37,9 +37,9 @@ test("profile changes can refresh the portal header session state", () => { }); test("profile keeps organization create, hosted switch, and detail entry points", () => { - assert.notEqual(source.indexOf(".getOrganizations"), -1); + assert.equal(source.indexOf(".getOrganizations"), -1); assert.notEqual(source.indexOf("apiService.createOrganization"), -1); - assert.notEqual(source.indexOf("apiService.setSessionOrganization"), -1); + assert.notEqual(source.indexOf("portal?.switchOrganization"), -1); assert.notEqual( source.indexOf("const canSwitchOrganizations = activeOrganizations.length > 1"), -1 @@ -52,6 +52,10 @@ test("profile keeps organization create, hosted switch, and detail entry points" test("profile and portal show the current active organization", () => { assert.notEqual(source.indexOf("Active organization"), -1); assert.notEqual(source.indexOf('Current'), -1); - assert.notEqual(source.indexOf("organizationLabel={organizationLabel}"), -1); + assert.notEqual(source.indexOf("useUserPortal"), -1); assert.notEqual(appSource.indexOf("organizationSlug: session.organizationSlug"), -1); + assert.notEqual(appSource.indexOf("const [organizations, setOrganizations]"), -1); + assert.notEqual(appSource.indexOf("const organizationLabel = activeOrganizationLabel"), -1); + assert.notEqual(appSource.indexOf("setActiveOrganizationLabel(currentOrganization.name)"), -1); + assert.notEqual(appSource.indexOf("organizationId: session.organizationId"), -1); }); diff --git a/packages/user-ui/src/components/Profile.tsx b/packages/user-ui/src/components/Profile.tsx index bd263069..3dac6a68 100644 --- a/packages/user-ui/src/components/Profile.tsx +++ b/packages/user-ui/src/components/Profile.tsx @@ -1,11 +1,12 @@ import { Plus } from "lucide-react"; import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import apiService, { type UserOrganization, type UserProfile } from "../services/api"; +import apiService, { type UserProfile } from "../services/api"; import Button from "./Button"; import { cx, PortalHeader, PortalPage, PortalSection, StatusPill } from "./Portal"; import styles from "./Profile.module.css"; import UserLayout from "./UserLayout"; +import { useUserPortal } from "./UserPortalContext"; interface ProfileProps { sessionData: { @@ -17,10 +18,6 @@ interface ProfileProps { organizationSlug?: string; }; onLogout: () => void; - onOrganizationChanged?: (organization: { - organizationId: string; - organizationSlug?: string; - }) => void; onProfileChanged?: (profile: { name?: string | null; email?: string | null; @@ -56,13 +53,11 @@ function isEmailLike(value: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); } -export default function Profile({ - sessionData, - onLogout, - onOrganizationChanged, - onProfileChanged, -}: ProfileProps) { +export default function Profile({ sessionData, onLogout, onProfileChanged }: ProfileProps) { const navigate = useNavigate(); + const portal = useUserPortal(); + const organizations = portal?.organizations || []; + const organizationsLoading = portal?.organizationsLoading || false; const [profile, setProfile] = useState(() => profileFromSession(sessionData)); const [loadingProfile, setLoadingProfile] = useState(true); const [editingField, setEditingField] = useState(null); @@ -74,8 +69,6 @@ export default function Profile({ const [sendingEmail, setSendingEmail] = useState(false); const [resendingEmail, setResendingEmail] = useState(false); const [cancelingEmail, setCancelingEmail] = useState(false); - const [organizations, setOrganizations] = useState([]); - const [loadingOrganizations, setLoadingOrganizations] = useState(true); const [copied, setCopied] = useState(false); const [showCreateOrg, setShowCreateOrg] = useState(false); const [newOrgName, setNewOrgName] = useState(""); @@ -123,24 +116,6 @@ export default function Profile({ }; }, [onProfileChanged]); - useEffect(() => { - let cancelled = false; - apiService - .getOrganizations() - .then((response) => { - if (!cancelled) setOrganizations(response.organizations || []); - }) - .catch(() => { - if (!cancelled) setOrganizations([]); - }) - .finally(() => { - if (!cancelled) setLoadingOrganizations(false); - }); - return () => { - cancelled = true; - }; - }, []); - const activeOrganizations = useMemo( () => organizations.filter((organization) => @@ -148,11 +123,14 @@ export default function Profile({ ), [organizations] ); - const currentOrganization = activeOrganizations.find( - (organization) => organization.organizationId === sessionData.organizationId - ); + const currentOrganization = + activeOrganizations.find( + (organization) => organization.organizationId === sessionData.organizationId + ) || + (!sessionData.organizationId && activeOrganizations.length === 1 + ? activeOrganizations[0] + : null); const canSwitchOrganizations = activeOrganizations.length > 1; - const organizationLabel = currentOrganization?.name || sessionData.organizationSlug || null; const activeEmail = profile.email || sessionData.email || ""; const signInEmail = profile.signInEmail || activeEmail; const signInEmailDiffers = @@ -308,12 +286,11 @@ export default function Profile({ ...(slug ? { slug } : {}), }); const nextOrganization = response.organization; - setOrganizations((current) => [...current, nextOrganization]); + portal?.addCreatedOrganization(nextOrganization); setShowCreateOrg(false); setNewOrgName(""); setNewOrgSlug(""); - const nextSession = await apiService.setSessionOrganization(nextOrganization.organizationId); - onOrganizationChanged?.(nextSession); + await portal?.switchOrganization(nextOrganization.organizationId); } catch (error) { setOrganizationError( error instanceof Error ? error.message : "Unable to create organization." @@ -327,7 +304,6 @@ export default function Profile({ @@ -474,7 +450,7 @@ export default function Profile({ id="profile-organizations" title="Organizations" description={ - loadingOrganizations + organizationsLoading ? "Loading organizations..." : activeOrganizations.length === 1 ? "1 active organization" diff --git a/packages/user-ui/src/components/Register.tsx b/packages/user-ui/src/components/Register.tsx index d220cbd8..8d9ca2f5 100644 --- a/packages/user-ui/src/components/Register.tsx +++ b/packages/user-ui/src/components/Register.tsx @@ -16,6 +16,8 @@ interface RegisterProps { email?: string; passwordResetRequired?: boolean; keyState?: "locked" | "unlocked" | "setup_required"; + organizationId?: string; + organizationSlug?: string; }) => void; onSwitchToLogin: () => void; } @@ -216,6 +218,8 @@ export default function Register({ onRegister, onSwitchToLogin }: RegisterProps) email: sessionData.email, passwordResetRequired: !!sessionData.passwordResetRequired, keyState: "unlocked", + organizationId: sessionData.organizationId, + organizationSlug: sessionData.organizationSlug, }); } catch (error) { logger.error(error, "Registration failed"); diff --git a/packages/user-ui/src/components/RegisterView.tsx b/packages/user-ui/src/components/RegisterView.tsx index 6ab3dac0..93ba1b62 100644 --- a/packages/user-ui/src/components/RegisterView.tsx +++ b/packages/user-ui/src/components/RegisterView.tsx @@ -7,6 +7,8 @@ type SessionData = { email?: string; passwordResetRequired?: boolean; keyState?: "locked" | "unlocked" | "setup_required"; + organizationId?: string; + organizationSlug?: string; }; export default function RegisterView(props?: { diff --git a/packages/user-ui/src/components/SettingsSecurityView.tsx b/packages/user-ui/src/components/SettingsSecurityView.tsx index f9c371fd..5bb9b5c7 100644 --- a/packages/user-ui/src/components/SettingsSecurityView.tsx +++ b/packages/user-ui/src/components/SettingsSecurityView.tsx @@ -42,7 +42,6 @@ export default function SettingsSecurityView({ navigate("/security/password")} onManageSecurity={() => navigate("/security")} onLogout={onLogout} diff --git a/packages/user-ui/src/components/UserLayout.module.css b/packages/user-ui/src/components/UserLayout.module.css index a0d74e15..54d7c509 100644 --- a/packages/user-ui/src/components/UserLayout.module.css +++ b/packages/user-ui/src/components/UserLayout.module.css @@ -117,43 +117,8 @@ gap: 0.5rem; } -.orgIndicator { - display: none; - min-width: 0; - max-width: 12rem; - padding: 0.375rem 0.625rem; - border: 1px solid color-mix(in srgb, var(--da-color-action) 30%, var(--da-color-border)); - border-radius: 0.5rem; - background: color-mix(in srgb, var(--da-color-action) 8%, transparent); - color: var(--da-color-text); - text-decoration: none; -} - -.orgIndicator:hover { - background: color-mix(in srgb, var(--da-color-action) 12%, var(--da-color-surface)); - text-decoration: none; -} - -.orgIndicator span, -.orgIndicator strong { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.orgIndicator span { - color: var(--da-color-text-muted); - font-size: 0.6875rem; - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.orgIndicator strong { - color: var(--da-color-text); - font-size: 0.8125rem; - font-weight: 800; +.accountMenu { + position: relative; } .userButton { @@ -176,6 +141,11 @@ text-decoration: none; } +.userButton[aria-expanded="true"] { + border-color: color-mix(in srgb, var(--da-color-action) 38%, var(--da-color-border)); + background: var(--da-color-surface-raised); +} + .avatar { display: inline-flex; align-items: center; @@ -212,6 +182,140 @@ font-size: 0.75rem; } +.userChevron { + display: none; + color: var(--da-color-text-muted); +} + +.accountPopover { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: 50; + width: min(21rem, calc(100vw - 1rem)); + overflow: hidden; + border: 1px solid var(--da-color-border); + border-radius: 0.75rem; + background: var(--da-color-surface); + box-shadow: 0 18px 42px color-mix(in srgb, black 38%, transparent); +} + +.accountSummary { + display: grid; + gap: 0.125rem; + padding: 0.875rem 1rem; + border-bottom: 1px solid var(--da-color-border); +} + +.accountSummary strong, +.accountSummary span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.accountSummary strong { + color: var(--da-color-text); + font-size: 0.9375rem; +} + +.accountSummary span { + color: var(--da-color-text-muted); + font-size: 0.8125rem; +} + +.accountGroup { + display: grid; + gap: 0.25rem; + padding: 0.625rem; +} + +.accountGroupLabel { + padding: 0 0.375rem 0.25rem; + color: var(--da-color-text-muted); + font-size: 0.6875rem; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.organizationItem, +.organizationItemActive { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-width: 0; + gap: 0.75rem; + padding: 0.625rem; + border: 1px solid transparent; + border-radius: 0.5rem; + background: transparent; + color: var(--da-color-text); + text-align: left; + cursor: pointer; +} + +.organizationItem:hover, +.organizationItemActive { + border-color: color-mix(in srgb, var(--da-color-action) 24%, var(--da-color-border)); + background: color-mix(in srgb, var(--da-color-action) 10%, transparent); +} + +.organizationItem:disabled, +.organizationItemActive:disabled { + cursor: wait; + opacity: 0.72; +} + +.organizationItem span, +.organizationItemActive span { + display: grid; + min-width: 0; + gap: 0.125rem; +} + +.organizationItem strong, +.organizationItem small, +.organizationItemActive strong, +.organizationItemActive small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.organizationItem strong, +.organizationItemActive strong { + font-size: 0.875rem; +} + +.organizationItem small, +.organizationItemActive small { + color: var(--da-color-text-muted); + font-size: 0.75rem; +} + +.accountEmpty { + padding: 0.625rem; + color: var(--da-color-text-muted); + font-size: 0.875rem; +} + +.accountProfileLink { + display: block; + padding: 0.75rem 1rem; + border-top: 1px solid var(--da-color-border); + color: var(--da-color-text); + font-size: 0.875rem; + font-weight: 750; + text-decoration: none; +} + +.accountProfileLink:hover { + background: var(--da-color-surface-raised); + text-decoration: none; +} + .main { width: 100%; padding: var(--da-space-5) 0 5.25rem; @@ -235,8 +339,8 @@ display: none; } - .orgIndicator, - .userCopy { + .userCopy, + .userChevron { display: grid; } diff --git a/packages/user-ui/src/components/UserLayout.tsx b/packages/user-ui/src/components/UserLayout.tsx index a1245b85..71a73633 100644 --- a/packages/user-ui/src/components/UserLayout.tsx +++ b/packages/user-ui/src/components/UserLayout.tsx @@ -1,30 +1,29 @@ -import { AppWindow, ShieldCheck, UserRound } from "lucide-react"; -import type { ReactNode } from "react"; +import { AppWindow, Check, ChevronDown, ShieldCheck, UserRound } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { Link, NavLink } from "react-router-dom"; import { useBranding } from "../hooks/useBranding"; import ThemeToggle from "./ThemeToggle"; import styles from "./UserLayout.module.css"; +import { useUserPortal } from "./UserPortalContext"; interface UserLayoutProps { userName?: string | null; userEmail?: string | null; - organizationLabel?: string | null; onChangePassword?: () => void; onManageSecurity?: () => void; onLogout?: () => void; children: ReactNode; } -export default function UserLayout({ - userName, - userEmail, - organizationLabel, - children, -}: UserLayoutProps) { +export default function UserLayout({ userName, userEmail, children }: UserLayoutProps) { const branding = useBranding(); + const portal = useUserPortal(); const logoUrl = branding.getLogoUrl(); const isDefaultLogo = branding.isDefaultLogoUrl(logoUrl); const displayName = userName || userEmail || "Account"; + const [accountOpen, setAccountOpen] = useState(false); + const [switchingOrganizationId, setSwitchingOrganizationId] = useState(null); + const accountRef = useRef(null); const navItems = [ { to: "/apps", label: "Apps", Icon: AppWindow }, { to: "/security", label: "Security", Icon: ShieldCheck }, @@ -46,6 +45,44 @@ export default function UserLayout({ ))} ); + const activeOrganizations = + portal?.organizations.filter((organization) => + organization.status ? organization.status === "active" : true + ) || []; + const organizationLabel = portal?.activeOrganizationLabel || null; + + useEffect(() => { + if (!accountOpen) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node | null; + if (target && accountRef.current && !accountRef.current.contains(target)) { + setAccountOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setAccountOpen(false); + }; + window.addEventListener("pointerdown", onPointerDown); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("keydown", onKeyDown); + }; + }, [accountOpen]); + + const switchOrganization = async (organizationId: string) => { + if (!portal || organizationId === portal.activeOrganizationId) { + setAccountOpen(false); + return; + } + try { + setSwitchingOrganizationId(organizationId); + await portal.switchOrganization(organizationId); + setAccountOpen(false); + } finally { + setSwitchingOrganizationId(null); + } + }; return (
@@ -61,29 +98,71 @@ export default function UserLayout({ {renderNav(styles.desktopNav)}
- {organizationLabel ? ( - - Active org - {organizationLabel} - - ) : null} {(userName || userEmail) && ( - - - - {displayName} - {userName && userEmail && ( - {userEmail} - )} - - +
+ + {accountOpen ? ( +
+
+ {displayName} + {userEmail ? {userEmail} : null} +
+
+ Organizations + {portal?.organizationsLoading ? ( + Loading organizations... + ) : activeOrganizations.length === 0 ? ( + No active organizations + ) : ( + activeOrganizations.map((organization) => { + const active = + organization.organizationId === portal?.activeOrganizationId; + const switching = switchingOrganizationId === organization.organizationId; + return ( + + ); + }) + )} +
+ + Profile + +
+ ) : null} +
)}
diff --git a/packages/user-ui/src/components/UserPortalContext.tsx b/packages/user-ui/src/components/UserPortalContext.tsx new file mode 100644 index 00000000..2580e3a1 --- /dev/null +++ b/packages/user-ui/src/components/UserPortalContext.tsx @@ -0,0 +1,28 @@ +import { createContext, type ReactNode, useContext } from "react"; +import type { UserOrganization } from "../services/api"; + +interface UserPortalContextValue { + organizations: UserOrganization[]; + organizationsLoading: boolean; + activeOrganizationId?: string; + activeOrganizationLabel?: string | null; + switchOrganization: (organizationId: string) => Promise; + refreshOrganizations: () => Promise; + addCreatedOrganization: (organization: UserOrganization) => void; +} + +const UserPortalContext = createContext(null); + +export function UserPortalProvider({ + value, + children, +}: { + value: UserPortalContextValue; + children: ReactNode; +}) { + return {children}; +} + +export function useUserPortal() { + return useContext(UserPortalContext); +} From 0de3929510171f40cfd860691ea602ce01b08dea Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Mon, 1 Jun 2026 22:44:37 +0100 Subject: [PATCH 4/5] fix: cicd --- packages/test-suite/setup/helpers/auth.ts | 5 +++++ .../user-key-management-admin-ui.spec.ts | 8 ++++--- packages/user-ui/src/App.tsx | 5 +++-- packages/user-ui/src/services/api.ts | 21 +++++++++++++++---- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/test-suite/setup/helpers/auth.ts b/packages/test-suite/setup/helpers/auth.ts index 46e5fc83..815db04d 100644 --- a/packages/test-suite/setup/helpers/auth.ts +++ b/packages/test-suite/setup/helpers/auth.ts @@ -1,5 +1,6 @@ import type { BrowserContext, Page } from '@playwright/test'; import { OpaqueClient } from '@DarkAuth/api/src/lib/opaque/opaque-ts-wrapper.ts'; +import { setUserPasswordResetRequired } from '@DarkAuth/api/src/models/users.ts'; import { toBase64Url, fromBase64Url, sha256Base64Url } from '@DarkAuth/api/src/utils/crypto.ts'; import { totp, base32 } from '@DarkAuth/api/src/utils/totp.ts'; import type { TestServers } from '../server.js'; @@ -385,6 +386,7 @@ export async function createUserViaAdmin( createPersonalOrganization?: boolean; personalOrganizationName?: string; personalOrganizationSlug?: string; + passwordResetRequired?: boolean; } ): Promise<{ sub: string }> { const cacheKey = `${servers.adminUrl}|${admin.email}`; @@ -463,6 +465,9 @@ export async function createUserViaAdmin( }) }); if (!setFinishRes.ok) throw new Error(`password set finish failed: ${setFinishRes.status}`); + if (options.passwordResetRequired !== true) { + await setUserPasswordResetRequired(servers.getContext(), sub, false); + } return { sub }; } diff --git a/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts b/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts index aae5e946..679e5f2c 100644 --- a/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts +++ b/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts @@ -34,13 +34,15 @@ async function fillField(root: Page | Locator, label: string, value: string) { } async function selectField(root: Page | Locator, page: Page, label: string, option: string) { - await clickElement(field(root, label).getByRole('combobox')); + await clickElement(field(root, label).locator('button, select, [role="combobox"]').first()); await clickElement(page.getByRole('option', { name: option, exact: true })); } async function selectFirstFieldOption(root: Page | Locator, page: Page, label: string) { - await clickElement(field(root, label).getByRole('combobox')); - await page.keyboard.press('Enter'); + await clickElement(field(root, label).locator('button, select, [role="combobox"]').first()); + const option = page.getByRole('option').first(); + await expect(option).toBeVisible(); + await clickElement(option); } async function openRowAction(row: Locator, name: string) { diff --git a/packages/user-ui/src/App.tsx b/packages/user-ui/src/App.tsx index e8321adf..a6f22336 100644 --- a/packages/user-ui/src/App.tsx +++ b/packages/user-ui/src/App.tsx @@ -222,9 +222,10 @@ function AppContent() { const sessionSub = sessionData?.sub || ""; const activeOrganizationId = sessionData?.organizationId || ""; + const isOtpRoute = location.pathname === "/otp/setup" || location.pathname === "/otp/verify"; useEffect(() => { - if (!sessionSub) { + if (!sessionSub || isOtpRoute) { setOrganizations([]); setOrganizationsLoading(false); setActiveOrganizationLabel(null); @@ -246,7 +247,7 @@ function AppContent() { return () => { cancelled = true; }; - }, [sessionSub]); + }, [isOtpRoute, sessionSub]); useEffect(() => { const handleSessionExpired = () => { diff --git a/packages/user-ui/src/services/api.ts b/packages/user-ui/src/services/api.ts index ea4c5f7a..423202bb 100644 --- a/packages/user-ui/src/services/api.ts +++ b/packages/user-ui/src/services/api.ts @@ -456,6 +456,15 @@ class ApiService { return appConfig.__APP_CONFIG__?.clientId || appConfig.__APP_CONFIG__?.auth?.clientId || "user"; } + private isOtpVerificationRequired(data: unknown): boolean { + return ( + !!data && + typeof data === "object" && + "error" in data && + (data as { error?: unknown }).error === "OTP verification required" + ); + } + private async refreshSessionWithToken(): Promise { if (!this.refreshInFlight) { this.refreshInFlight = (async () => { @@ -512,21 +521,25 @@ class ApiService { try { let response = await fetch(url, config); + let data = await response.json().catch(() => ({})); if ( !response.ok && (response.status === 401 || response.status === 403) && - endpoint !== "/token" + endpoint !== "/token" && + !this.isOtpVerificationRequired(data) ) { const refreshed = await this.refreshSessionWithToken(); if (refreshed) { response = await fetch(url, config); + data = await response.json().catch(() => ({})); } } - const data = await response.json().catch(() => ({})); - if (!response.ok) { - if (response.status === 401 || response.status === 403) { + if ( + (response.status === 401 || response.status === 403) && + !this.isOtpVerificationRequired(data) + ) { this.clearLegacyTokens(); if (this.onSessionExpired) { this.onSessionExpired(); From b0643afbbf34f922e50bfbf43199fba5039c1d3b Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Mon, 1 Jun 2026 22:52:51 +0100 Subject: [PATCH 5/5] fix: cicd --- .../user-key-management-admin-ui.spec.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts b/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts index 679e5f2c..4b9abfe2 100644 --- a/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts +++ b/packages/test-suite/tests/admin/user-key-management-admin-ui.spec.ts @@ -33,13 +33,34 @@ async function fillField(root: Page | Locator, label: string, value: string) { await field(root, label).locator('input, textarea').first().fill(value); } +async function selectControl(root: Page | Locator, label: string) { + const container = field(root, label); + const combobox = container.locator('select, [role="combobox"]').first(); + if ((await combobox.count()) > 0) return combobox; + return container.locator('button').first(); +} + async function selectField(root: Page | Locator, page: Page, label: string, option: string) { - await clickElement(field(root, label).locator('button, select, [role="combobox"]').first()); + const control = await selectControl(root, label); + if ((await control.evaluate((element) => element.tagName.toLowerCase())) === 'select') { + await control.selectOption({ label: option }); + return; + } + await clickElement(control); await clickElement(page.getByRole('option', { name: option, exact: true })); } async function selectFirstFieldOption(root: Page | Locator, page: Page, label: string) { - await clickElement(field(root, label).locator('button, select, [role="combobox"]').first()); + const control = await selectControl(root, label); + if ((await control.evaluate((element) => element.tagName.toLowerCase())) === 'select') { + const value = await control.evaluate((element) => { + const select = element as HTMLSelectElement; + return Array.from(select.options).find((option) => !option.disabled)?.value ?? ''; + }); + await control.selectOption(value); + return; + } + await clickElement(control); const option = page.getByRole('option').first(); await expect(option).toBeVisible(); await clickElement(option);