@burdenoff/microfe-auth 2026.917.1 → 2026.917.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/pages/UserProfilePage.js +188 -171
- package/dist/auth/pages/UserProfilePage.js.map +1 -1
- package/dist/auth/pages/UsersPage.js +199 -182
- package/dist/auth/pages/UsersPage.js.map +1 -1
- package/dist/auth/utils/validation.d.ts +19 -0
- package/dist/auth/utils/validation.js +20 -1
- package/dist/auth/utils/validation.js.map +1 -1
- package/dist/microfe-auth.css +1 -1
- package/dist/translations/en.d.ts +2 -0
- package/dist/translations/en.js +2 -0
- package/dist/translations/en.js.map +1 -1
- package/package.json +3 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"UsersPage.js","names":[],"sources":["../../../src/auth/pages/UsersPage.tsx"],"sourcesContent":["import { useState, useEffect, useCallback, useId } from \"react\";\nimport { useNavigate } from \"react-router\";\nimport { useTr } from \"../../shared/hooks/useTr\";\nimport { Button } from \"@burdenoff/fe-libs/button\";\nimport {\n EmphasisPanel,\n IllustratedEmptyState,\n PagePurpose,\n NextSteps,\n} from \"@burdenoff/fe-libs/ui\";\nimport { useAuthPermissions } from \"../hooks/useAuthPermissions\";\nimport { useUserOperations } from \"../hooks/useUserOperations\";\nimport { useAuthStore } from \"../store/authStore\";\nimport { validateUserFields } from \"../utils/validation\";\nimport { ConfirmActionDialog } from \"../components/ConfirmActionDialog\";\nimport type {\n User,\n UserRole,\n UserStatus,\n UserFilterInput,\n PaginationInput,\n} from \"../types\";\nimport { SpinnerIcon, AlertIcon } from \"../components/AuthIcons\";\nimport { nativeImpact, nativeNotify } from \"../../utils/nativeBridge\";\n\ntype ViewMode = \"list\" | \"edit\" | \"view\";\n\ninterface FormData {\n username: string;\n firstName: string;\n lastName: string;\n name: string;\n phoneNumber: string;\n role: UserRole;\n status: UserStatus;\n emailVerified: boolean;\n}\n\n// SUPER_ADMIN is determined by email allowlist at runtime, not stored as a\n// DB role — filtering by it always returns 0 rows, so it is excluded here.\nconst USER_ROLES: UserRole[] = [\n \"TENANT_ADMIN\",\n \"ORG_ADMIN\",\n \"WORKSPACE_ADMIN\",\n \"MEMBER\",\n \"GUEST\",\n];\n\nconst USER_STATUSES: UserStatus[] = [\n \"ACTIVE\",\n \"INACTIVE\",\n \"SUSPENDED\",\n \"DELETED\",\n];\n\n/**\n * Users CRUDL page for managing users (admin only)\n */\nexport function UsersPage() {\n const tr = useTr();\n const navigate = useNavigate();\n\n const isAuthenticated = useAuthStore((state) => state.isAuthenticated);\n const currentUser = useAuthStore((state) => state.user);\n const {\n canViewUsers,\n canUpdateUsers,\n canDeleteUsers,\n isLoading: permissionsLoading,\n } = useAuthPermissions();\n const { listUsers, updateUser, deleteUser, hardDeleteUser, isLoading } =\n useUserOperations();\n\n // Stable IDs for ARIA associations (accessibility)\n const searchId = useId();\n const roleFilterId = useId();\n const statusFilterId = useId();\n const errorId = useId();\n const successId = useId();\n\n const [isExporting, setIsExporting] = useState(false);\n\n const handleExport = async () => {\n setIsExporting(true);\n try {\n const filter: UserFilterInput = {};\n if (searchQuery) filter.search = searchQuery;\n if (filterRole) filter.role = filterRole;\n if (filterStatus) filter.status = filterStatus;\n\n const result = await listUsers(filter, { offset: 0, limit: 10000 });\n const rows = result.users;\n\n const headers = [\n \"ID\",\n \"Email\",\n \"Username\",\n \"First Name\",\n \"Last Name\",\n \"Phone\",\n \"Role\",\n \"Status\",\n \"Email Verified\",\n \"MFA Enabled\",\n \"Last Login\",\n \"Created At\",\n ];\n\n const escape = (v: string | undefined | null) => {\n if (v == null) return \"\";\n const s = String(v);\n return s.includes(\",\") || s.includes('\"') || s.includes(\"\\n\")\n ? `\"${s.replace(/\"/g, '\"\"')}\"`\n : s;\n };\n\n const lines = [\n headers.join(\",\"),\n ...rows.map((u) =>\n [\n escape(u.id),\n escape(u.email),\n escape(u.username),\n escape(u.firstName),\n escape(u.lastName),\n escape(u.phoneNumber),\n escape(u.role),\n escape(u.status),\n escape(String(u.emailVerified)),\n escape(String(u.mfaEnabled)),\n escape(u.lastLoginAt),\n escape(u.createdAt),\n ].join(\",\"),\n ),\n ];\n\n const blob = new Blob([lines.join(\"\\n\")], {\n type: \"text/csv;charset=utf-8;\",\n });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `users-${new Date().toISOString().slice(0, 10)}.csv`;\n a.click();\n URL.revokeObjectURL(url);\n } catch (err) {\n setError(err instanceof Error ? err.message : \"Failed to export users\");\n } finally {\n setIsExporting(false);\n }\n };\n\n const [users, setUsers] = useState<User[]>([]);\n const [totalCount, setTotalCount] = useState(0);\n const [hasMore, setHasMore] = useState(false);\n const [viewMode, setViewMode] = useState<ViewMode>(\"list\");\n const [selectedUser, setSelectedUser] = useState<User | null>(null);\n const [formData, setFormData] = useState<FormData>({\n username: \"\",\n firstName: \"\",\n lastName: \"\",\n name: \"\",\n phoneNumber: \"\",\n role: \"MEMBER\",\n status: \"ACTIVE\",\n emailVerified: false,\n });\n const [error, setError] = useState<string | null>(null);\n const [isUnauthorized, setIsUnauthorized] = useState(false);\n const [successMessage, setSuccessMessage] = useState<string | null>(null);\n const [pendingAction, setPendingAction] = useState<{\n type: \"deactivate\" | \"hard-delete\";\n user: User;\n } | null>(null);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [filterRole, setFilterRole] = useState<UserRole | \"\">(\"\");\n const [filterStatus, setFilterStatus] = useState<UserStatus | \"\">(\"\");\n const [currentPage, setCurrentPage] = useState(0);\n const pageSize = 10;\n\n const fetchUsers = useCallback(async () => {\n if (!isAuthenticated) return;\n\n try {\n setIsUnauthorized(false);\n const filter: UserFilterInput = {};\n if (searchQuery) filter.search = searchQuery;\n if (filterRole) filter.role = filterRole;\n if (filterStatus) filter.status = filterStatus;\n\n const pagination: PaginationInput = {\n offset: currentPage * pageSize,\n limit: pageSize,\n };\n\n const result = await listUsers(filter, pagination);\n setUsers(result.users);\n setTotalCount(result.totalCount);\n setHasMore(result.hasMore);\n setError(null);\n } catch (err) {\n // Ignore abort errors - they happen during component unmount/re-render\n if (\n err instanceof Error &&\n (err.name === \"AbortError\" || err.message.includes(\"aborted\"))\n ) {\n return;\n }\n const errorMessage =\n err instanceof Error ? err.message : \"Failed to fetch users\";\n // Check if it's an authorization error\n if (\n errorMessage.toLowerCase().includes(\"not authorized\") ||\n errorMessage.toLowerCase().includes(\"forbidden\")\n ) {\n setIsUnauthorized(true);\n setError(null);\n } else {\n setError(errorMessage);\n }\n }\n }, [\n listUsers,\n searchQuery,\n filterRole,\n filterStatus,\n currentPage,\n isAuthenticated,\n ]);\n\n useEffect(() => {\n fetchUsers();\n }, [fetchUsers]);\n\n const resetForm = () => {\n setFormData({\n username: \"\",\n firstName: \"\",\n lastName: \"\",\n name: \"\",\n phoneNumber: \"\",\n role: \"MEMBER\",\n status: \"ACTIVE\",\n emailVerified: false,\n });\n setSelectedUser(null);\n setError(null);\n setSuccessMessage(null);\n };\n\n const handleUpdate = async (e: React.FormEvent) => {\n e.preventDefault();\n if (!selectedUser) return;\n setError(null);\n\n // BOFF-7214: same name/username invariants the signup form enforces\n // must hold on user edit too, otherwise an admin (or anyone with\n // users:update) can re-introduce a numbers-only account. Same\n // validator the register form uses, same canonicalised output.\n // Pass `requireAll: false` so an existing user whose optional\n // `firstName`/`lastName` is unset (e.g. provisioned via IdP) can\n // still update unrelated fields like phone or role (Codex P2 review).\n const validation = validateUserFields(formData, { requireAll: false });\n if (!validation.valid) {\n setError(\n validation.errors\n .map((e) => {\n const key = `auth.users.${e.field}_${e.code}`;\n return tr(key, e.message);\n })\n .join(\" · \"),\n );\n return;\n }\n\n try {\n await updateUser(selectedUser.id, {\n username: validation.values.username || undefined,\n firstName: validation.values.firstName || undefined,\n lastName: validation.values.lastName || undefined,\n name: formData.name || undefined,\n phoneNumber: formData.phoneNumber || undefined,\n role: formData.role,\n status: formData.status,\n emailVerified: formData.emailVerified,\n });\n setSuccessMessage(\n tr(\"auth.users.updateSuccess\", \"User updated successfully!\"),\n );\n setViewMode(\"list\");\n resetForm();\n fetchUsers();\n } catch (err) {\n setError(err instanceof Error ? err.message : \"Failed to update user\");\n }\n };\n\n const handleDelete = async (user: User) => {\n void nativeImpact(\"medium\");\n try {\n const result = await deleteUser(user.id);\n if (result.success) {\n void nativeNotify(\"success\");\n setSuccessMessage(\n tr(\n \"auth.users.userDeactivated\",\n 'User \"{email}\" has been deactivated.',\n ).replace(\"{email}\", user.email),\n );\n setPendingAction(null);\n fetchUsers();\n } else {\n void nativeNotify(\"error\");\n setError(result.message);\n }\n } catch (err) {\n void nativeNotify(\"error\");\n setError(err instanceof Error ? err.message : \"Failed to delete user\");\n }\n };\n\n const handleHardDelete = async (user: User) => {\n try {\n const result = await hardDeleteUser(user.id);\n if (result.success) {\n setSuccessMessage(\n tr(\n \"auth.users.userPermanentlyDeleted\",\n 'User \"{email}\" has been permanently deleted.',\n ).replace(\"{email}\", user.email),\n );\n setPendingAction(null);\n fetchUsers();\n } else {\n setError(result.message);\n }\n } catch (err) {\n setError(\n err instanceof Error ? err.message : \"Failed to hard delete user\",\n );\n }\n };\n\n const startEdit = (user: User) => {\n setSelectedUser(user);\n setFormData({\n username: user.username || \"\",\n firstName: user.firstName || \"\",\n lastName: user.lastName || \"\",\n name: user.name || \"\",\n phoneNumber: user.phoneNumber || \"\",\n role: user.role,\n status: user.status,\n emailVerified: user.emailVerified,\n });\n setViewMode(\"edit\");\n setError(null);\n };\n\n const pendingActionDialog = (\n <ConfirmActionDialog\n open={pendingAction !== null}\n onOpenChange={(open) => {\n if (!open) {\n setPendingAction(null);\n }\n }}\n title={\n pendingAction?.type === \"hard-delete\"\n ? tr(\"auth.users.hardDeleteTitle\", \"Permanently delete user?\")\n : tr(\"auth.users.deactivateTitle\", \"Deactivate user?\")\n }\n description={\n pendingAction?.type === \"hard-delete\"\n ? pendingAction\n ? tr(\n \"auth.users.hardDeleteDesc\",\n 'This permanently deletes \"{email}\" and cannot be undone.',\n ).replace(\"{email}\", pendingAction.user.email)\n : \"\"\n : pendingAction\n ? tr(\n \"auth.users.deactivateDesc\",\n '\"{email}\" will be prevented from signing in until reactivated.',\n ).replace(\"{email}\", pendingAction.user.email)\n : \"\"\n }\n confirmText={\n pendingAction?.type === \"hard-delete\"\n ? tr(\"auth.users.hardDeleteConfirm\", \"Delete permanently\")\n : tr(\"auth.users.deactivateConfirm\", \"Deactivate user\")\n }\n variant={pendingAction?.type === \"hard-delete\" ? \"danger\" : \"warning\"}\n onConfirm={async () => {\n if (!pendingAction) return;\n if (pendingAction.type === \"hard-delete\") {\n await handleHardDelete(pendingAction.user);\n return;\n }\n await handleDelete(pendingAction.user);\n }}\n />\n );\n\n const getRoleColor = (role: UserRole) => {\n switch (role) {\n case \"SUPER_ADMIN\":\n return \"bg-accent-purple-subtle text-accent-purple\";\n case \"TENANT_ADMIN\":\n return \"bg-status-info-bg-subtle text-status-info-text\";\n case \"ORG_ADMIN\":\n return \"bg-accent-indigo-subtle text-accent-indigo\";\n case \"WORKSPACE_ADMIN\":\n return \"bg-accent-cyan-subtle text-accent-cyan\";\n case \"MEMBER\":\n return \"bg-status-success-bg-subtle text-status-success-text\";\n case \"GUEST\":\n return \"bg-bg-sunken text-text-primary\";\n default:\n return \"bg-bg-sunken text-text-primary\";\n }\n };\n\n const getStatusColor = (status: UserStatus) => {\n switch (status) {\n case \"ACTIVE\":\n return \"bg-status-success-bg-subtle text-status-success-text\";\n case \"INACTIVE\":\n return \"bg-status-warning-bg-subtle text-status-warning-text\";\n case \"SUSPENDED\":\n return \"bg-status-warning-bg-subtle text-status-warning-text\";\n case \"DELETED\":\n return \"bg-status-error-bg-subtle text-status-error-text\";\n default:\n return \"bg-bg-sunken text-text-primary\";\n }\n };\n\n // Authentication check\n if (!isAuthenticated) {\n return (\n <div className=\"max-w-6xl mx-auto p-6\">\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title={tr(\"auth.users.authRequired\", \"Authentication Required\")}\n description={tr(\n \"auth.users.signInToManage\",\n \"Please sign in to manage users\",\n )}\n />\n </div>\n );\n }\n\n // Loading permissions\n if (permissionsLoading) {\n return (\n <div className=\"max-w-6xl mx-auto p-6\">\n <div className=\"text-center py-16\">\n <SpinnerIcon className=\"size-8 mx-auto text-text-secondary animate-spin\" />\n <p className=\"mt-4 text-text-secondary\">\n {tr(\"auth.users.loadingPermissions\", \"Loading permissions...\")}\n </p>\n </div>\n </div>\n );\n }\n\n // Authorization check - user needs user:read permission\n if (!canViewUsers || isUnauthorized) {\n return (\n <div className=\"max-w-6xl mx-auto p-6\">\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title={tr(\"auth.users.adminAccessRequired\", \"Admin Access Required\")}\n description={\n <>\n {tr(\n \"auth.users.adminAccessDesc\",\n \"You need administrator privileges to view and manage users.\",\n )}{\" \"}\n {tr(\n \"auth.users.requiredRoles\",\n \"Required roles: Super Admin, Tenant Admin, or Org Admin\",\n )}\n </>\n }\n />\n </div>\n );\n }\n\n // List view\n if (viewMode === \"list\") {\n return (\n <div className=\"max-w-6xl mx-auto p-6\">\n {pendingActionDialog}\n {/* Header */}\n <div className=\"flex items-center justify-between mb-8\">\n <div>\n <h1 className=\"text-3xl font-semibold text-text-primary\">\n {tr(\"auth.users.title\", \"Users\")}\n </h1>\n <p className=\"text-text-secondary mt-2\">\n {tr(\"auth.users.subtitle\", \"Manage users in your organization\")} (\n {totalCount} {tr(\"auth.users.paginationUsers\", \"total\")})\n </p>\n </div>\n {/* Export button */}\n <button\n type=\"button\"\n onClick={() => void handleExport()}\n disabled={isExporting}\n aria-label={tr(\"auth.users.exportAriaLabel\", \"Export user list\")}\n className=\"flex items-center gap-2 px-4 py-2.5 rounded-xl border border-border-default text-text-primary bg-bg-surface hover:bg-bg-sunken disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium\"\n >\n {isExporting ? (\n <>\n <SpinnerIcon\n className=\"size-4 animate-spin\"\n aria-hidden=\"true\"\n />\n {tr(\"auth.users.exporting\", \"Exporting...\")}\n </>\n ) : (\n <>\n <svg\n className=\"size-4\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n aria-hidden=\"true\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\"\n />\n </svg>\n {tr(\"auth.users.export\", \"Export\")}\n </>\n )}\n </button>\n </div>\n\n <PagePurpose className=\"mb-6\">\n {tr(\n \"auth.users.purpose\",\n \"Manage everyone with access to your organization. Search and filter accounts, review their role and status, edit details, or deactivate people who should no longer have access.\",\n )}\n </PagePurpose>\n\n {/* Emphasis: directory summary — the one highlighted zone */}\n <EmphasisPanel className=\"mb-6\">\n <p className=\"text-sm font-medium text-text-secondary\">\n {tr(\"auth.users.summaryLabel\", \"People in this organization\")}\n </p>\n <p className=\"mt-1 text-3xl font-semibold text-text-primary\">\n {totalCount}\n </p>\n <p className=\"mt-1 text-sm text-text-secondary\">\n {tr(\n \"auth.users.summaryHint\",\n \"Use the filters below to find a specific person or role.\",\n )}\n </p>\n </EmphasisPanel>\n\n {/* Live region for status announcements */}\n <div aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n {successMessage}\n </div>\n <div\n role=\"alert\"\n aria-live=\"assertive\"\n aria-atomic=\"true\"\n className=\"sr-only\"\n >\n {error}\n </div>\n\n {/* Success Message */}\n {successMessage && (\n <output\n id={successId}\n className=\"block mb-6 p-4 rounded-xl bg-status-success-bg-subtle border border-status-success-border\"\n >\n <p className=\"text-status-success-text\">{successMessage}</p>\n <button\n type=\"button\"\n onClick={() => setSuccessMessage(null)}\n className=\"mt-2 text-sm text-status-success-text hover:underline\"\n >\n {tr(\"auth.users.dismiss\", \"Dismiss\")}\n </button>\n </output>\n )}\n\n {/* Error Message */}\n {error && (\n <div\n id={errorId}\n role=\"alert\"\n className=\"mb-6 flex items-start gap-3 p-4 rounded-xl bg-status-error-bg-subtle border border-status-error-border\"\n >\n <AlertIcon\n className=\"size-5 text-status-error-text flex-shrink-0 mt-0.5\"\n aria-hidden=\"true\"\n />\n <div className=\"flex-1\">\n <p className=\"text-sm text-status-error-text\">{error}</p>\n <button\n type=\"button\"\n onClick={() => setError(null)}\n className=\"mt-2 text-sm text-status-error-text hover:underline\"\n >\n {tr(\"auth.users.dismiss\", \"Dismiss\")}\n </button>\n </div>\n </div>\n )}\n\n {/* Filters */}\n <div className=\"mb-6 p-4 rounded-xl border border-border-subtle bg-bg-surface\">\n <div className=\"flex flex-wrap gap-4 items-end\">\n {/* Visually hidden labels satisfy WCAG — placeholders are not labels */}\n <div className=\"flex-1 min-w-[200px]\">\n <label htmlFor={searchId} className=\"sr-only\">\n {tr(\"auth.users.searchLabel\", \"Search users\")}\n </label>\n <input\n id={searchId}\n type=\"search\"\n aria-label={tr(\"auth.users.searchLabel\", \"Search users\")}\n placeholder={tr(\n \"auth.users.searchPlaceholder\",\n \"Search by email, username, or name...\",\n )}\n value={searchQuery}\n onChange={(e) => {\n setSearchQuery(e.target.value);\n setCurrentPage(0);\n }}\n className=\"w-full px-4 py-2.5 border border-border-default rounded-xl bg-bg-surface text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n <div>\n <label htmlFor={roleFilterId} className=\"sr-only\">\n {tr(\"auth.users.filterRoleLabel\", \"Filter by role\")}\n </label>\n <select\n id={roleFilterId}\n value={filterRole}\n onChange={(e) => {\n setFilterRole(e.target.value as UserRole | \"\");\n setCurrentPage(0);\n }}\n className=\"px-4 py-2.5 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n >\n <option value=\"\">\n {tr(\"auth.users.allRoles\", \"All Roles\")}\n </option>\n {USER_ROLES.map((role) => (\n <option key={role} value={role}>\n {role.replace(\"_\", \" \")}\n </option>\n ))}\n </select>\n </div>\n <div>\n <label htmlFor={statusFilterId} className=\"sr-only\">\n {tr(\"auth.users.filterStatusLabel\", \"Filter by status\")}\n </label>\n <select\n id={statusFilterId}\n value={filterStatus}\n onChange={(e) => {\n setFilterStatus(e.target.value as UserStatus | \"\");\n setCurrentPage(0);\n }}\n className=\"px-4 py-2.5 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n >\n <option value=\"\">\n {tr(\"auth.users.allStatuses\", \"All Statuses\")}\n </option>\n {USER_STATUSES.map((status) => (\n <option key={status} value={status}>\n {status}\n </option>\n ))}\n </select>\n </div>\n </div>\n </div>\n\n {isLoading ? (\n <div\n aria-live=\"polite\"\n aria-busy=\"true\"\n className=\"flex items-center justify-center py-12\"\n >\n <SpinnerIcon\n className=\"size-8 text-text-link animate-spin\"\n aria-hidden=\"true\"\n />\n <span className=\"ml-3 text-text-secondary\">\n {tr(\"auth.users.loadingUsers\", \"Loading users...\")}\n </span>\n </div>\n ) : users.length === 0 ? (\n <IllustratedEmptyState\n illustration={\n searchQuery || filterRole || filterStatus\n ? \"empty-search\"\n : \"empty-people\"\n }\n title={tr(\"auth.users.noUsersFound\", \"No users found\")}\n description={\n searchQuery || filterRole || filterStatus\n ? tr(\n \"auth.users.noUsersFilteredDesc\",\n \"No users match your current search or filters. Try clearing them to see everyone.\",\n )\n : tr(\n \"auth.users.noUsersDesc\",\n \"Once people join your organization they'll show up here for you to manage.\",\n )\n }\n />\n ) : (\n <>\n {/* Users Table */}\n <div className=\"rounded-xl border border-border-subtle bg-bg-surface overflow-hidden shadow-sm\">\n <div className=\"overflow-x-auto\">\n <table\n className=\"w-full min-w-[800px]\"\n aria-label={tr(\"auth.users.tableAriaLabel\", \"Users table\")}\n >\n <thead>\n <tr className=\"border-b border-border-subtle bg-bg-canvas\">\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[280px]\"\n >\n {tr(\"auth.users.columnUser\", \"User\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[130px]\"\n >\n {tr(\"auth.users.columnRole\", \"Role\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[100px]\"\n >\n {tr(\"auth.users.columnStatus\", \"Status\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[80px]\"\n >\n {tr(\"auth.users.columnVerified\", \"Verified\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[100px]\"\n >\n {tr(\"auth.users.columnLastLogin\", \"Last Login\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-right p-4 text-sm font-medium text-text-secondary w-[180px]\"\n >\n {tr(\"auth.users.columnActions\", \"Actions\")}\n </th>\n </tr>\n </thead>\n <tbody>\n {users.map((user) => (\n <tr\n key={user.id}\n className=\"border-b border-border-subtle last:border-b-0 hover:bg-bg-sunken/50 transition-colors\"\n >\n <td className=\"p-4\">\n <div className=\"flex items-center gap-3 min-w-0\">\n <div className=\"auth-brand-badge flex size-10 shrink-0 items-center justify-center rounded-full font-medium\">\n {(user.name || user.username || user.email)\n .charAt(0)\n .toUpperCase()}\n </div>\n <div className=\"min-w-0 flex-1\">\n <p className=\"font-medium text-text-primary truncate\">\n {user.name || user.username}\n </p>\n <p className=\"text-sm text-text-secondary truncate\">\n {user.email}\n </p>\n </div>\n </div>\n </td>\n <td className=\"p-4\">\n <span\n className={`inline-block px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${getRoleColor(user.role)}`}\n >\n {user.role.replace(\"_\", \" \")}\n </span>\n </td>\n <td className=\"p-4\">\n <span\n className={`inline-block px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${getStatusColor(user.status)}`}\n >\n {user.status}\n </span>\n </td>\n <td className=\"p-4\">\n {user.emailVerified ? (\n <span className=\"inline-flex items-center gap-1 text-status-success-text whitespace-nowrap\">\n <svg\n className=\"size-4 flex-shrink-0\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n fillRule=\"evenodd\"\n d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\"\n clipRule=\"evenodd\"\n />\n </svg>\n <span className=\"text-sm\">\n {tr(\"auth.users.verifiedYes\", \"Yes\")}\n </span>\n </span>\n ) : (\n <span className=\"inline-flex items-center gap-1 text-status-warning-text whitespace-nowrap\">\n <svg\n className=\"size-4 flex-shrink-0\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n fillRule=\"evenodd\"\n d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z\"\n clipRule=\"evenodd\"\n />\n </svg>\n <span className=\"text-sm\">\n {tr(\"auth.users.verifiedNo\", \"No\")}\n </span>\n </span>\n )}\n </td>\n <td className=\"p-4 text-sm text-text-secondary whitespace-nowrap\">\n {user.lastLoginAt\n ? new Date(user.lastLoginAt).toLocaleDateString()\n : tr(\"auth.users.lastLoginNever\", \"Never\")}\n </td>\n <td className=\"p-4\">\n <div className=\"flex justify-end gap-1.5 flex-nowrap\">\n <button\n type=\"button\"\n onClick={() => navigate(`/auth/users/${user.id}`)}\n aria-label={`${tr(\"auth.users.actionView\", \"View\")} ${user.name || user.username || user.email}`}\n className=\"px-2.5 py-1.5 rounded-lg text-xs font-medium border border-border-default text-text-primary bg-bg-surface hover:bg-bg-sunken transition-colors whitespace-nowrap flex-shrink-0\"\n >\n {tr(\"auth.users.actionView\", \"View\")}\n </button>\n {canUpdateUsers && (\n <button\n type=\"button\"\n onClick={() => startEdit(user)}\n aria-label={`${tr(\"auth.users.actionEdit\", \"Edit\")} ${user.name || user.username || user.email}`}\n className=\"px-2.5 py-1.5 rounded-lg text-xs font-medium border border-status-info-border text-text-link bg-bg-surface hover:bg-status-info-bg-subtle transition-colors whitespace-nowrap flex-shrink-0\"\n >\n {tr(\"auth.users.actionEdit\", \"Edit\")}\n </button>\n )}\n {canDeleteUsers && user.status !== \"DELETED\" && (\n <button\n type=\"button\"\n onClick={() =>\n setPendingAction({ type: \"deactivate\", user })\n }\n aria-label={`${tr(\"auth.users.actionDelete\", \"Delete\")} ${user.name || user.username || user.email}`}\n className=\"px-2.5 py-1.5 rounded-lg text-xs font-medium border border-status-error-border text-status-error-text bg-bg-surface hover:bg-status-error-bg-subtle transition-colors whitespace-nowrap flex-shrink-0\"\n >\n {tr(\"auth.users.actionDelete\", \"Delete\")}\n </button>\n )}\n </div>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </div>\n\n {/* Pagination */}\n <div className=\"mt-6 flex items-center justify-between\">\n <p className=\"text-sm text-text-secondary\">\n {tr(\"auth.users.showing\", \"Showing\")}{\" \"}\n {currentPage * pageSize + 1} {tr(\"auth.users.to\", \"to\")}{\" \"}\n {Math.min((currentPage + 1) * pageSize, totalCount)}{\" \"}\n {tr(\"auth.users.of\", \"of\")} {totalCount}{\" \"}\n {tr(\"auth.users.paginationUsers\", \"users\")}\n </p>\n <nav\n aria-label={tr(\n \"auth.users.paginationAriaLabel\",\n \"User list pagination\",\n )}\n >\n <div className=\"flex gap-2\">\n <button\n type=\"button\"\n onClick={() =>\n setCurrentPage((prev) => Math.max(0, prev - 1))\n }\n disabled={currentPage === 0}\n aria-label={tr(\n \"auth.users.previousPageAriaLabel\",\n \"Go to previous page\",\n )}\n className=\"px-4 py-2 rounded-lg border border-border-default text-text-primary bg-bg-surface disabled:opacity-50 disabled:cursor-not-allowed hover:bg-bg-sunken transition-colors\"\n >\n {tr(\"auth.users.previousPage\", \"Previous\")}\n </button>\n <button\n type=\"button\"\n onClick={() => setCurrentPage((prev) => prev + 1)}\n disabled={!hasMore}\n aria-label={tr(\n \"auth.users.nextPageAriaLabel\",\n \"Go to next page\",\n )}\n className=\"px-4 py-2 rounded-lg border border-border-default text-text-primary bg-bg-surface disabled:opacity-50 disabled:cursor-not-allowed hover:bg-bg-sunken transition-colors\"\n >\n {tr(\"auth.users.nextPage\", \"Next\")}\n </button>\n </div>\n </nav>\n </div>\n\n <NextSteps\n className=\"mt-8\"\n storageKey=\"auth-users\"\n steps={[\n {\n id: \"search-user\",\n label: tr(\"auth.users.nextSearch\", \"Find a specific person\"),\n description: tr(\n \"auth.users.nextSearchDesc\",\n \"Use the search and role / status filters above to narrow the list.\",\n ),\n onClick: () => {\n const el = document.getElementById(searchId);\n if (el instanceof HTMLInputElement) el.focus();\n },\n },\n {\n id: \"export-users\",\n label: tr(\"auth.users.nextExport\", \"Export the user list\"),\n description: tr(\n \"auth.users.nextExportDesc\",\n \"Download a CSV of users matching your current filters.\",\n ),\n onClick: () => void handleExport(),\n },\n ]}\n />\n </>\n )}\n </div>\n );\n }\n\n // View details\n if (viewMode === \"view\" && selectedUser) {\n return (\n <div className=\"max-w-4xl mx-auto p-6\">\n {pendingActionDialog}\n <button\n type=\"button\"\n onClick={() => {\n setViewMode(\"list\");\n resetForm();\n }}\n className=\"mb-6 flex items-center gap-2 text-text-link hover:underline font-medium\"\n >\n <svg\n className=\"size-4\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M10 19l-7-7m0 0l7-7m-7 7h18\"\n />\n </svg>\n {tr(\"auth.users.backToUsers\", \"Back to Users\")}\n </button>\n\n <div className=\"p-6 rounded-xl border border-border-subtle bg-bg-surface shadow-sm\">\n <div className=\"flex items-center gap-4 mb-8\">\n <div className=\"auth-brand-badge flex size-20 items-center justify-center rounded-full text-3xl font-semibold\">\n {(\n selectedUser.name ||\n selectedUser.username ||\n selectedUser.email\n )\n .charAt(0)\n .toUpperCase()}\n </div>\n <div>\n <h1 className=\"text-2xl font-semibold text-text-primary\">\n {selectedUser.name || selectedUser.username}\n </h1>\n <p className=\"text-text-secondary\">{selectedUser.email}</p>\n <div className=\"flex items-center gap-2 mt-2\">\n <span\n className={`px-2.5 py-1 rounded-full text-xs font-medium ${getRoleColor(selectedUser.role)}`}\n >\n {selectedUser.role.replace(\"_\", \" \")}\n </span>\n <span\n className={`px-2.5 py-1 rounded-full text-xs font-medium ${getStatusColor(selectedUser.status)}`}\n >\n {selectedUser.status}\n </span>\n </div>\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-6\">\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsUsername\", \"Username\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.username}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsEmail\", \"Email\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.email}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsEmailVerified\", \"Email Verified\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.emailVerified ? (\n <span className=\"text-status-success-text\">\n {tr(\"auth.users.verifiedYes\", \"Yes\")}\n </span>\n ) : (\n <span className=\"text-status-warning-text\">\n {tr(\"auth.users.verifiedNo\", \"No\")}\n </span>\n )}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsMfaEnabled\", \"MFA Enabled\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.mfaEnabled ? (\n <span className=\"text-status-success-text\">\n {tr(\"auth.users.verifiedYes\", \"Yes\")}\n </span>\n ) : (\n <span className=\"text-text-secondary\">\n {tr(\"auth.users.verifiedNo\", \"No\")}\n </span>\n )}\n </p>\n </div>\n\n {selectedUser.phoneNumber && (\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsPhoneNumber\", \"Phone Number\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.phoneNumber}\n </p>\n </div>\n )}\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsLastLogin\", \"Last Login\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.lastLoginAt\n ? new Date(selectedUser.lastLoginAt).toLocaleString()\n : tr(\"auth.users.lastLoginNever\", \"Never\")}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsCreated\", \"Created\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {new Date(selectedUser.createdAt).toLocaleString()}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsLastUpdated\", \"Last Updated\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {new Date(selectedUser.updatedAt).toLocaleString()}\n </p>\n </div>\n </div>\n\n <div className=\"mt-8 pt-6 border-t border-border-subtle flex gap-3\">\n <Button\n type=\"button\"\n onClick={() => startEdit(selectedUser)}\n variant=\"default\"\n className=\"font-semibold\"\n >\n {tr(\"auth.users.editUser\", \"Edit User\")}\n </Button>\n {selectedUser.status !== \"DELETED\" && (\n <button\n type=\"button\"\n onClick={() =>\n setPendingAction({ type: \"deactivate\", user: selectedUser })\n }\n className=\"px-4 py-2.5 rounded-xl border border-status-error-border text-status-error-text font-medium hover:bg-status-error-bg-subtle transition-colors\"\n >\n {tr(\"auth.users.deleteUser\", \"Delete User\")}\n </button>\n )}\n {canDeleteUsers &&\n selectedUser.status === \"DELETED\" &&\n currentUser?.role === \"SUPER_ADMIN\" && (\n <button\n type=\"button\"\n onClick={() =>\n setPendingAction({\n type: \"hard-delete\",\n user: selectedUser,\n })\n }\n className=\"px-4 py-2.5 rounded-xl bg-status-error-bg text-white font-medium hover:bg-status-error-bg-hover transition-colors\"\n >\n {tr(\"auth.users.permanentlyDelete\", \"Permanently Delete\")}\n </button>\n )}\n </div>\n </div>\n </div>\n );\n }\n\n // Edit form\n return (\n <div className=\"max-w-2xl mx-auto p-6\">\n {pendingActionDialog}\n <button\n type=\"button\"\n onClick={() => {\n setViewMode(\"list\");\n resetForm();\n }}\n className=\"mb-6 flex items-center gap-2 text-text-link hover:underline font-medium\"\n >\n <svg\n className=\"size-4\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M10 19l-7-7m0 0l7-7m-7 7h18\"\n />\n </svg>\n {tr(\"auth.users.backToUsers\", \"Back to Users\")}\n </button>\n\n <div className=\"p-6 rounded-xl border border-border-subtle bg-bg-surface shadow-sm\">\n <h1 className=\"text-2xl font-semibold text-text-primary mb-2\">\n {tr(\"auth.users.editUser\", \"Edit User\")}\n </h1>\n\n {selectedUser && (\n <p className=\"text-text-secondary mb-6\">\n {tr(\"auth.users.editingUser\", \"Editing:\")}{\" \"}\n <strong>{selectedUser.email}</strong>\n </p>\n )}\n\n {error && (\n <div className=\"mb-6 flex items-start gap-3 p-4 rounded-xl bg-status-error-bg-subtle border border-status-error-border\">\n <AlertIcon className=\"size-5 text-status-error-text flex-shrink-0 mt-0.5\" />\n <div className=\"flex-1\">\n <p className=\"text-sm text-status-error-text\">{error}</p>\n </div>\n </div>\n )}\n\n <form\n onSubmit={handleUpdate}\n className=\"space-y-6\"\n aria-describedby={error ? errorId : undefined}\n noValidate\n >\n <div className=\"grid grid-cols-2 gap-4\">\n <div>\n <label\n htmlFor=\"edit-username\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldUsername\", \"Username\")}\n </label>\n <input\n id=\"edit-username\"\n type=\"text\"\n aria-label={tr(\"auth.users.fieldUsername\", \"Username\")}\n value={formData.username}\n onChange={(e) =>\n setFormData({ ...formData, username: e.target.value })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n\n <div>\n <label\n htmlFor=\"edit-name\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldName\", \"Name\")}\n </label>\n <input\n id=\"edit-name\"\n type=\"text\"\n aria-label={tr(\"auth.users.fieldName\", \"Name\")}\n value={formData.name}\n onChange={(e) =>\n setFormData({ ...formData, name: e.target.value })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-4\">\n <div>\n <label\n htmlFor=\"edit-firstName\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldFirstName\", \"First Name\")}\n </label>\n <input\n id=\"edit-firstName\"\n type=\"text\"\n aria-label={tr(\"auth.users.fieldFirstName\", \"First Name\")}\n value={formData.firstName}\n onChange={(e) =>\n setFormData({ ...formData, firstName: e.target.value })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n\n <div>\n <label\n htmlFor=\"edit-lastName\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldLastName\", \"Last Name\")}\n </label>\n <input\n id=\"edit-lastName\"\n type=\"text\"\n aria-label={tr(\"auth.users.fieldLastName\", \"Last Name\")}\n value={formData.lastName}\n onChange={(e) =>\n setFormData({ ...formData, lastName: e.target.value })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n </div>\n\n <div>\n <label\n htmlFor=\"edit-phoneNumber\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldPhoneNumber\", \"Phone Number\")}\n </label>\n <input\n id=\"edit-phoneNumber\"\n type=\"tel\"\n aria-label={tr(\"auth.users.fieldPhoneNumber\", \"Phone Number\")}\n value={formData.phoneNumber}\n onChange={(e) =>\n setFormData({ ...formData, phoneNumber: e.target.value })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n\n <div className=\"grid grid-cols-2 gap-4\">\n <div>\n <label\n htmlFor=\"edit-role\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldRole\", \"Role\")}\n </label>\n <select\n id=\"edit-role\"\n aria-label={tr(\"auth.users.fieldRole\", \"Role\")}\n value={formData.role}\n onChange={(e) =>\n setFormData({ ...formData, role: e.target.value as UserRole })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n >\n {USER_ROLES.map((role) => (\n <option key={role} value={role}>\n {role.replace(\"_\", \" \")}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <label\n htmlFor=\"edit-status\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldStatus\", \"Status\")}\n </label>\n <select\n id=\"edit-status\"\n aria-label={tr(\"auth.users.fieldStatus\", \"Status\")}\n value={formData.status}\n onChange={(e) =>\n setFormData({\n ...formData,\n status: e.target.value as UserStatus,\n })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n >\n {USER_STATUSES.map((status) => (\n <option key={status} value={status}>\n {status}\n </option>\n ))}\n </select>\n </div>\n </div>\n\n <div className=\"flex items-center gap-3 p-4 rounded-xl bg-bg-canvas/50\">\n <input\n type=\"checkbox\"\n id=\"emailVerified\"\n checked={formData.emailVerified}\n onChange={(e) =>\n setFormData({ ...formData, emailVerified: e.target.checked })\n }\n className=\"size-5 rounded border-border-default text-text-link focus:ring-action-primary-bg\"\n />\n <label\n htmlFor=\"emailVerified\"\n className=\"text-sm font-medium text-text-primary\"\n >\n {tr(\"auth.users.fieldEmailVerified\", \"Email Verified\")}\n </label>\n </div>\n\n <div className=\"flex gap-3 pt-4\">\n <Button\n type=\"submit\"\n disabled={isLoading}\n variant=\"default\"\n size=\"lg\"\n className=\"font-semibold\"\n >\n {isLoading ? (\n <>\n <SpinnerIcon className=\"size-4 animate-spin\" />\n {tr(\"auth.users.saving\", \"Saving...\")}\n </>\n ) : (\n tr(\"auth.users.saveChanges\", \"Save Changes\")\n )}\n </Button>\n <button\n type=\"button\"\n onClick={() => {\n setViewMode(\"list\");\n resetForm();\n }}\n className=\"px-6 py-3 rounded-xl border border-border-default text-text-primary font-medium hover:bg-bg-sunken transition-colors\"\n >\n {tr(\"auth.users.cancel\", \"Cancel\")}\n </button>\n </div>\n </form>\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"UsersPage.js","names":[],"sources":["../../../src/auth/pages/UsersPage.tsx"],"sourcesContent":["import { useState, useEffect, useCallback, useId } from \"react\";\nimport { useNavigate } from \"react-router\";\nimport { useTr } from \"../../shared/hooks/useTr\";\nimport { Button } from \"@burdenoff/fe-libs/button\";\nimport {\n EmphasisPanel,\n IllustratedEmptyState,\n PagePurpose,\n NextSteps,\n} from \"@burdenoff/fe-libs/ui\";\nimport { useAuthPermissions } from \"../hooks/useAuthPermissions\";\nimport { useUserOperations } from \"../hooks/useUserOperations\";\nimport { validatePhoneNumber } from \"../utils/validation\";\nimport { useAuthStore } from \"../store/authStore\";\nimport { validateUserFields } from \"../utils/validation\";\nimport { ConfirmActionDialog } from \"../components/ConfirmActionDialog\";\nimport type {\n User,\n UserRole,\n UserStatus,\n UserFilterInput,\n PaginationInput,\n} from \"../types\";\nimport { SpinnerIcon, AlertIcon } from \"../components/AuthIcons\";\nimport { nativeImpact, nativeNotify } from \"../../utils/nativeBridge\";\n\ntype ViewMode = \"list\" | \"edit\" | \"view\";\n\ninterface FormData {\n username: string;\n firstName: string;\n lastName: string;\n name: string;\n phoneNumber: string;\n role: UserRole;\n status: UserStatus;\n emailVerified: boolean;\n}\n\n// SUPER_ADMIN is determined by email allowlist at runtime, not stored as a\n// DB role — filtering by it always returns 0 rows, so it is excluded here.\nconst USER_ROLES: UserRole[] = [\n \"TENANT_ADMIN\",\n \"ORG_ADMIN\",\n \"WORKSPACE_ADMIN\",\n \"MEMBER\",\n \"GUEST\",\n];\n\nconst USER_STATUSES: UserStatus[] = [\n \"ACTIVE\",\n \"INACTIVE\",\n \"SUSPENDED\",\n \"DELETED\",\n];\n\n/**\n * Users CRUDL page for managing users (admin only)\n */\nexport function UsersPage() {\n const tr = useTr();\n const navigate = useNavigate();\n\n const isAuthenticated = useAuthStore((state) => state.isAuthenticated);\n const currentUser = useAuthStore((state) => state.user);\n const {\n canViewUsers,\n canUpdateUsers,\n canDeleteUsers,\n isLoading: permissionsLoading,\n } = useAuthPermissions();\n const { listUsers, updateUser, deleteUser, hardDeleteUser, isLoading } =\n useUserOperations();\n\n // Stable IDs for ARIA associations (accessibility)\n const searchId = useId();\n const roleFilterId = useId();\n const statusFilterId = useId();\n const errorId = useId();\n const successId = useId();\n\n const [isExporting, setIsExporting] = useState(false);\n\n const handleExport = async () => {\n setIsExporting(true);\n try {\n const filter: UserFilterInput = {};\n if (searchQuery) filter.search = searchQuery;\n if (filterRole) filter.role = filterRole;\n if (filterStatus) filter.status = filterStatus;\n\n const result = await listUsers(filter, { offset: 0, limit: 10000 });\n const rows = result.users;\n\n const headers = [\n \"ID\",\n \"Email\",\n \"Username\",\n \"First Name\",\n \"Last Name\",\n \"Phone\",\n \"Role\",\n \"Status\",\n \"Email Verified\",\n \"MFA Enabled\",\n \"Last Login\",\n \"Created At\",\n ];\n\n const escape = (v: string | undefined | null) => {\n if (v == null) return \"\";\n const s = String(v);\n return s.includes(\",\") || s.includes('\"') || s.includes(\"\\n\")\n ? `\"${s.replace(/\"/g, '\"\"')}\"`\n : s;\n };\n\n const lines = [\n headers.join(\",\"),\n ...rows.map((u) =>\n [\n escape(u.id),\n escape(u.email),\n escape(u.username),\n escape(u.firstName),\n escape(u.lastName),\n escape(u.phoneNumber),\n escape(u.role),\n escape(u.status),\n escape(String(u.emailVerified)),\n escape(String(u.mfaEnabled)),\n escape(u.lastLoginAt),\n escape(u.createdAt),\n ].join(\",\"),\n ),\n ];\n\n const blob = new Blob([lines.join(\"\\n\")], {\n type: \"text/csv;charset=utf-8;\",\n });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `users-${new Date().toISOString().slice(0, 10)}.csv`;\n a.click();\n URL.revokeObjectURL(url);\n } catch (err) {\n setError(err instanceof Error ? err.message : \"Failed to export users\");\n } finally {\n setIsExporting(false);\n }\n };\n\n const [users, setUsers] = useState<User[]>([]);\n const [totalCount, setTotalCount] = useState(0);\n const [hasMore, setHasMore] = useState(false);\n const [viewMode, setViewMode] = useState<ViewMode>(\"list\");\n const [selectedUser, setSelectedUser] = useState<User | null>(null);\n const [formData, setFormData] = useState<FormData>({\n username: \"\",\n firstName: \"\",\n lastName: \"\",\n name: \"\",\n phoneNumber: \"\",\n role: \"MEMBER\",\n status: \"ACTIVE\",\n emailVerified: false,\n });\n const [error, setError] = useState<string | null>(null);\n const [phoneError, setPhoneError] = useState<string | null>(null);\n const [isUnauthorized, setIsUnauthorized] = useState(false);\n const [successMessage, setSuccessMessage] = useState<string | null>(null);\n const [pendingAction, setPendingAction] = useState<{\n type: \"deactivate\" | \"hard-delete\";\n user: User;\n } | null>(null);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const [filterRole, setFilterRole] = useState<UserRole | \"\">(\"\");\n const [filterStatus, setFilterStatus] = useState<UserStatus | \"\">(\"\");\n const [currentPage, setCurrentPage] = useState(0);\n const pageSize = 10;\n\n const fetchUsers = useCallback(async () => {\n if (!isAuthenticated) return;\n\n try {\n setIsUnauthorized(false);\n const filter: UserFilterInput = {};\n if (searchQuery) filter.search = searchQuery;\n if (filterRole) filter.role = filterRole;\n if (filterStatus) filter.status = filterStatus;\n\n const pagination: PaginationInput = {\n offset: currentPage * pageSize,\n limit: pageSize,\n };\n\n const result = await listUsers(filter, pagination);\n setUsers(result.users);\n setTotalCount(result.totalCount);\n setHasMore(result.hasMore);\n setError(null);\n } catch (err) {\n // Ignore abort errors - they happen during component unmount/re-render\n if (\n err instanceof Error &&\n (err.name === \"AbortError\" || err.message.includes(\"aborted\"))\n ) {\n return;\n }\n const errorMessage =\n err instanceof Error ? err.message : \"Failed to fetch users\";\n // Check if it's an authorization error\n if (\n errorMessage.toLowerCase().includes(\"not authorized\") ||\n errorMessage.toLowerCase().includes(\"forbidden\")\n ) {\n setIsUnauthorized(true);\n setError(null);\n } else {\n setError(errorMessage);\n }\n }\n }, [\n listUsers,\n searchQuery,\n filterRole,\n filterStatus,\n currentPage,\n isAuthenticated,\n ]);\n\n useEffect(() => {\n fetchUsers();\n }, [fetchUsers]);\n\n const resetForm = () => {\n setFormData({\n username: \"\",\n firstName: \"\",\n lastName: \"\",\n name: \"\",\n phoneNumber: \"\",\n role: \"MEMBER\",\n status: \"ACTIVE\",\n emailVerified: false,\n });\n setSelectedUser(null);\n setError(null);\n setPhoneError(null);\n setSuccessMessage(null);\n };\n\n const handleUpdate = async (e: React.FormEvent) => {\n e.preventDefault();\n if (!selectedUser) return;\n setError(null);\n setPhoneError(null);\n\n const phoneValidation = validatePhoneNumber(formData.phoneNumber);\n if (!phoneValidation.valid) {\n const phoneMsg = tr(\n \"auth.users.phoneNumber_invalid\",\n \"Invalid phone number\",\n );\n setPhoneError(phoneMsg);\n setError(phoneMsg);\n return;\n }\n\n // BOFF-7214: same name/username invariants the signup form enforces\n // must hold on user edit too, otherwise an admin (or anyone with\n // users:update) can re-introduce a numbers-only account. Same\n // validator the register form uses, same canonicalised output.\n // Pass `requireAll: false` so an existing user whose optional\n // `firstName`/`lastName` is unset (e.g. provisioned via IdP) can\n // still update unrelated fields like phone or role (Codex P2 review).\n const validation = validateUserFields(formData, { requireAll: false });\n if (!validation.valid) {\n setError(\n validation.errors\n .map((e) => {\n const key = `auth.users.${e.field}_${e.code}`;\n return tr(key, e.message);\n })\n .join(\" · \"),\n );\n return;\n }\n\n try {\n await updateUser(selectedUser.id, {\n username: validation.values.username || undefined,\n firstName: validation.values.firstName || undefined,\n lastName: validation.values.lastName || undefined,\n name: formData.name || undefined,\n phoneNumber: formData.phoneNumber || undefined,\n role: formData.role,\n status: formData.status,\n emailVerified: formData.emailVerified,\n });\n setSuccessMessage(\n tr(\"auth.users.updateSuccess\", \"User updated successfully!\"),\n );\n setViewMode(\"list\");\n resetForm();\n fetchUsers();\n } catch (err) {\n setError(err instanceof Error ? err.message : \"Failed to update user\");\n }\n };\n\n const handleDelete = async (user: User) => {\n void nativeImpact(\"medium\");\n try {\n const result = await deleteUser(user.id);\n if (result.success) {\n void nativeNotify(\"success\");\n setSuccessMessage(\n tr(\n \"auth.users.userDeactivated\",\n 'User \"{email}\" has been deactivated.',\n ).replace(\"{email}\", user.email),\n );\n setPendingAction(null);\n fetchUsers();\n } else {\n void nativeNotify(\"error\");\n setError(result.message);\n }\n } catch (err) {\n void nativeNotify(\"error\");\n setError(err instanceof Error ? err.message : \"Failed to delete user\");\n }\n };\n\n const handleHardDelete = async (user: User) => {\n try {\n const result = await hardDeleteUser(user.id);\n if (result.success) {\n setSuccessMessage(\n tr(\n \"auth.users.userPermanentlyDeleted\",\n 'User \"{email}\" has been permanently deleted.',\n ).replace(\"{email}\", user.email),\n );\n setPendingAction(null);\n fetchUsers();\n } else {\n setError(result.message);\n }\n } catch (err) {\n setError(\n err instanceof Error ? err.message : \"Failed to hard delete user\",\n );\n }\n };\n\n const startEdit = (user: User) => {\n setSelectedUser(user);\n setFormData({\n username: user.username || \"\",\n firstName: user.firstName || \"\",\n lastName: user.lastName || \"\",\n name: user.name || \"\",\n phoneNumber: user.phoneNumber || \"\",\n role: user.role,\n status: user.status,\n emailVerified: user.emailVerified,\n });\n setViewMode(\"edit\");\n setError(null);\n };\n\n const pendingActionDialog = (\n <ConfirmActionDialog\n open={pendingAction !== null}\n onOpenChange={(open) => {\n if (!open) {\n setPendingAction(null);\n }\n }}\n title={\n pendingAction?.type === \"hard-delete\"\n ? tr(\"auth.users.hardDeleteTitle\", \"Permanently delete user?\")\n : tr(\"auth.users.deactivateTitle\", \"Deactivate user?\")\n }\n description={\n pendingAction?.type === \"hard-delete\"\n ? pendingAction\n ? tr(\n \"auth.users.hardDeleteDesc\",\n 'This permanently deletes \"{email}\" and cannot be undone.',\n ).replace(\"{email}\", pendingAction.user.email)\n : \"\"\n : pendingAction\n ? tr(\n \"auth.users.deactivateDesc\",\n '\"{email}\" will be prevented from signing in until reactivated.',\n ).replace(\"{email}\", pendingAction.user.email)\n : \"\"\n }\n confirmText={\n pendingAction?.type === \"hard-delete\"\n ? tr(\"auth.users.hardDeleteConfirm\", \"Delete permanently\")\n : tr(\"auth.users.deactivateConfirm\", \"Deactivate user\")\n }\n variant={pendingAction?.type === \"hard-delete\" ? \"danger\" : \"warning\"}\n onConfirm={async () => {\n if (!pendingAction) return;\n if (pendingAction.type === \"hard-delete\") {\n await handleHardDelete(pendingAction.user);\n return;\n }\n await handleDelete(pendingAction.user);\n }}\n />\n );\n\n const getRoleColor = (role: UserRole) => {\n switch (role) {\n case \"SUPER_ADMIN\":\n return \"bg-accent-purple-subtle text-accent-purple\";\n case \"TENANT_ADMIN\":\n return \"bg-status-info-bg-subtle text-status-info-text\";\n case \"ORG_ADMIN\":\n return \"bg-accent-indigo-subtle text-accent-indigo\";\n case \"WORKSPACE_ADMIN\":\n return \"bg-accent-cyan-subtle text-accent-cyan\";\n case \"MEMBER\":\n return \"bg-status-success-bg-subtle text-status-success-text\";\n case \"GUEST\":\n return \"bg-bg-sunken text-text-primary\";\n default:\n return \"bg-bg-sunken text-text-primary\";\n }\n };\n\n const getStatusColor = (status: UserStatus) => {\n switch (status) {\n case \"ACTIVE\":\n return \"bg-status-success-bg-subtle text-status-success-text\";\n case \"INACTIVE\":\n return \"bg-status-warning-bg-subtle text-status-warning-text\";\n case \"SUSPENDED\":\n return \"bg-status-warning-bg-subtle text-status-warning-text\";\n case \"DELETED\":\n return \"bg-status-error-bg-subtle text-status-error-text\";\n default:\n return \"bg-bg-sunken text-text-primary\";\n }\n };\n\n // Authentication check\n if (!isAuthenticated) {\n return (\n <div className=\"max-w-6xl mx-auto p-6\">\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title={tr(\"auth.users.authRequired\", \"Authentication Required\")}\n description={tr(\n \"auth.users.signInToManage\",\n \"Please sign in to manage users\",\n )}\n />\n </div>\n );\n }\n\n // Loading permissions\n if (permissionsLoading) {\n return (\n <div className=\"max-w-6xl mx-auto p-6\">\n <div className=\"text-center py-16\">\n <SpinnerIcon className=\"size-8 mx-auto text-text-secondary animate-spin\" />\n <p className=\"mt-4 text-text-secondary\">\n {tr(\"auth.users.loadingPermissions\", \"Loading permissions...\")}\n </p>\n </div>\n </div>\n );\n }\n\n // Authorization check - user needs user:read permission\n if (!canViewUsers || isUnauthorized) {\n return (\n <div className=\"max-w-6xl mx-auto p-6\">\n <IllustratedEmptyState\n illustration=\"empty-generic\"\n title={tr(\"auth.users.adminAccessRequired\", \"Admin Access Required\")}\n description={\n <>\n {tr(\n \"auth.users.adminAccessDesc\",\n \"You need administrator privileges to view and manage users.\",\n )}{\" \"}\n {tr(\n \"auth.users.requiredRoles\",\n \"Required roles: Super Admin, Tenant Admin, or Org Admin\",\n )}\n </>\n }\n />\n </div>\n );\n }\n\n // List view\n if (viewMode === \"list\") {\n return (\n <div className=\"max-w-6xl mx-auto p-6\">\n {pendingActionDialog}\n {/* Header */}\n <div className=\"flex items-center justify-between mb-8\">\n <div>\n <h1 className=\"text-3xl font-semibold text-text-primary\">\n {tr(\"auth.users.title\", \"Users\")}\n </h1>\n <p className=\"text-text-secondary mt-2\">\n {tr(\"auth.users.subtitle\", \"Manage users in your organization\")} (\n {totalCount} {tr(\"auth.users.paginationUsers\", \"total\")})\n </p>\n </div>\n {/* Export button */}\n <button\n type=\"button\"\n onClick={() => void handleExport()}\n disabled={isExporting}\n aria-label={tr(\"auth.users.exportAriaLabel\", \"Export user list\")}\n className=\"flex items-center gap-2 px-4 py-2.5 rounded-xl border border-border-default text-text-primary bg-bg-surface hover:bg-bg-sunken disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium\"\n >\n {isExporting ? (\n <>\n <SpinnerIcon\n className=\"size-4 animate-spin\"\n aria-hidden=\"true\"\n />\n {tr(\"auth.users.exporting\", \"Exporting...\")}\n </>\n ) : (\n <>\n <svg\n className=\"size-4\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n aria-hidden=\"true\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4\"\n />\n </svg>\n {tr(\"auth.users.export\", \"Export\")}\n </>\n )}\n </button>\n </div>\n\n <PagePurpose className=\"mb-6\">\n {tr(\n \"auth.users.purpose\",\n \"Manage everyone with access to your organization. Search and filter accounts, review their role and status, edit details, or deactivate people who should no longer have access.\",\n )}\n </PagePurpose>\n\n {/* Emphasis: directory summary — the one highlighted zone */}\n <EmphasisPanel className=\"mb-6\">\n <p className=\"text-sm font-medium text-text-secondary\">\n {tr(\"auth.users.summaryLabel\", \"People in this organization\")}\n </p>\n <p className=\"mt-1 text-3xl font-semibold text-text-primary\">\n {totalCount}\n </p>\n <p className=\"mt-1 text-sm text-text-secondary\">\n {tr(\n \"auth.users.summaryHint\",\n \"Use the filters below to find a specific person or role.\",\n )}\n </p>\n </EmphasisPanel>\n\n {/* Live region for status announcements */}\n <div aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n {successMessage}\n </div>\n <div\n role=\"alert\"\n aria-live=\"assertive\"\n aria-atomic=\"true\"\n className=\"sr-only\"\n >\n {error}\n </div>\n\n {/* Success Message */}\n {successMessage && (\n <output\n id={successId}\n className=\"block mb-6 p-4 rounded-xl bg-status-success-bg-subtle border border-status-success-border\"\n >\n <p className=\"text-status-success-text\">{successMessage}</p>\n <button\n type=\"button\"\n onClick={() => setSuccessMessage(null)}\n className=\"mt-2 text-sm text-status-success-text hover:underline\"\n >\n {tr(\"auth.users.dismiss\", \"Dismiss\")}\n </button>\n </output>\n )}\n\n {/* Error Message */}\n {error && (\n <div\n id={errorId}\n role=\"alert\"\n className=\"mb-6 flex items-start gap-3 p-4 rounded-xl bg-status-error-bg-subtle border border-status-error-border\"\n >\n <AlertIcon\n className=\"size-5 text-status-error-text flex-shrink-0 mt-0.5\"\n aria-hidden=\"true\"\n />\n <div className=\"flex-1\">\n <p className=\"text-sm text-status-error-text\">{error}</p>\n <button\n type=\"button\"\n onClick={() => setError(null)}\n className=\"mt-2 text-sm text-status-error-text hover:underline\"\n >\n {tr(\"auth.users.dismiss\", \"Dismiss\")}\n </button>\n </div>\n </div>\n )}\n\n {/* Filters */}\n <div className=\"mb-6 p-4 rounded-xl border border-border-subtle bg-bg-surface\">\n <div className=\"flex flex-wrap gap-4 items-end\">\n {/* Visually hidden labels satisfy WCAG — placeholders are not labels */}\n <div className=\"flex-1 min-w-[200px]\">\n <label htmlFor={searchId} className=\"sr-only\">\n {tr(\"auth.users.searchLabel\", \"Search users\")}\n </label>\n <input\n id={searchId}\n type=\"search\"\n aria-label={tr(\"auth.users.searchLabel\", \"Search users\")}\n placeholder={tr(\n \"auth.users.searchPlaceholder\",\n \"Search by email, username, or name...\",\n )}\n value={searchQuery}\n onChange={(e) => {\n setSearchQuery(e.target.value);\n setCurrentPage(0);\n }}\n className=\"w-full px-4 py-2.5 border border-border-default rounded-xl bg-bg-surface text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n <div>\n <label htmlFor={roleFilterId} className=\"sr-only\">\n {tr(\"auth.users.filterRoleLabel\", \"Filter by role\")}\n </label>\n <select\n id={roleFilterId}\n value={filterRole}\n onChange={(e) => {\n setFilterRole(e.target.value as UserRole | \"\");\n setCurrentPage(0);\n }}\n className=\"px-4 py-2.5 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n >\n <option value=\"\">\n {tr(\"auth.users.allRoles\", \"All Roles\")}\n </option>\n {USER_ROLES.map((role) => (\n <option key={role} value={role}>\n {role.replace(\"_\", \" \")}\n </option>\n ))}\n </select>\n </div>\n <div>\n <label htmlFor={statusFilterId} className=\"sr-only\">\n {tr(\"auth.users.filterStatusLabel\", \"Filter by status\")}\n </label>\n <select\n id={statusFilterId}\n value={filterStatus}\n onChange={(e) => {\n setFilterStatus(e.target.value as UserStatus | \"\");\n setCurrentPage(0);\n }}\n className=\"px-4 py-2.5 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n >\n <option value=\"\">\n {tr(\"auth.users.allStatuses\", \"All Statuses\")}\n </option>\n {USER_STATUSES.map((status) => (\n <option key={status} value={status}>\n {status}\n </option>\n ))}\n </select>\n </div>\n </div>\n </div>\n\n {isLoading ? (\n <div\n aria-live=\"polite\"\n aria-busy=\"true\"\n className=\"flex items-center justify-center py-12\"\n >\n <SpinnerIcon\n className=\"size-8 text-text-link animate-spin\"\n aria-hidden=\"true\"\n />\n <span className=\"ml-3 text-text-secondary\">\n {tr(\"auth.users.loadingUsers\", \"Loading users...\")}\n </span>\n </div>\n ) : users.length === 0 ? (\n <IllustratedEmptyState\n illustration={\n searchQuery || filterRole || filterStatus\n ? \"empty-search\"\n : \"empty-people\"\n }\n title={tr(\"auth.users.noUsersFound\", \"No users found\")}\n description={\n searchQuery || filterRole || filterStatus\n ? tr(\n \"auth.users.noUsersFilteredDesc\",\n \"No users match your current search or filters. Try clearing them to see everyone.\",\n )\n : tr(\n \"auth.users.noUsersDesc\",\n \"Once people join your organization they'll show up here for you to manage.\",\n )\n }\n />\n ) : (\n <>\n {/* Users Table */}\n <div className=\"rounded-xl border border-border-subtle bg-bg-surface overflow-hidden shadow-sm\">\n <div className=\"overflow-x-auto\">\n <table\n className=\"w-full min-w-[800px]\"\n aria-label={tr(\"auth.users.tableAriaLabel\", \"Users table\")}\n >\n <thead>\n <tr className=\"border-b border-border-subtle bg-bg-canvas\">\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[280px]\"\n >\n {tr(\"auth.users.columnUser\", \"User\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[130px]\"\n >\n {tr(\"auth.users.columnRole\", \"Role\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[100px]\"\n >\n {tr(\"auth.users.columnStatus\", \"Status\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[80px]\"\n >\n {tr(\"auth.users.columnVerified\", \"Verified\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-left p-4 text-sm font-medium text-text-secondary w-[100px]\"\n >\n {tr(\"auth.users.columnLastLogin\", \"Last Login\")}\n </th>\n <th\n scope=\"col\"\n className=\"text-right p-4 text-sm font-medium text-text-secondary w-[180px]\"\n >\n {tr(\"auth.users.columnActions\", \"Actions\")}\n </th>\n </tr>\n </thead>\n <tbody>\n {users.map((user) => (\n <tr\n key={user.id}\n className=\"border-b border-border-subtle last:border-b-0 hover:bg-bg-sunken/50 transition-colors\"\n >\n <td className=\"p-4\">\n <div className=\"flex items-center gap-3 min-w-0\">\n <div className=\"auth-brand-badge flex size-10 shrink-0 items-center justify-center rounded-full font-medium\">\n {(user.name || user.username || user.email)\n .charAt(0)\n .toUpperCase()}\n </div>\n <div className=\"min-w-0 flex-1\">\n <p className=\"font-medium text-text-primary truncate\">\n {user.name || user.username}\n </p>\n <p className=\"text-sm text-text-secondary truncate\">\n {user.email}\n </p>\n </div>\n </div>\n </td>\n <td className=\"p-4\">\n <span\n className={`inline-block px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${getRoleColor(user.role)}`}\n >\n {user.role.replace(\"_\", \" \")}\n </span>\n </td>\n <td className=\"p-4\">\n <span\n className={`inline-block px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${getStatusColor(user.status)}`}\n >\n {user.status}\n </span>\n </td>\n <td className=\"p-4\">\n {user.emailVerified ? (\n <span className=\"inline-flex items-center gap-1 text-status-success-text whitespace-nowrap\">\n <svg\n className=\"size-4 flex-shrink-0\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n fillRule=\"evenodd\"\n d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\"\n clipRule=\"evenodd\"\n />\n </svg>\n <span className=\"text-sm\">\n {tr(\"auth.users.verifiedYes\", \"Yes\")}\n </span>\n </span>\n ) : (\n <span className=\"inline-flex items-center gap-1 text-status-warning-text whitespace-nowrap\">\n <svg\n className=\"size-4 flex-shrink-0\"\n fill=\"currentColor\"\n viewBox=\"0 0 20 20\"\n >\n <path\n fillRule=\"evenodd\"\n d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z\"\n clipRule=\"evenodd\"\n />\n </svg>\n <span className=\"text-sm\">\n {tr(\"auth.users.verifiedNo\", \"No\")}\n </span>\n </span>\n )}\n </td>\n <td className=\"p-4 text-sm text-text-secondary whitespace-nowrap\">\n {user.lastLoginAt\n ? new Date(user.lastLoginAt).toLocaleDateString()\n : tr(\"auth.users.lastLoginNever\", \"Never\")}\n </td>\n <td className=\"p-4\">\n <div className=\"flex justify-end gap-1.5 flex-nowrap\">\n <button\n type=\"button\"\n onClick={() => navigate(`/auth/users/${user.id}`)}\n aria-label={`${tr(\"auth.users.actionView\", \"View\")} ${user.name || user.username || user.email}`}\n className=\"px-2.5 py-1.5 rounded-lg text-xs font-medium border border-border-default text-text-primary bg-bg-surface hover:bg-bg-sunken transition-colors whitespace-nowrap flex-shrink-0\"\n >\n {tr(\"auth.users.actionView\", \"View\")}\n </button>\n {canUpdateUsers && (\n <button\n type=\"button\"\n onClick={() => startEdit(user)}\n aria-label={`${tr(\"auth.users.actionEdit\", \"Edit\")} ${user.name || user.username || user.email}`}\n className=\"px-2.5 py-1.5 rounded-lg text-xs font-medium border border-status-info-border text-text-link bg-bg-surface hover:bg-status-info-bg-subtle transition-colors whitespace-nowrap flex-shrink-0\"\n >\n {tr(\"auth.users.actionEdit\", \"Edit\")}\n </button>\n )}\n {canDeleteUsers && user.status !== \"DELETED\" && (\n <button\n type=\"button\"\n onClick={() =>\n setPendingAction({ type: \"deactivate\", user })\n }\n aria-label={`${tr(\"auth.users.actionDelete\", \"Delete\")} ${user.name || user.username || user.email}`}\n className=\"px-2.5 py-1.5 rounded-lg text-xs font-medium border border-status-error-border text-status-error-text bg-bg-surface hover:bg-status-error-bg-subtle transition-colors whitespace-nowrap flex-shrink-0\"\n >\n {tr(\"auth.users.actionDelete\", \"Delete\")}\n </button>\n )}\n </div>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </div>\n\n {/* Pagination */}\n <div className=\"mt-6 flex items-center justify-between\">\n <p className=\"text-sm text-text-secondary\">\n {tr(\"auth.users.showing\", \"Showing\")}{\" \"}\n {currentPage * pageSize + 1} {tr(\"auth.users.to\", \"to\")}{\" \"}\n {Math.min((currentPage + 1) * pageSize, totalCount)}{\" \"}\n {tr(\"auth.users.of\", \"of\")} {totalCount}{\" \"}\n {tr(\"auth.users.paginationUsers\", \"users\")}\n </p>\n <nav\n aria-label={tr(\n \"auth.users.paginationAriaLabel\",\n \"User list pagination\",\n )}\n >\n <div className=\"flex gap-2\">\n <button\n type=\"button\"\n onClick={() =>\n setCurrentPage((prev) => Math.max(0, prev - 1))\n }\n disabled={currentPage === 0}\n aria-label={tr(\n \"auth.users.previousPageAriaLabel\",\n \"Go to previous page\",\n )}\n className=\"px-4 py-2 rounded-lg border border-border-default text-text-primary bg-bg-surface disabled:opacity-50 disabled:cursor-not-allowed hover:bg-bg-sunken transition-colors\"\n >\n {tr(\"auth.users.previousPage\", \"Previous\")}\n </button>\n <button\n type=\"button\"\n onClick={() => setCurrentPage((prev) => prev + 1)}\n disabled={!hasMore}\n aria-label={tr(\n \"auth.users.nextPageAriaLabel\",\n \"Go to next page\",\n )}\n className=\"px-4 py-2 rounded-lg border border-border-default text-text-primary bg-bg-surface disabled:opacity-50 disabled:cursor-not-allowed hover:bg-bg-sunken transition-colors\"\n >\n {tr(\"auth.users.nextPage\", \"Next\")}\n </button>\n </div>\n </nav>\n </div>\n\n <NextSteps\n className=\"mt-8\"\n storageKey=\"auth-users\"\n steps={[\n {\n id: \"search-user\",\n label: tr(\"auth.users.nextSearch\", \"Find a specific person\"),\n description: tr(\n \"auth.users.nextSearchDesc\",\n \"Use the search and role / status filters above to narrow the list.\",\n ),\n onClick: () => {\n const el = document.getElementById(searchId);\n if (el instanceof HTMLInputElement) el.focus();\n },\n },\n {\n id: \"export-users\",\n label: tr(\"auth.users.nextExport\", \"Export the user list\"),\n description: tr(\n \"auth.users.nextExportDesc\",\n \"Download a CSV of users matching your current filters.\",\n ),\n onClick: () => void handleExport(),\n },\n ]}\n />\n </>\n )}\n </div>\n );\n }\n\n // View details\n if (viewMode === \"view\" && selectedUser) {\n return (\n <div className=\"max-w-4xl mx-auto p-6\">\n {pendingActionDialog}\n <button\n type=\"button\"\n onClick={() => {\n setViewMode(\"list\");\n resetForm();\n }}\n className=\"mb-6 flex items-center gap-2 text-text-link hover:underline font-medium\"\n >\n <svg\n className=\"size-4\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M10 19l-7-7m0 0l7-7m-7 7h18\"\n />\n </svg>\n {tr(\"auth.users.backToUsers\", \"Back to Users\")}\n </button>\n\n <div className=\"p-6 rounded-xl border border-border-subtle bg-bg-surface shadow-sm\">\n <div className=\"flex items-center gap-4 mb-8\">\n <div className=\"auth-brand-badge flex size-20 items-center justify-center rounded-full text-3xl font-semibold\">\n {(\n selectedUser.name ||\n selectedUser.username ||\n selectedUser.email\n )\n .charAt(0)\n .toUpperCase()}\n </div>\n <div>\n <h1 className=\"text-2xl font-semibold text-text-primary\">\n {selectedUser.name || selectedUser.username}\n </h1>\n <p className=\"text-text-secondary\">{selectedUser.email}</p>\n <div className=\"flex items-center gap-2 mt-2\">\n <span\n className={`px-2.5 py-1 rounded-full text-xs font-medium ${getRoleColor(selectedUser.role)}`}\n >\n {selectedUser.role.replace(\"_\", \" \")}\n </span>\n <span\n className={`px-2.5 py-1 rounded-full text-xs font-medium ${getStatusColor(selectedUser.status)}`}\n >\n {selectedUser.status}\n </span>\n </div>\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-6\">\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsUsername\", \"Username\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.username}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsEmail\", \"Email\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.email}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsEmailVerified\", \"Email Verified\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.emailVerified ? (\n <span className=\"text-status-success-text\">\n {tr(\"auth.users.verifiedYes\", \"Yes\")}\n </span>\n ) : (\n <span className=\"text-status-warning-text\">\n {tr(\"auth.users.verifiedNo\", \"No\")}\n </span>\n )}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsMfaEnabled\", \"MFA Enabled\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.mfaEnabled ? (\n <span className=\"text-status-success-text\">\n {tr(\"auth.users.verifiedYes\", \"Yes\")}\n </span>\n ) : (\n <span className=\"text-text-secondary\">\n {tr(\"auth.users.verifiedNo\", \"No\")}\n </span>\n )}\n </p>\n </div>\n\n {selectedUser.phoneNumber && (\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsPhoneNumber\", \"Phone Number\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.phoneNumber}\n </p>\n </div>\n )}\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsLastLogin\", \"Last Login\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {selectedUser.lastLoginAt\n ? new Date(selectedUser.lastLoginAt).toLocaleString()\n : tr(\"auth.users.lastLoginNever\", \"Never\")}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsCreated\", \"Created\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {new Date(selectedUser.createdAt).toLocaleString()}\n </p>\n </div>\n\n <div className=\"p-4 rounded-lg bg-bg-canvas/50\">\n <label className=\"block text-sm font-medium text-text-secondary mb-1\">\n {tr(\"auth.users.viewDetailsLastUpdated\", \"Last Updated\")}\n </label>\n <p className=\"text-text-primary font-medium\">\n {new Date(selectedUser.updatedAt).toLocaleString()}\n </p>\n </div>\n </div>\n\n <div className=\"mt-8 pt-6 border-t border-border-subtle flex gap-3\">\n <Button\n type=\"button\"\n onClick={() => startEdit(selectedUser)}\n variant=\"default\"\n className=\"font-semibold\"\n >\n {tr(\"auth.users.editUser\", \"Edit User\")}\n </Button>\n {selectedUser.status !== \"DELETED\" && (\n <button\n type=\"button\"\n onClick={() =>\n setPendingAction({ type: \"deactivate\", user: selectedUser })\n }\n className=\"px-4 py-2.5 rounded-xl border border-status-error-border text-status-error-text font-medium hover:bg-status-error-bg-subtle transition-colors\"\n >\n {tr(\"auth.users.deleteUser\", \"Delete User\")}\n </button>\n )}\n {canDeleteUsers &&\n selectedUser.status === \"DELETED\" &&\n currentUser?.role === \"SUPER_ADMIN\" && (\n <button\n type=\"button\"\n onClick={() =>\n setPendingAction({\n type: \"hard-delete\",\n user: selectedUser,\n })\n }\n className=\"px-4 py-2.5 rounded-xl bg-status-error-bg text-white font-medium hover:bg-status-error-bg-hover transition-colors\"\n >\n {tr(\"auth.users.permanentlyDelete\", \"Permanently Delete\")}\n </button>\n )}\n </div>\n </div>\n </div>\n );\n }\n\n // Edit form\n return (\n <div className=\"max-w-2xl mx-auto p-6\">\n {pendingActionDialog}\n <button\n type=\"button\"\n onClick={() => {\n setViewMode(\"list\");\n resetForm();\n }}\n className=\"mb-6 flex items-center gap-2 text-text-link hover:underline font-medium\"\n >\n <svg\n className=\"size-4\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n strokeWidth={2}\n d=\"M10 19l-7-7m0 0l7-7m-7 7h18\"\n />\n </svg>\n {tr(\"auth.users.backToUsers\", \"Back to Users\")}\n </button>\n\n <div className=\"p-6 rounded-xl border border-border-subtle bg-bg-surface shadow-sm\">\n <h1 className=\"text-2xl font-semibold text-text-primary mb-2\">\n {tr(\"auth.users.editUser\", \"Edit User\")}\n </h1>\n\n {selectedUser && (\n <p className=\"text-text-secondary mb-6\">\n {tr(\"auth.users.editingUser\", \"Editing:\")}{\" \"}\n <strong>{selectedUser.email}</strong>\n </p>\n )}\n\n {error && (\n <div className=\"mb-6 flex items-start gap-3 p-4 rounded-xl bg-status-error-bg-subtle border border-status-error-border\">\n <AlertIcon className=\"size-5 text-status-error-text flex-shrink-0 mt-0.5\" />\n <div className=\"flex-1\">\n <p className=\"text-sm text-status-error-text\">{error}</p>\n </div>\n </div>\n )}\n\n <form\n onSubmit={handleUpdate}\n className=\"space-y-6\"\n aria-describedby={error ? errorId : undefined}\n noValidate\n >\n <div className=\"grid grid-cols-2 gap-4\">\n <div>\n <label\n htmlFor=\"edit-username\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldUsername\", \"Username\")}\n </label>\n <input\n id=\"edit-username\"\n type=\"text\"\n aria-label={tr(\"auth.users.fieldUsername\", \"Username\")}\n value={formData.username}\n onChange={(e) =>\n setFormData({ ...formData, username: e.target.value })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n\n <div>\n <label\n htmlFor=\"edit-name\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldName\", \"Name\")}\n </label>\n <input\n id=\"edit-name\"\n type=\"text\"\n aria-label={tr(\"auth.users.fieldName\", \"Name\")}\n value={formData.name}\n onChange={(e) =>\n setFormData({ ...formData, name: e.target.value })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n </div>\n\n <div className=\"grid grid-cols-2 gap-4\">\n <div>\n <label\n htmlFor=\"edit-firstName\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldFirstName\", \"First Name\")}\n </label>\n <input\n id=\"edit-firstName\"\n type=\"text\"\n aria-label={tr(\"auth.users.fieldFirstName\", \"First Name\")}\n value={formData.firstName}\n onChange={(e) =>\n setFormData({ ...formData, firstName: e.target.value })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n\n <div>\n <label\n htmlFor=\"edit-lastName\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldLastName\", \"Last Name\")}\n </label>\n <input\n id=\"edit-lastName\"\n type=\"text\"\n aria-label={tr(\"auth.users.fieldLastName\", \"Last Name\")}\n value={formData.lastName}\n onChange={(e) =>\n setFormData({ ...formData, lastName: e.target.value })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n </div>\n </div>\n\n <div>\n <label\n htmlFor=\"edit-phoneNumber\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldPhoneNumber\", \"Phone Number\")}\n </label>\n <input\n id=\"edit-phoneNumber\"\n type=\"tel\"\n aria-label={tr(\"auth.users.fieldPhoneNumber\", \"Phone Number\")}\n aria-invalid={phoneError ? true : undefined}\n aria-describedby={\n phoneError ? \"edit-phoneNumber-error\" : undefined\n }\n value={formData.phoneNumber}\n onChange={(e) => {\n setFormData({ ...formData, phoneNumber: e.target.value });\n if (phoneError) {\n setPhoneError(null);\n setError(null);\n }\n }}\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n />\n {phoneError && (\n <p\n id=\"edit-phoneNumber-error\"\n role=\"alert\"\n className=\"mt-2 text-sm text-[var(--color-status-error-text)]\"\n >\n {phoneError}\n </p>\n )}\n </div>\n\n <div className=\"grid grid-cols-2 gap-4\">\n <div>\n <label\n htmlFor=\"edit-role\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldRole\", \"Role\")}\n </label>\n <select\n id=\"edit-role\"\n aria-label={tr(\"auth.users.fieldRole\", \"Role\")}\n value={formData.role}\n onChange={(e) =>\n setFormData({ ...formData, role: e.target.value as UserRole })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n >\n {USER_ROLES.map((role) => (\n <option key={role} value={role}>\n {role.replace(\"_\", \" \")}\n </option>\n ))}\n </select>\n </div>\n\n <div>\n <label\n htmlFor=\"edit-status\"\n className=\"block text-sm font-medium text-text-primary mb-2\"\n >\n {tr(\"auth.users.fieldStatus\", \"Status\")}\n </label>\n <select\n id=\"edit-status\"\n aria-label={tr(\"auth.users.fieldStatus\", \"Status\")}\n value={formData.status}\n onChange={(e) =>\n setFormData({\n ...formData,\n status: e.target.value as UserStatus,\n })\n }\n className=\"block w-full px-4 py-3 border border-border-default rounded-xl bg-bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-action-primary-bg/20 focus:border-action-primary-bg transition-all\"\n >\n {USER_STATUSES.map((status) => (\n <option key={status} value={status}>\n {status}\n </option>\n ))}\n </select>\n </div>\n </div>\n\n <div className=\"flex items-center gap-3 p-4 rounded-xl bg-bg-canvas/50\">\n <input\n type=\"checkbox\"\n id=\"emailVerified\"\n checked={formData.emailVerified}\n onChange={(e) =>\n setFormData({ ...formData, emailVerified: e.target.checked })\n }\n className=\"size-5 rounded border-border-default text-text-link focus:ring-action-primary-bg\"\n />\n <label\n htmlFor=\"emailVerified\"\n className=\"text-sm font-medium text-text-primary\"\n >\n {tr(\"auth.users.fieldEmailVerified\", \"Email Verified\")}\n </label>\n </div>\n\n <div className=\"flex gap-3 pt-6 mt-6 border-t border-border-default\">\n <Button\n type=\"submit\"\n disabled={isLoading}\n variant=\"default\"\n size=\"lg\"\n className=\"font-semibold\"\n >\n {isLoading ? (\n <>\n <SpinnerIcon className=\"size-4 animate-spin\" />\n {tr(\"auth.users.saving\", \"Saving...\")}\n </>\n ) : (\n tr(\"auth.users.saveChanges\", \"Save Changes\")\n )}\n </Button>\n <button\n type=\"button\"\n onClick={() => {\n setViewMode(\"list\");\n resetForm();\n }}\n className=\"inline-flex items-center h-10 px-8 rounded-xl border border-border-default text-text-primary font-medium hover:bg-bg-sunken transition-colors\"\n >\n {tr(\"auth.users.cancel\", \"Cancel\")}\n </button>\n </div>\n </form>\n </div>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -125,3 +125,22 @@ export declare function validateUserFields(input: {
|
|
|
125
125
|
}, options?: {
|
|
126
126
|
requireAll?: boolean;
|
|
127
127
|
}): UserFieldsValidationResult;
|
|
128
|
+
/**
|
|
129
|
+
* Validate a phone number (BOFF-7216).
|
|
130
|
+
*
|
|
131
|
+
* Phone numbers are optional on profile / admin edit forms — an empty input
|
|
132
|
+
* is accepted. When a value is supplied it must:
|
|
133
|
+
* - contain only digits, spaces, and the formatting characters + - ( )
|
|
134
|
+
* - include 7–15 digits (E.164-style length bounds; we don't strip "+"
|
|
135
|
+
* country prefix here, we just count digits)
|
|
136
|
+
* - start with +, a digit, or (
|
|
137
|
+
*
|
|
138
|
+
* Returns `{ valid: true }` on an empty / whitespace-only input.
|
|
139
|
+
* Returns `{ valid: false, error }` with a human-readable message on
|
|
140
|
+
* rejection. The message is intentionally plain English so the caller can
|
|
141
|
+
* either display it verbatim or pipe it through `tr()` with an i18n key.
|
|
142
|
+
*/
|
|
143
|
+
export declare function validatePhoneNumber(phone: string): {
|
|
144
|
+
valid: boolean;
|
|
145
|
+
error?: string;
|
|
146
|
+
};
|
|
@@ -96,7 +96,26 @@ function p(e, t = {}) {
|
|
|
96
96
|
}
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
|
+
function m(e) {
|
|
100
|
+
let t = e.trim();
|
|
101
|
+
if (t.length === 0) return { valid: !0 };
|
|
102
|
+
if (!/^[0-9+\-() ]+$/.test(t)) return {
|
|
103
|
+
valid: !1,
|
|
104
|
+
error: "Phone number can only contain digits, spaces, and the characters + - ( )"
|
|
105
|
+
};
|
|
106
|
+
let n = t.replace(/[^\d]/g, "");
|
|
107
|
+
return n.length < 7 ? {
|
|
108
|
+
valid: !1,
|
|
109
|
+
error: "Phone number is too short"
|
|
110
|
+
} : n.length > 15 ? {
|
|
111
|
+
valid: !1,
|
|
112
|
+
error: "Phone number is too long"
|
|
113
|
+
} : /^[+\d(]/.test(t) ? { valid: !0 } : {
|
|
114
|
+
valid: !1,
|
|
115
|
+
error: "Phone number must start with +, a digit, or ("
|
|
116
|
+
};
|
|
117
|
+
}
|
|
99
118
|
//#endregion
|
|
100
|
-
export { e as validateEmail, t as validatePassword, p as validateUserFields };
|
|
119
|
+
export { e as validateEmail, t as validatePassword, m as validatePhoneNumber, p as validateUserFields };
|
|
101
120
|
|
|
102
121
|
//# sourceMappingURL=validation.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validation.js","names":[],"sources":["../../../src/auth/utils/validation.ts"],"sourcesContent":["import type { PasswordStrength } from \"../types\";\n\n/**\n * Validate email format\n */\nexport function validateEmail(email: string): boolean {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n return emailRegex.test(email);\n}\n\n/**\n * Validate password requirements\n */\nexport function validatePassword(password: string): {\n valid: boolean;\n errors: string[];\n} {\n const errors: string[] = [];\n\n if (password.length < 12) {\n errors.push(\"Password must be at least 12 characters long\");\n }\n\n if (!/[A-Z]/.test(password)) {\n errors.push(\"Password must contain at least one uppercase letter\");\n }\n\n if (!/[a-z]/.test(password)) {\n errors.push(\"Password must contain at least one lowercase letter\");\n }\n\n if (!/[0-9]/.test(password)) {\n errors.push(\"Password must contain at least one number\");\n }\n\n if (!/[!@#$%^&*(),.?\":{}|<>]/.test(password)) {\n errors.push(\"Password must contain at least one special character\");\n }\n\n return {\n valid: errors.length === 0,\n errors,\n };\n}\n\n/**\n * Calculate password strength using Zxcvbn-like algorithm\n * Returns a score from 0-4 (0 = very weak, 4 = very strong)\n */\nexport function calculatePasswordStrength(password: string): PasswordStrength {\n let score: 0 | 1 | 2 | 3 | 4 = 0;\n const feedback: string[] = [];\n\n // Length check\n if (password.length >= 8) score++;\n if (password.length >= 12) score++;\n if (password.length >= 16) score++;\n\n // Complexity checks\n if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;\n if (/[0-9]/.test(password)) score++;\n if (/[!@#$%^&*(),.?\":{}|<>]/.test(password)) score++;\n\n // Common patterns\n if (/^[a-z]+$/.test(password.toLowerCase())) {\n feedback.push(\"Add uppercase letters, numbers, and symbols\");\n score = Math.max(0, score - 1) as 0 | 1 | 2 | 3 | 4;\n }\n\n if (/^[0-9]+$/.test(password)) {\n feedback.push(\"Avoid using only numbers\");\n score = 0;\n }\n\n if (/(.)\\1{2,}/.test(password)) {\n feedback.push(\"Avoid repeating characters\");\n score = Math.max(0, score - 1) as 0 | 1 | 2 | 3 | 4;\n }\n\n // Common passwords\n const commonPasswords = [\"password\", \"123456\", \"qwerty\", \"admin\", \"letmein\"];\n if (commonPasswords.some((p) => password.toLowerCase().includes(p))) {\n feedback.push(\"Avoid using common passwords\");\n score = 0;\n }\n\n // Ensure score is 0-4\n score = Math.min(4, Math.max(0, score)) as 0 | 1 | 2 | 3 | 4;\n\n // Crack time estimation\n const crackTime =\n score === 0\n ? \"less than 1 second\"\n : score === 1\n ? \"less than 1 minute\"\n : score === 2\n ? \"less than 1 hour\"\n : score === 3\n ? \"less than 1 day\"\n : \"centuries\";\n\n return { score, feedback, crackTime };\n}\n\n/**\n * Validate MFA code format (6 digits)\n */\nexport function validateMfaCode(code: string): boolean {\n return /^\\d{6}$/.test(code);\n}\n\n/**\n * Validate API key name\n */\nexport function validateApiKeyName(name: string): {\n valid: boolean;\n error?: string;\n} {\n if (!name || name.trim().length === 0) {\n return { valid: false, error: \"API key name is required\" };\n }\n\n if (name.length > 100) {\n return {\n valid: false,\n error: \"API key name must be less than 100 characters\",\n };\n }\n\n return { valid: true };\n}\n\n/**\n * Minimum/maximum lengths for a person's name (first or last), measured in\n * Unicode code points (NOT JavaScript `.length`, which counts UTF-16 code\n * units — see `lengthInCodePoints`).\n *\n * Picked to stay well under the 255-char Prisma `User.firstName/lastName`\n * columns with headroom.\n */\nconst NAME_MIN_LENGTH = 1;\nconst NAME_MAX_LENGTH = 100;\n\n/**\n * Count Unicode code points in `s` instead of UTF-16 code units. Names\n * written in scripts that live in the supplementary plane (Adlam 𞤁𞤔𞤣,\n * many CJK extensions, etc.) have each letter encoded as two UTF-16 units,\n * so a 51-letter Adlam name would otherwise be miscounted as 102 and\n * rejected as too long (Codex P2 review).\n *\n * Code-point counting is sufficient for the name validator — grapheme-\n * cluster counting would also handle emoji ZWJ sequences, but the name\n * regex already disallows emoji.\n */\nfunction lengthInCodePoints(s: string): number {\n return [...s].length;\n}\n\n/**\n * Regex for an acceptable name. Must contain at least one letter (so \"12345\",\n * \"12 34\", \"---\" all fail). Every character must be a letter, mark, literal\n * space, hyphen, apostrophe, period, middle-dot, or join control —\n * diacritics-friendly across scripts (Latin, Tamil, Chinese, etc.).\n *\n * Note: a literal space, not `\\s` — `\\s` matches tabs/newlines/other Unicode\n * whitespace which has no business being inside a person's name (review #2).\n *\n * Two middle-dot codepoints are allowed as-is, NOT folded to `.` — they're\n * orthographically distinct and silently rewriting the user's name would\n * corrupt their identity (Codex P2 reviews):\n * U+00B7 — middle dot, used in Catalan (`Joan·Maria`), Chinese\n * (`阿布杜拉·阿扎姆`), and Russian.\n * U+30FB — katakana middle dot, the standard Japanese separator for\n * foreign personal names (`ジョン・スミス`).\n *\n * Zero-width join controls are also allowed as-is:\n * U+200C ZERO WIDTH NON-JOINER — required for Persian compound names\n * like `محمدرضا` so the letters don't ligate.\n * U+200D ZERO WIDTH JOINER — used in some Indic scripts and emoji\n * sequences (we don't allow emoji ZWJ, but the codepoint is\n * accepted as a generic join marker).\n *\n * Typographic apostrophes (U+2018 / U+2019) and modifier letter apostrophe\n * (U+02BC), and typographic hyphens (U+2010 / U+2011), ARE folded to their\n * ASCII equivalents before validation — they're typographic variants of the\n * same character, not semantically distinct (Codex P2 review).\n *\n * BOFF-7214.\n */\nconst NAME_ALLOWED_CHARS =\n /^[\\p{L}\\p{M} '.\\-\\u00B7\\u30FB\\u200C\\u200D]*\\p{L}[\\p{L}\\p{M} '.\\-\\u00B7\\u30FB\\u200C\\u200D]*$/u;\nconst TYPOGRAPHIC_APOSTROPHES = /[\\u2018\\u2019\\u02BC]/g;\nconst TYPOGRAPHIC_HYPHENS = /[\\u2010\\u2011]/g;\n\n/**\n * Machine-readable name-validation error codes. The caller maps each code\n * to a distinct i18n key (review #1) so that one validator error doesn't\n * collapse N different messages into one translated string.\n */\nexport type NameErrorCode =\n | \"required\"\n | \"too_long\"\n | \"no_letter\"\n | \"invalid_chars\";\n\nexport type NameValidationResult =\n | { valid: true; value: string }\n | { valid: false; code: NameErrorCode; message: string };\n\n/**\n * Validate a person's name (first or last).\n * Accepts `string | undefined | null` because the signup form types name\n * fields as optional (matching the `SignUpInput` contract); an absent value\n * is treated as \"missing\" and fails the validation.\n * On success the result carries the canonicalised `value` (trimmed, with\n * typographic apostrophes folded to ASCII) so the caller can submit the\n * exact same string the validator approved — otherwise the validated\n * string and the value sent to `signUp()` would diverge (e.g. user types\n * `\" O'Connor \"`, validator normalises to `\"O'Connor\"`, but signUp\n * receives the original raw value).\n * Returns a structured result so the caller can decide how to render errors.\n */\nexport function validateName(\n name: string | undefined | null,\n): NameValidationResult {\n // Normalise typographic apostrophes and hyphens to their ASCII\n // equivalents before testing — these are typographic variants of the\n // same character, so the canonical ASCII form is what reaches the DB.\n // (Middle-dot U+00B7 is preserved as-is: see NAME_ALLOWED_CHARS comment.)\n // NFC normalisation happens before trim/length so combining-mark\n // sequences (e.g. `e\\u0301` for `é`) fold to their precomposed\n // form first — otherwise `(\"e\\u0301\").repeat(51)` would over-count\n // to 102 code points and reject a valid 51-character name (Codex P2).\n const trimmed = (name ?? \"\")\n .normalize(\"NFC\")\n .replace(TYPOGRAPHIC_APOSTROPHES, \"'\")\n .replace(TYPOGRAPHIC_HYPHENS, \"-\")\n .trim();\n\n if (lengthInCodePoints(trimmed) < NAME_MIN_LENGTH) {\n return { valid: false, code: \"required\", message: \"Name is required\" };\n }\n\n if (lengthInCodePoints(trimmed) > NAME_MAX_LENGTH) {\n return {\n valid: false,\n code: \"too_long\",\n message: `Name must be ${NAME_MAX_LENGTH} characters or fewer`,\n };\n }\n\n if (!NAME_ALLOWED_CHARS.test(trimmed)) {\n // The combined regex distinguishes the two failure modes that matter\n // to the user: a value with no letters vs. a value with disallowed\n // characters. Surface the right hint for each.\n if (!/\\p{L}/u.test(trimmed)) {\n return {\n valid: false,\n code: \"no_letter\",\n message: \"Name must contain at least one letter\",\n };\n }\n return {\n valid: false,\n code: \"invalid_chars\",\n message:\n \"Name can only contain letters, spaces, periods, hyphens, and apostrophes\",\n };\n }\n\n return { valid: true, value: trimmed };\n}\n\n/**\n * Regex for characters allowed in a username. Letters, digits, underscores,\n * hyphens — the existing rule. The new rule layered on top (BOFF-7214):\n * a username must contain at least one letter, so an attacker cannot create\n * an account like \"12345\" with a throwaway email.\n */\nconst USERNAME_ALLOWED_CHARS = /^[a-zA-Z0-9_-]+$/;\nconst USERNAME_MIN_LENGTH = 3;\nconst USERNAME_MAX_LENGTH = 30;\n/**\n * Machine-readable username-validation error codes — one per failing rule\n * (review #1) so each can map to its own i18n key.\n */\nexport type UsernameErrorCode =\n | \"required\"\n | \"too_short\"\n | \"too_long\"\n | \"invalid_chars\"\n | \"no_letter\";\n\nexport type UsernameValidationResult =\n | { valid: true; value: string }\n | {\n valid: false;\n errors: Array<{ code: UsernameErrorCode; message: string }>;\n };\n\n/**\n * Validate a username.\n * Accepts `string | undefined | null` for parity with `validateName` and the\n * optional `SignUpInput.username` contract.\n *\n * Surrounding whitespace is trimmed before the length / character checks —\n * a pasted `\" alice_01 \"` would otherwise fail with a misleading\n * \"can only contain letters, numbers, underscores, and hyphens\" error\n * when the actual problem is invisible whitespace. On success the result\n * carries the trimmed `value` so the caller submits the same string the\n * validator approved (Claude LOW review).\n * Returns a structured result with a list of errors so the caller can\n * render every failing rule at once (mirrors `validatePassword`).\n */\nexport function validateUsername(\n username: string | undefined | null,\n): UsernameValidationResult {\n const errors: Array<{ code: UsernameErrorCode; message: string }> = [];\n const value = (username ?? \"\").trim();\n\n if (lengthInCodePoints(value) === 0) {\n errors.push({\n code: \"required\",\n message: \"Username is required\",\n });\n } else {\n if (lengthInCodePoints(value) < USERNAME_MIN_LENGTH) {\n errors.push({\n code: \"too_short\",\n message: `Username must be at least ${USERNAME_MIN_LENGTH} characters`,\n });\n }\n\n if (lengthInCodePoints(value) > USERNAME_MAX_LENGTH) {\n errors.push({\n code: \"too_long\",\n message: `Username must be ${USERNAME_MAX_LENGTH} characters or fewer`,\n });\n }\n\n if (!USERNAME_ALLOWED_CHARS.test(value)) {\n errors.push({\n code: \"invalid_chars\",\n message:\n \"Username can only contain letters, numbers, underscores, and hyphens\",\n });\n }\n\n // Must contain at least one letter — guards against all-numeric or\n // all-symbol usernames slipping through. Pure numeric values like\n // \"12345\" previously passed the character allow-list alone.\n if (\n lengthInCodePoints(value) >= USERNAME_MIN_LENGTH &&\n !/[a-zA-Z]/.test(value)\n ) {\n errors.push({\n code: \"no_letter\",\n message: \"Username must contain at least one letter\",\n });\n }\n }\n\n return errors.length === 0\n ? { valid: true, value }\n : { valid: false, errors };\n}\n\n/**\n * Bundle validation across all three user-identity fields so callers\n * (RegisterPage, UserProfilePage, UsersPage) can fail fast on the whole\n * form instead of piecemeal — and so the BOFF-7214 invariant\n * (firstName / lastName contain a letter; username contains a letter)\n * is enforced at every mutation surface, not just signup.\n *\n * On success, the result carries the canonicalised values from each\n * individual validator so the caller submits the same strings the\n * validators approved (no validate/submit divergence).\n *\n * The caller is responsible for mapping each `{ field, code, message }`\n * error to its own i18n key — different surfaces have different key\n * namespaces (`auth.register.*`, `auth.profile.*`, `auth.users.*`).\n */\nexport type UserFieldKey = \"firstName\" | \"lastName\" | \"username\";\n\nexport interface UserFieldError {\n field: UserFieldKey;\n code: string;\n message: string;\n}\n\nexport interface ValidatedUserFields {\n firstName: string;\n lastName: string;\n username: string;\n}\n\nexport type UserFieldsValidationResult =\n | { valid: true; values: ValidatedUserFields }\n | { valid: false; errors: UserFieldError[] };\n\nexport function validateUserFields(\n input: {\n firstName?: string | null;\n lastName?: string | null;\n username?: string | null;\n },\n options: { requireAll?: boolean } = {},\n): UserFieldsValidationResult {\n // Default to requiring every field. Profile-edit surfaces pass\n // `requireAll: false` so an existing user whose optional firstName /\n // lastName is unset (e.g. provisioned via an identity provider) can\n // still update unrelated fields like phone or role (Codex P2 review).\n const requireAll = options.requireAll ?? true;\n const errors: UserFieldError[] = [];\n\n // Skip the validator entirely when a field is empty AND requireAll is\n // false — that means \"user is leaving this field blank\". The signup\n // form always requires every field; profile edit does not.\n const isBlank = (v: string | null | undefined): boolean =>\n !v || v.trim().length === 0;\n\n const firstName =\n !isBlank(input.firstName) || requireAll\n ? validateName(input.firstName)\n : null;\n if (firstName && !firstName.valid) {\n errors.push({\n field: \"firstName\",\n code: firstName.code,\n message: firstName.message,\n });\n }\n\n const lastName =\n !isBlank(input.lastName) || requireAll\n ? validateName(input.lastName)\n : null;\n if (lastName && !lastName.valid) {\n errors.push({\n field: \"lastName\",\n code: lastName.code,\n message: lastName.message,\n });\n }\n\n const username =\n !isBlank(input.username) || requireAll\n ? validateUsername(input.username)\n : null;\n if (username && !username.valid) {\n for (const e of username.errors) {\n errors.push({ field: \"username\", code: e.code, message: e.message });\n }\n }\n\n if (errors.length > 0) return { valid: false, errors };\n\n // Return canonical values: trimmed/NFC-normalised when validated, the\n // original trimmed value when the field was optional and left blank.\n // The cast is sound because the `errors.length > 0` early-return above\n // guarantees each result, when non-null, is the `{ valid: true }` arm.\n const valueOr = (\n result: { valid: true; value: string } | null,\n fallback: string | null | undefined,\n ): string => (result ? result.value : (fallback ?? \"\").trim());\n\n return {\n valid: true,\n values: {\n firstName: valueOr(\n firstName as { valid: true; value: string } | null,\n input.firstName,\n ),\n lastName: valueOr(\n lastName as { valid: true; value: string } | null,\n input.lastName,\n ),\n username: valueOr(\n username as { valid: true; value: string } | null,\n input.username,\n ),\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"validation.js","names":[],"sources":["../../../src/auth/utils/validation.ts"],"sourcesContent":["import type { PasswordStrength } from \"../types\";\n\n/**\n * Validate email format\n */\nexport function validateEmail(email: string): boolean {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n return emailRegex.test(email);\n}\n\n/**\n * Validate password requirements\n */\nexport function validatePassword(password: string): {\n valid: boolean;\n errors: string[];\n} {\n const errors: string[] = [];\n\n if (password.length < 12) {\n errors.push(\"Password must be at least 12 characters long\");\n }\n\n if (!/[A-Z]/.test(password)) {\n errors.push(\"Password must contain at least one uppercase letter\");\n }\n\n if (!/[a-z]/.test(password)) {\n errors.push(\"Password must contain at least one lowercase letter\");\n }\n\n if (!/[0-9]/.test(password)) {\n errors.push(\"Password must contain at least one number\");\n }\n\n if (!/[!@#$%^&*(),.?\":{}|<>]/.test(password)) {\n errors.push(\"Password must contain at least one special character\");\n }\n\n return {\n valid: errors.length === 0,\n errors,\n };\n}\n\n/**\n * Calculate password strength using Zxcvbn-like algorithm\n * Returns a score from 0-4 (0 = very weak, 4 = very strong)\n */\nexport function calculatePasswordStrength(password: string): PasswordStrength {\n let score: 0 | 1 | 2 | 3 | 4 = 0;\n const feedback: string[] = [];\n\n // Length check\n if (password.length >= 8) score++;\n if (password.length >= 12) score++;\n if (password.length >= 16) score++;\n\n // Complexity checks\n if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;\n if (/[0-9]/.test(password)) score++;\n if (/[!@#$%^&*(),.?\":{}|<>]/.test(password)) score++;\n\n // Common patterns\n if (/^[a-z]+$/.test(password.toLowerCase())) {\n feedback.push(\"Add uppercase letters, numbers, and symbols\");\n score = Math.max(0, score - 1) as 0 | 1 | 2 | 3 | 4;\n }\n\n if (/^[0-9]+$/.test(password)) {\n feedback.push(\"Avoid using only numbers\");\n score = 0;\n }\n\n if (/(.)\\1{2,}/.test(password)) {\n feedback.push(\"Avoid repeating characters\");\n score = Math.max(0, score - 1) as 0 | 1 | 2 | 3 | 4;\n }\n\n // Common passwords\n const commonPasswords = [\"password\", \"123456\", \"qwerty\", \"admin\", \"letmein\"];\n if (commonPasswords.some((p) => password.toLowerCase().includes(p))) {\n feedback.push(\"Avoid using common passwords\");\n score = 0;\n }\n\n // Ensure score is 0-4\n score = Math.min(4, Math.max(0, score)) as 0 | 1 | 2 | 3 | 4;\n\n // Crack time estimation\n const crackTime =\n score === 0\n ? \"less than 1 second\"\n : score === 1\n ? \"less than 1 minute\"\n : score === 2\n ? \"less than 1 hour\"\n : score === 3\n ? \"less than 1 day\"\n : \"centuries\";\n\n return { score, feedback, crackTime };\n}\n\n/**\n * Validate MFA code format (6 digits)\n */\nexport function validateMfaCode(code: string): boolean {\n return /^\\d{6}$/.test(code);\n}\n\n/**\n * Validate API key name\n */\nexport function validateApiKeyName(name: string): {\n valid: boolean;\n error?: string;\n} {\n if (!name || name.trim().length === 0) {\n return { valid: false, error: \"API key name is required\" };\n }\n\n if (name.length > 100) {\n return {\n valid: false,\n error: \"API key name must be less than 100 characters\",\n };\n }\n\n return { valid: true };\n}\n/**\n * Minimum/maximum lengths for a person's name (first or last), measured in\n * Unicode code points (NOT JavaScript `.length`, which counts UTF-16 code\n * units — see `lengthInCodePoints`).\n *\n * Picked to stay well under the 255-char Prisma `User.firstName/lastName`\n * columns with headroom.\n */\nconst NAME_MIN_LENGTH = 1;\nconst NAME_MAX_LENGTH = 100;\n\n/**\n * Count Unicode code points in `s` instead of UTF-16 code units. Names\n * written in scripts that live in the supplementary plane (Adlam 𞤁𞤔𞤣,\n * many CJK extensions, etc.) have each letter encoded as two UTF-16 units,\n * so a 51-letter Adlam name would otherwise be miscounted as 102 and\n * rejected as too long (Codex P2 review).\n *\n * Code-point counting is sufficient for the name validator — grapheme-\n * cluster counting would also handle emoji ZWJ sequences, but the name\n * regex already disallows emoji.\n */\nfunction lengthInCodePoints(s: string): number {\n return [...s].length;\n}\n\n/**\n * Regex for an acceptable name. Must contain at least one letter (so \"12345\",\n * \"12 34\", \"---\" all fail). Every character must be a letter, mark, literal\n * space, hyphen, apostrophe, period, middle-dot, or join control —\n * diacritics-friendly across scripts (Latin, Tamil, Chinese, etc.).\n *\n * Note: a literal space, not `\\s` — `\\s` matches tabs/newlines/other Unicode\n * whitespace which has no business being inside a person's name (review #2).\n *\n * Two middle-dot codepoints are allowed as-is, NOT folded to `.` — they're\n * orthographically distinct and silently rewriting the user's name would\n * corrupt their identity (Codex P2 reviews):\n * U+00B7 — middle dot, used in Catalan (`Joan·Maria`), Chinese\n * (`阿布杜拉·阿扎姆`), and Russian.\n * U+30FB — katakana middle dot, the standard Japanese separator for\n * foreign personal names (`ジョン・スミス`).\n *\n * Zero-width join controls are also allowed as-is:\n * U+200C ZERO WIDTH NON-JOINER — required for Persian compound names\n * like `محمدرضا` so the letters don't ligate.\n * U+200D ZERO WIDTH JOINER — used in some Indic scripts and emoji\n * sequences (we don't allow emoji ZWJ, but the codepoint is\n * accepted as a generic join marker).\n *\n * Typographic apostrophes (U+2018 / U+2019) and modifier letter apostrophe\n * (U+02BC), and typographic hyphens (U+2010 / U+2011), ARE folded to their\n * ASCII equivalents before validation — they're typographic variants of the\n * same character, not semantically distinct (Codex P2 review).\n *\n * BOFF-7214.\n */\nconst NAME_ALLOWED_CHARS =\n /^[\\p{L}\\p{M} '.\\-\\u00B7\\u30FB\\u200C\\u200D]*\\p{L}[\\p{L}\\p{M} '.\\-\\u00B7\\u30FB\\u200C\\u200D]*$/u;\nconst TYPOGRAPHIC_APOSTROPHES = /[\\u2018\\u2019\\u02BC]/g;\nconst TYPOGRAPHIC_HYPHENS = /[\\u2010\\u2011]/g;\n\n/**\n * Machine-readable name-validation error codes. The caller maps each code\n * to a distinct i18n key (review #1) so that one validator error doesn't\n * collapse N different messages into one translated string.\n */\nexport type NameErrorCode =\n | \"required\"\n | \"too_long\"\n | \"no_letter\"\n | \"invalid_chars\";\n\nexport type NameValidationResult =\n | { valid: true; value: string }\n | { valid: false; code: NameErrorCode; message: string };\n\n/**\n * Validate a person's name (first or last).\n * Accepts `string | undefined | null` because the signup form types name\n * fields as optional (matching the `SignUpInput` contract); an absent value\n * is treated as \"missing\" and fails the validation.\n * On success the result carries the canonicalised `value` (trimmed, with\n * typographic apostrophes folded to ASCII) so the caller can submit the\n * exact same string the validator approved — otherwise the validated\n * string and the value sent to `signUp()` would diverge (e.g. user types\n * `\" O'Connor \"`, validator normalises to `\"O'Connor\"`, but signUp\n * receives the original raw value).\n * Returns a structured result so the caller can decide how to render errors.\n */\nexport function validateName(\n name: string | undefined | null,\n): NameValidationResult {\n // Normalise typographic apostrophes and hyphens to their ASCII\n // equivalents before testing — these are typographic variants of the\n // same character, so the canonical ASCII form is what reaches the DB.\n // (Middle-dot U+00B7 is preserved as-is: see NAME_ALLOWED_CHARS comment.)\n // NFC normalisation happens before trim/length so combining-mark\n // sequences (e.g. `e\\u0301` for `é`) fold to their precomposed\n // form first — otherwise `(\"e\\u0301\").repeat(51)` would over-count\n // to 102 code points and reject a valid 51-character name (Codex P2).\n const trimmed = (name ?? \"\")\n .normalize(\"NFC\")\n .replace(TYPOGRAPHIC_APOSTROPHES, \"'\")\n .replace(TYPOGRAPHIC_HYPHENS, \"-\")\n .trim();\n\n if (lengthInCodePoints(trimmed) < NAME_MIN_LENGTH) {\n return { valid: false, code: \"required\", message: \"Name is required\" };\n }\n\n if (lengthInCodePoints(trimmed) > NAME_MAX_LENGTH) {\n return {\n valid: false,\n code: \"too_long\",\n message: `Name must be ${NAME_MAX_LENGTH} characters or fewer`,\n };\n }\n\n if (!NAME_ALLOWED_CHARS.test(trimmed)) {\n // The combined regex distinguishes the two failure modes that matter\n // to the user: a value with no letters vs. a value with disallowed\n // characters. Surface the right hint for each.\n if (!/\\p{L}/u.test(trimmed)) {\n return {\n valid: false,\n code: \"no_letter\",\n message: \"Name must contain at least one letter\",\n };\n }\n return {\n valid: false,\n code: \"invalid_chars\",\n message:\n \"Name can only contain letters, spaces, periods, hyphens, and apostrophes\",\n };\n }\n\n return { valid: true, value: trimmed };\n}\n\n/**\n * Regex for characters allowed in a username. Letters, digits, underscores,\n * hyphens — the existing rule. The new rule layered on top (BOFF-7214):\n * a username must contain at least one letter, so an attacker cannot create\n * an account like \"12345\" with a throwaway email.\n */\nconst USERNAME_ALLOWED_CHARS = /^[a-zA-Z0-9_-]+$/;\nconst USERNAME_MIN_LENGTH = 3;\nconst USERNAME_MAX_LENGTH = 30;\n/**\n * Machine-readable username-validation error codes — one per failing rule\n * (review #1) so each can map to its own i18n key.\n */\nexport type UsernameErrorCode =\n | \"required\"\n | \"too_short\"\n | \"too_long\"\n | \"invalid_chars\"\n | \"no_letter\";\n\nexport type UsernameValidationResult =\n | { valid: true; value: string }\n | {\n valid: false;\n errors: Array<{ code: UsernameErrorCode; message: string }>;\n };\n\n/**\n * Validate a username.\n * Accepts `string | undefined | null` for parity with `validateName` and the\n * optional `SignUpInput.username` contract.\n *\n * Surrounding whitespace is trimmed before the length / character checks —\n * a pasted `\" alice_01 \"` would otherwise fail with a misleading\n * \"can only contain letters, numbers, underscores, and hyphens\" error\n * when the actual problem is invisible whitespace. On success the result\n * carries the trimmed `value` so the caller submits the same string the\n * validator approved (Claude LOW review).\n * Returns a structured result with a list of errors so the caller can\n * render every failing rule at once (mirrors `validatePassword`).\n */\nexport function validateUsername(\n username: string | undefined | null,\n): UsernameValidationResult {\n const errors: Array<{ code: UsernameErrorCode; message: string }> = [];\n const value = (username ?? \"\").trim();\n\n if (lengthInCodePoints(value) === 0) {\n errors.push({\n code: \"required\",\n message: \"Username is required\",\n });\n } else {\n if (lengthInCodePoints(value) < USERNAME_MIN_LENGTH) {\n errors.push({\n code: \"too_short\",\n message: `Username must be at least ${USERNAME_MIN_LENGTH} characters`,\n });\n }\n\n if (lengthInCodePoints(value) > USERNAME_MAX_LENGTH) {\n errors.push({\n code: \"too_long\",\n message: `Username must be ${USERNAME_MAX_LENGTH} characters or fewer`,\n });\n }\n\n if (!USERNAME_ALLOWED_CHARS.test(value)) {\n errors.push({\n code: \"invalid_chars\",\n message:\n \"Username can only contain letters, numbers, underscores, and hyphens\",\n });\n }\n\n // Must contain at least one letter — guards against all-numeric or\n // all-symbol usernames slipping through. Pure numeric values like\n // \"12345\" previously passed the character allow-list alone.\n if (\n lengthInCodePoints(value) >= USERNAME_MIN_LENGTH &&\n !/[a-zA-Z]/.test(value)\n ) {\n errors.push({\n code: \"no_letter\",\n message: \"Username must contain at least one letter\",\n });\n }\n }\n\n return errors.length === 0\n ? { valid: true, value }\n : { valid: false, errors };\n}\n\n/**\n * Bundle validation across all three user-identity fields so callers\n * (RegisterPage, UserProfilePage, UsersPage) can fail fast on the whole\n * form instead of piecemeal — and so the BOFF-7214 invariant\n * (firstName / lastName contain a letter; username contains a letter)\n * is enforced at every mutation surface, not just signup.\n *\n * On success, the result carries the canonicalised values from each\n * individual validator so the caller submits the same strings the\n * validators approved (no validate/submit divergence).\n *\n * The caller is responsible for mapping each `{ field, code, message }`\n * error to its own i18n key — different surfaces have different key\n * namespaces (`auth.register.*`, `auth.profile.*`, `auth.users.*`).\n */\nexport type UserFieldKey = \"firstName\" | \"lastName\" | \"username\";\n\nexport interface UserFieldError {\n field: UserFieldKey;\n code: string;\n message: string;\n}\n\nexport interface ValidatedUserFields {\n firstName: string;\n lastName: string;\n username: string;\n}\n\nexport type UserFieldsValidationResult =\n | { valid: true; values: ValidatedUserFields }\n | { valid: false; errors: UserFieldError[] };\n\nexport function validateUserFields(\n input: {\n firstName?: string | null;\n lastName?: string | null;\n username?: string | null;\n },\n options: { requireAll?: boolean } = {},\n): UserFieldsValidationResult {\n // Default to requiring every field. Profile-edit surfaces pass\n // `requireAll: false` so an existing user whose optional firstName /\n // lastName is unset (e.g. provisioned via an identity provider) can\n // still update unrelated fields like phone or role (Codex P2 review).\n const requireAll = options.requireAll ?? true;\n const errors: UserFieldError[] = [];\n\n // Skip the validator entirely when a field is empty AND requireAll is\n // false — that means \"user is leaving this field blank\". The signup\n // form always requires every field; profile edit does not.\n const isBlank = (v: string | null | undefined): boolean =>\n !v || v.trim().length === 0;\n\n const firstName =\n !isBlank(input.firstName) || requireAll\n ? validateName(input.firstName)\n : null;\n if (firstName && !firstName.valid) {\n errors.push({\n field: \"firstName\",\n code: firstName.code,\n message: firstName.message,\n });\n }\n\n const lastName =\n !isBlank(input.lastName) || requireAll\n ? validateName(input.lastName)\n : null;\n if (lastName && !lastName.valid) {\n errors.push({\n field: \"lastName\",\n code: lastName.code,\n message: lastName.message,\n });\n }\n\n const username =\n !isBlank(input.username) || requireAll\n ? validateUsername(input.username)\n : null;\n if (username && !username.valid) {\n for (const e of username.errors) {\n errors.push({ field: \"username\", code: e.code, message: e.message });\n }\n }\n\n if (errors.length > 0) return { valid: false, errors };\n\n // Return canonical values: trimmed/NFC-normalised when validated, the\n // original trimmed value when the field was optional and left blank.\n // The cast is sound because the `errors.length > 0` early-return above\n // guarantees each result, when non-null, is the `{ valid: true }` arm.\n const valueOr = (\n result: { valid: true; value: string } | null,\n fallback: string | null | undefined,\n ): string => (result ? result.value : (fallback ?? \"\").trim());\n\n return {\n valid: true,\n values: {\n firstName: valueOr(\n firstName as { valid: true; value: string } | null,\n input.firstName,\n ),\n lastName: valueOr(\n lastName as { valid: true; value: string } | null,\n input.lastName,\n ),\n username: valueOr(\n username as { valid: true; value: string } | null,\n input.username,\n ),\n },\n };\n}\n\n/**\n * Validate a phone number (BOFF-7216).\n *\n * Phone numbers are optional on profile / admin edit forms — an empty input\n * is accepted. When a value is supplied it must:\n * - contain only digits, spaces, and the formatting characters + - ( )\n * - include 7–15 digits (E.164-style length bounds; we don't strip \"+\"\n * country prefix here, we just count digits)\n * - start with +, a digit, or (\n *\n * Returns `{ valid: true }` on an empty / whitespace-only input.\n * Returns `{ valid: false, error }` with a human-readable message on\n * rejection. The message is intentionally plain English so the caller can\n * either display it verbatim or pipe it through `tr()` with an i18n key.\n */\nexport function validatePhoneNumber(phone: string): {\n valid: boolean;\n error?: string;\n} {\n const trimmed = phone.trim();\n\n if (trimmed.length === 0) {\n return { valid: true };\n }\n\n if (!/^[0-9+\\-() ]+$/.test(trimmed)) {\n return {\n valid: false,\n error:\n \"Phone number can only contain digits, spaces, and the characters + - ( )\",\n };\n }\n\n const digits = trimmed.replace(/[^\\d]/g, \"\");\n if (digits.length < 7) {\n return { valid: false, error: \"Phone number is too short\" };\n }\n\n if (digits.length > 15) {\n return { valid: false, error: \"Phone number is too long\" };\n }\n\n if (!/^[+\\d(]/.test(trimmed)) {\n return {\n valid: false,\n error: \"Phone number must start with +, a digit, or (\",\n };\n }\n\n return { valid: true };\n}\n"],"mappings":"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"}
|