@better-forge/ui 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +13 -3
- package/dist/api/client.d.ts +42 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +130 -0
- package/dist/api/client.js.map +1 -0
- package/dist/components/ConfirmIdentityModal.d.ts +18 -0
- package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
- package/dist/components/ConfirmIdentityModal.js +30 -0
- package/dist/components/ConfirmIdentityModal.js.map +1 -0
- package/dist/components/ConfirmModal.d.ts +18 -0
- package/dist/components/ConfirmModal.d.ts.map +1 -0
- package/dist/components/ConfirmModal.js +11 -0
- package/dist/components/ConfirmModal.js.map +1 -0
- package/dist/components/CopyButton.d.ts +11 -0
- package/dist/components/CopyButton.d.ts.map +1 -0
- package/dist/components/CopyButton.js +44 -0
- package/dist/components/CopyButton.js.map +1 -0
- package/dist/components/CredentialSection.d.ts +38 -0
- package/dist/components/CredentialSection.d.ts.map +1 -0
- package/dist/components/CredentialSection.js +69 -0
- package/dist/components/CredentialSection.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +18 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +25 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/ModalShell.d.ts +58 -0
- package/dist/components/ModalShell.d.ts.map +1 -0
- package/dist/components/ModalShell.js +92 -0
- package/dist/components/ModalShell.js.map +1 -0
- package/dist/components/ResetTwoFactorModal.d.ts +14 -0
- package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
- package/dist/components/ResetTwoFactorModal.js +16 -0
- package/dist/components/ResetTwoFactorModal.js.map +1 -0
- package/dist/components/SidePanel.d.ts +35 -0
- package/dist/components/SidePanel.d.ts.map +1 -0
- package/dist/components/SidePanel.js +19 -0
- package/dist/components/SidePanel.js.map +1 -0
- package/dist/components/SingleSignOnModal.d.ts +4 -0
- package/dist/components/SingleSignOnModal.d.ts.map +1 -0
- package/dist/components/SingleSignOnModal.js +64 -0
- package/dist/components/SingleSignOnModal.js.map +1 -0
- package/dist/components/SsoConfirmation.d.ts +22 -0
- package/dist/components/SsoConfirmation.d.ts.map +1 -0
- package/dist/components/SsoConfirmation.js +87 -0
- package/dist/components/SsoConfirmation.js.map +1 -0
- package/dist/components/VaultTypePicker.d.ts +13 -0
- package/dist/components/VaultTypePicker.d.ts.map +1 -0
- package/dist/components/VaultTypePicker.js +62 -0
- package/dist/components/VaultTypePicker.js.map +1 -0
- package/dist/components/WhatsNew.d.ts +18 -0
- package/dist/components/WhatsNew.d.ts.map +1 -0
- package/dist/components/WhatsNew.js +50 -0
- package/dist/components/WhatsNew.js.map +1 -0
- package/dist/components/formParts.d.ts +40 -0
- package/dist/components/formParts.d.ts.map +1 -0
- package/dist/components/formParts.js +31 -0
- package/dist/components/formParts.js.map +1 -0
- package/dist/components/ui/Alert.d.ts +15 -0
- package/dist/components/ui/Alert.d.ts.map +1 -0
- package/dist/components/ui/Alert.js +14 -0
- package/dist/components/ui/Alert.js.map +1 -0
- package/dist/components/ui/DataTable.d.ts +52 -0
- package/dist/components/ui/DataTable.d.ts.map +1 -0
- package/dist/components/ui/DataTable.js +112 -0
- package/dist/components/ui/DataTable.js.map +1 -0
- package/dist/components/ui/Field.d.ts +27 -0
- package/dist/components/ui/Field.d.ts.map +1 -0
- package/dist/components/ui/Field.js +25 -0
- package/dist/components/ui/Field.js.map +1 -0
- package/dist/components/ui/PageHeader.d.ts +18 -0
- package/dist/components/ui/PageHeader.d.ts.map +1 -0
- package/dist/components/ui/PageHeader.js +10 -0
- package/dist/components/ui/PageHeader.js.map +1 -0
- package/dist/components/ui/Pagination.d.ts +21 -0
- package/dist/components/ui/Pagination.d.ts.map +1 -0
- package/dist/components/ui/Pagination.js +10 -0
- package/dist/components/ui/Pagination.js.map +1 -0
- package/dist/components/ui/QuickStartPanel.d.ts +21 -0
- package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
- package/dist/components/ui/QuickStartPanel.js +11 -0
- package/dist/components/ui/QuickStartPanel.js.map +1 -0
- package/dist/components/ui/StateViews.d.ts +50 -0
- package/dist/components/ui/StateViews.d.ts.map +1 -0
- package/dist/components/ui/StateViews.js +33 -0
- package/dist/components/ui/StateViews.js.map +1 -0
- package/dist/components/ui/Switch.d.ts +16 -0
- package/dist/components/ui/Switch.d.ts.map +1 -0
- package/dist/components/ui/Switch.js +15 -0
- package/dist/components/ui/Switch.js.map +1 -0
- package/dist/components/ui/Toast.d.ts +20 -0
- package/dist/components/ui/Toast.d.ts.map +1 -0
- package/dist/components/ui/Toast.js +46 -0
- package/dist/components/ui/Toast.js.map +1 -0
- package/dist/components/ui/index.d.ts +10 -0
- package/dist/components/ui/index.d.ts.map +1 -0
- package/dist/components/ui/index.js +10 -0
- package/dist/components/ui/index.js.map +1 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks/useDarkMode.d.ts +2 -0
- package/dist/hooks/useDarkMode.d.ts.map +1 -0
- package/dist/hooks/useDarkMode.js +37 -0
- package/dist/hooks/useDarkMode.js.map +1 -0
- package/dist/hooks/useDebouncedValue.d.ts +6 -0
- package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
- package/dist/hooks/useDebouncedValue.js +19 -0
- package/dist/hooks/useDebouncedValue.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/changelog.d.ts +32 -0
- package/dist/lib/changelog.d.ts.map +1 -0
- package/dist/lib/changelog.js +71 -0
- package/dist/lib/changelog.js.map +1 -0
- package/dist/lib/dateFormat.d.ts +24 -0
- package/dist/lib/dateFormat.d.ts.map +1 -0
- package/dist/lib/dateFormat.js +67 -0
- package/dist/lib/dateFormat.js.map +1 -0
- package/dist/navigation.d.ts +5 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +7 -0
- package/dist/navigation.js.map +1 -0
- package/dist/pages/ApiTokens.d.ts +9 -0
- package/dist/pages/ApiTokens.d.ts.map +1 -0
- package/dist/pages/ApiTokens.js +116 -0
- package/dist/pages/ApiTokens.js.map +1 -0
- package/dist/pages/AuditLog.d.ts +14 -0
- package/dist/pages/AuditLog.d.ts.map +1 -0
- package/dist/pages/AuditLog.js +104 -0
- package/dist/pages/AuditLog.js.map +1 -0
- package/dist/pages/Backups.d.ts +2 -0
- package/dist/pages/Backups.d.ts.map +1 -0
- package/dist/pages/Backups.js +118 -0
- package/dist/pages/Backups.js.map +1 -0
- package/dist/pages/ChangePassword.d.ts +2 -0
- package/dist/pages/ChangePassword.d.ts.map +1 -0
- package/dist/pages/ChangePassword.js +40 -0
- package/dist/pages/ChangePassword.js.map +1 -0
- package/dist/pages/Login.d.ts +12 -0
- package/dist/pages/Login.d.ts.map +1 -0
- package/dist/pages/Login.js +69 -0
- package/dist/pages/Login.js.map +1 -0
- package/dist/pages/Settings.d.ts +2 -0
- package/dist/pages/Settings.d.ts.map +1 -0
- package/dist/pages/Settings.js +149 -0
- package/dist/pages/Settings.js.map +1 -0
- package/dist/pages/Setup.d.ts +2 -0
- package/dist/pages/Setup.d.ts.map +1 -0
- package/dist/pages/Setup.js +113 -0
- package/dist/pages/Setup.js.map +1 -0
- package/dist/pages/SsoConfirm.d.ts +3 -0
- package/dist/pages/SsoConfirm.d.ts.map +1 -0
- package/dist/pages/SsoConfirm.js +20 -0
- package/dist/pages/SsoConfirm.js.map +1 -0
- package/dist/pages/SsoTestResult.d.ts +2 -0
- package/dist/pages/SsoTestResult.d.ts.map +1 -0
- package/dist/pages/SsoTestResult.js +49 -0
- package/dist/pages/SsoTestResult.js.map +1 -0
- package/dist/pages/Users.d.ts +22 -0
- package/dist/pages/Users.d.ts.map +1 -0
- package/dist/pages/Users.js +124 -0
- package/dist/pages/Users.js.map +1 -0
- package/dist/pages/Vault.d.ts +2 -0
- package/dist/pages/Vault.d.ts.map +1 -0
- package/dist/pages/Vault.js +372 -0
- package/dist/pages/Vault.js.map +1 -0
- package/dist/shell/AdminShell.d.ts +46 -0
- package/dist/shell/AdminShell.d.ts.map +1 -0
- package/dist/shell/AdminShell.js +76 -0
- package/dist/shell/AdminShell.js.map +1 -0
- package/dist/shell/guards.d.ts +28 -0
- package/dist/shell/guards.d.ts.map +1 -0
- package/dist/shell/guards.js +71 -0
- package/dist/shell/guards.js.map +1 -0
- package/icons.json +170 -0
- package/package.json +71 -6
- package/src/api/client.ts +139 -0
- package/src/components/ConfirmIdentityModal.tsx +82 -0
- package/src/components/ConfirmModal.tsx +83 -0
- package/src/components/CopyButton.tsx +71 -0
- package/src/components/CredentialSection.tsx +270 -0
- package/src/components/ErrorBoundary.tsx +47 -0
- package/src/components/ModalShell.tsx +160 -0
- package/src/components/ResetTwoFactorModal.tsx +74 -0
- package/src/components/SidePanel.tsx +96 -0
- package/src/components/SingleSignOnModal.tsx +161 -0
- package/src/components/SsoConfirmation.tsx +96 -0
- package/src/components/VaultTypePicker.tsx +131 -0
- package/src/components/WhatsNew.tsx +84 -0
- package/src/components/formParts.tsx +59 -0
- package/src/components/ui/Alert.tsx +45 -0
- package/src/components/ui/DataTable.tsx +225 -0
- package/src/components/ui/Field.tsx +63 -0
- package/src/components/ui/PageHeader.tsx +36 -0
- package/src/components/ui/Pagination.tsx +37 -0
- package/src/components/ui/QuickStartPanel.tsx +57 -0
- package/src/components/ui/StateViews.tsx +135 -0
- package/src/components/ui/Switch.tsx +65 -0
- package/src/components/ui/Toast.tsx +104 -0
- package/src/components/ui/index.ts +9 -0
- package/src/config.ts +46 -0
- package/src/hooks/useDarkMode.ts +38 -0
- package/src/hooks/useDebouncedValue.ts +22 -0
- package/src/index.ts +42 -0
- package/src/lib/changelog.ts +93 -0
- package/src/lib/dateFormat.ts +90 -0
- package/src/navigation.ts +6 -0
- package/src/pages/ApiTokens.tsx +361 -0
- package/src/pages/AuditLog.tsx +388 -0
- package/src/pages/Backups.tsx +170 -0
- package/src/pages/ChangePassword.tsx +84 -0
- package/src/pages/Login.tsx +275 -0
- package/src/pages/Settings.tsx +264 -0
- package/src/pages/Setup.tsx +438 -0
- package/src/pages/SsoConfirm.tsx +26 -0
- package/src/pages/SsoTestResult.tsx +104 -0
- package/src/pages/Users.tsx +426 -0
- package/src/pages/Vault.tsx +1036 -0
- package/src/shell/AdminShell.tsx +256 -0
- package/src/shell/guards.tsx +88 -0
- package/styles/base.css +521 -0
- package/styles/fonts/inter-latin-1.woff2 +0 -0
- package/styles/fonts/inter-latin-ext.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
- package/styles/fonts/manrope-latin-5.woff2 +0 -0
- package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
- package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
- package/styles/icons.css +4 -0
- package/tailwind-preset.js +79 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { useId, useState, type ComponentProps, type ReactNode } from 'react'
|
|
2
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { api, getCurrentUser } from '../api/client'
|
|
4
|
+
import { ConfirmIdentityModal } from '../components/ConfirmIdentityModal'
|
|
5
|
+
import { ConfirmModal } from '../components/ConfirmModal'
|
|
6
|
+
import { CopyButton } from '../components/CopyButton'
|
|
7
|
+
import { ResetTwoFactorModal } from '../components/ResetTwoFactorModal'
|
|
8
|
+
import { SingleSignOnModal } from '../components/SingleSignOnModal'
|
|
9
|
+
import { useSsoConfirmation } from '../components/SsoConfirmation'
|
|
10
|
+
import { Alert, EmptyStateLink, EmptyTableRow, ErrorState, LoadingState, PageContainer, PageHeader, useToast, DataTable, DataTh, useTableSort } from '../components/ui'
|
|
11
|
+
import { formatDate } from '../lib/dateFormat'
|
|
12
|
+
|
|
13
|
+
interface User {
|
|
14
|
+
id: number
|
|
15
|
+
email: string
|
|
16
|
+
role: string
|
|
17
|
+
pendingTemporaryPassword: boolean
|
|
18
|
+
ssoLinked: boolean
|
|
19
|
+
twoFactorEnabled: number
|
|
20
|
+
createdAt: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UserRole { value: string; label: string; desc: string }
|
|
24
|
+
|
|
25
|
+
/** A button in the header of the page that opens a dialog of the application's own (say, access settings). */
|
|
26
|
+
export interface UsersPageAction { label: string; icon: string; dialog: (close: () => void) => ReactNode }
|
|
27
|
+
|
|
28
|
+
export interface UsersPageProps {
|
|
29
|
+
roles: readonly UserRole[]
|
|
30
|
+
/** Higher number = more access; moving a user to a lower rank is a demotion and needs confirmation. */
|
|
31
|
+
roleRank: Readonly<Record<string, number>>
|
|
32
|
+
/** The role a new user starts with. */
|
|
33
|
+
defaultRole: string
|
|
34
|
+
actions?: readonly UsersPageAction[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function errorMessage(err: unknown, fallback: string) {
|
|
38
|
+
return err instanceof Error && err.message ? err.message : fallback
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ROW_ACTION = 'w-8 h-8 flex items-center justify-center rounded-lg transition-colors text-xs focus-ring'
|
|
42
|
+
|
|
43
|
+
export default function UsersPage({ roles: ROLES, roleRank: ROLE_RANK, defaultRole, actions = [] }: UsersPageProps) {
|
|
44
|
+
const roleLabel = (role: string) => ROLES.find((r) => r.value === role)?.label ?? role
|
|
45
|
+
const qc = useQueryClient()
|
|
46
|
+
const ssoConfirmation = useSsoConfirmation()
|
|
47
|
+
const toast = useToast()
|
|
48
|
+
const emailInputId = useId()
|
|
49
|
+
const [showCreate, setShowCreate] = useState(false)
|
|
50
|
+
const [showSso, setShowSso] = useState(false)
|
|
51
|
+
const [openAction, setOpenAction] = useState<number | null>(null)
|
|
52
|
+
const [newEmail, setNewEmail] = useState('')
|
|
53
|
+
const [newRole, setNewRole] = useState(defaultRole)
|
|
54
|
+
const [createdUser, setCreatedUser] = useState<{ email: string; temporaryPassword: string } | null>(null)
|
|
55
|
+
const [resetResult, setResetResult] = useState<{ email: string; temporaryPassword: string } | null>(null)
|
|
56
|
+
const [identityPrompt, setIdentityPrompt] = useState<Omit<ComponentProps<typeof ConfirmIdentityModal>, 'onClose'> | null>(null)
|
|
57
|
+
const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
|
|
58
|
+
const [twoFactorResetTarget, setTwoFactorResetTarget] = useState<User | null>(null)
|
|
59
|
+
const [message, setMessage] = useState('')
|
|
60
|
+
|
|
61
|
+
const { data: users = [], isLoading, isError, refetch } = useQuery<User[]>({
|
|
62
|
+
queryKey: ['users'],
|
|
63
|
+
queryFn: () => api.get('/admin/users'),
|
|
64
|
+
})
|
|
65
|
+
const { rows: sortedUsers, sort } = useTableSort(users, {
|
|
66
|
+
email: (u) => u.email,
|
|
67
|
+
role: (u) => ROLE_RANK[u.role] ?? 0,
|
|
68
|
+
status: (u) => u.pendingTemporaryPassword,
|
|
69
|
+
created: (u) => u.createdAt,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
function confirmCreateUser() {
|
|
73
|
+
const email = newEmail
|
|
74
|
+
setIdentityPrompt({
|
|
75
|
+
title: 'Create user',
|
|
76
|
+
message: `Create ${email} as ${roleLabel(newRole)}? They will be able to sign in with a temporary password.`,
|
|
77
|
+
confirmLabel: 'Create user',
|
|
78
|
+
pendingLabel: 'Creating…',
|
|
79
|
+
action: async (currentPassword) => {
|
|
80
|
+
const created = await api.post<{ email: string; temporaryPassword: string }>('/admin/users', { email, role: newRole, currentPassword })
|
|
81
|
+
qc.invalidateQueries({ queryKey: ['users'] })
|
|
82
|
+
setCreatedUser(created)
|
|
83
|
+
setShowCreate(false)
|
|
84
|
+
setNewEmail('')
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function confirmResetPassword(user: User) {
|
|
90
|
+
setIdentityPrompt({
|
|
91
|
+
title: 'Reset password',
|
|
92
|
+
message: `Reset password for ${user.email}? Their current password will stop working.`,
|
|
93
|
+
confirmLabel: 'Reset password',
|
|
94
|
+
pendingLabel: 'Resetting…',
|
|
95
|
+
danger: true,
|
|
96
|
+
action: async (currentPassword) => {
|
|
97
|
+
const reset = await api.post<{ temporaryPassword: string }>(`/admin/users/${user.id}/reset-password`, { currentPassword })
|
|
98
|
+
setResetResult({ email: user.email, temporaryPassword: reset.temporaryPassword })
|
|
99
|
+
qc.invalidateQueries({ queryKey: ['users'] })
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const deleteMutation = useMutation({
|
|
105
|
+
mutationFn: (id: number) => api.delete(`/admin/users/${id}`),
|
|
106
|
+
onSuccess: (_data, id) => {
|
|
107
|
+
const user = users.find((u) => u.id === id)
|
|
108
|
+
toast.success(user ? `Deleted ${user.email}.` : 'User deleted.')
|
|
109
|
+
setDeleteTarget(null)
|
|
110
|
+
qc.invalidateQueries({ queryKey: ['users'] })
|
|
111
|
+
},
|
|
112
|
+
onError: (err) => {
|
|
113
|
+
setDeleteTarget(null)
|
|
114
|
+
toast.error(errorMessage(err, 'Failed to delete user'))
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const resetTwoFactorMutation = useMutation({
|
|
119
|
+
mutationFn: ({ id, currentPassword }: { id: number; currentPassword: string | undefined }) =>
|
|
120
|
+
ssoConfirmation.run(() => api.post<{ twoFactorEnabled: false }>(`/admin/users/${id}/reset-2fa`, currentPassword === undefined ? {} : { currentPassword })),
|
|
121
|
+
onSuccess: (_data, variables) => {
|
|
122
|
+
const user = users.find((candidate) => candidate.id === variables.id)
|
|
123
|
+
setMessage(user ? `Two-factor authentication reset for ${user.email}.` : 'Two-factor authentication reset.')
|
|
124
|
+
setTwoFactorResetTarget(null)
|
|
125
|
+
qc.invalidateQueries({ queryKey: ['users'] })
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
function changeRole(user: User, role: string) {
|
|
130
|
+
if (user.role === role) return
|
|
131
|
+
const demotion = (ROLE_RANK[role] ?? 0) < (ROLE_RANK[user.role] ?? 0)
|
|
132
|
+
setIdentityPrompt({
|
|
133
|
+
title: 'Change role',
|
|
134
|
+
message: `Change ${user.email} from ${roleLabel(user.role)} to ${roleLabel(role)}?${demotion ? ' They will lose access to some areas immediately.' : ''}`,
|
|
135
|
+
confirmLabel: `Make ${roleLabel(role)}`,
|
|
136
|
+
pendingLabel: 'Saving…',
|
|
137
|
+
danger: demotion,
|
|
138
|
+
action: async (currentPassword) => {
|
|
139
|
+
await api.patch(`/admin/users/${user.id}/role`, { role, currentPassword })
|
|
140
|
+
toast.success(`${user.email} is now ${roleLabel(role)}.`)
|
|
141
|
+
qc.invalidateQueries({ queryKey: ['users'] })
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const currentUser = getCurrentUser()
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<PageContainer>
|
|
150
|
+
<PageHeader
|
|
151
|
+
title="Users"
|
|
152
|
+
subtitle={isLoading || isError ? undefined : `${users.length} user${users.length !== 1 ? 's' : ''}`}
|
|
153
|
+
actions={
|
|
154
|
+
<>
|
|
155
|
+
{actions.map((action, index) => (
|
|
156
|
+
<button key={action.label} type="button" onClick={() => setOpenAction(index)} className="btn btn-secondary">
|
|
157
|
+
<span className="material-symbols-outlined" aria-hidden="true">{action.icon}</span>
|
|
158
|
+
{action.label}
|
|
159
|
+
</button>
|
|
160
|
+
))}
|
|
161
|
+
<button type="button" onClick={() => setShowSso(true)} className="btn btn-secondary">
|
|
162
|
+
<span className="material-symbols-outlined" aria-hidden="true">passkey</span>
|
|
163
|
+
Single sign-on
|
|
164
|
+
</button>
|
|
165
|
+
<button type="button" onClick={() => setShowCreate(true)} className="btn btn-primary">
|
|
166
|
+
<span className="material-symbols-outlined" aria-hidden="true">person_add</span>
|
|
167
|
+
New User
|
|
168
|
+
</button>
|
|
169
|
+
</>
|
|
170
|
+
}
|
|
171
|
+
/>
|
|
172
|
+
|
|
173
|
+
{showSso && <SingleSignOnModal onClose={() => setShowSso(false)} />}
|
|
174
|
+
{openAction !== null && actions[openAction]?.dialog(() => setOpenAction(null))}
|
|
175
|
+
|
|
176
|
+
{message && (
|
|
177
|
+
<Alert tone="success" onDismiss={() => setMessage('')} className="max-w-2xl">{message}</Alert>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{/* Create user form */}
|
|
181
|
+
{showCreate && (
|
|
182
|
+
<div className="rounded-2xl p-5" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
183
|
+
<p className="font-headline font-semibold text-sm mb-3" style={{ color: 'var(--m3-on-surface)' }}>New User</p>
|
|
184
|
+
<label htmlFor={emailInputId} className="block text-xs mb-1.5" style={{ color: 'var(--m3-secondary)' }}>Email</label>
|
|
185
|
+
<div className="flex flex-wrap gap-3">
|
|
186
|
+
<input
|
|
187
|
+
id={emailInputId}
|
|
188
|
+
type="email"
|
|
189
|
+
value={newEmail}
|
|
190
|
+
onChange={(e) => setNewEmail(e.target.value)}
|
|
191
|
+
placeholder="user@example.com"
|
|
192
|
+
className="input-sig flex-1 min-w-[200px]"
|
|
193
|
+
onKeyDown={(e) => e.key === 'Enter' && newEmail && confirmCreateUser()}
|
|
194
|
+
/>
|
|
195
|
+
<select aria-label="Role" value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-sig w-auto">
|
|
196
|
+
{ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
|
197
|
+
</select>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={() => newEmail && confirmCreateUser()}
|
|
201
|
+
disabled={!newEmail}
|
|
202
|
+
className="btn btn-primary"
|
|
203
|
+
>
|
|
204
|
+
Create
|
|
205
|
+
</button>
|
|
206
|
+
<button type="button" onClick={() => setShowCreate(false)} className="btn btn-ghost">
|
|
207
|
+
Cancel
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
<p className="text-xs mt-2" style={{ color: 'var(--m3-secondary)' }}>
|
|
211
|
+
A temporary password will be generated. Share it with the user — they will be required to change it on first login.
|
|
212
|
+
</p>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{/* Temp password display after creation */}
|
|
217
|
+
{createdUser && (
|
|
218
|
+
<Alert
|
|
219
|
+
tone="success"
|
|
220
|
+
title={<>User <strong>{createdUser.email}</strong> created</>}
|
|
221
|
+
onDismiss={() => setCreatedUser(null)}
|
|
222
|
+
>
|
|
223
|
+
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
|
|
224
|
+
Share this temporary password. It will not be shown again.
|
|
225
|
+
</p>
|
|
226
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
227
|
+
<code
|
|
228
|
+
className="font-mono text-base px-4 py-2 rounded-xl select-all"
|
|
229
|
+
style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)', letterSpacing: '0.05em' }}
|
|
230
|
+
>
|
|
231
|
+
{createdUser.temporaryPassword}
|
|
232
|
+
</code>
|
|
233
|
+
<CopyButton value={createdUser.temporaryPassword} />
|
|
234
|
+
</div>
|
|
235
|
+
</Alert>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{/* Reset password result */}
|
|
239
|
+
{resetResult && (
|
|
240
|
+
<Alert
|
|
241
|
+
tone="info"
|
|
242
|
+
title={<>Password reset for <strong>{resetResult.email}</strong></>}
|
|
243
|
+
onDismiss={() => setResetResult(null)}
|
|
244
|
+
>
|
|
245
|
+
<p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
|
|
246
|
+
New temporary password (shown once):
|
|
247
|
+
</p>
|
|
248
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
249
|
+
<code
|
|
250
|
+
className="font-mono text-base px-4 py-2 rounded-xl select-all"
|
|
251
|
+
style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)', letterSpacing: '0.05em' }}
|
|
252
|
+
>
|
|
253
|
+
{resetResult.temporaryPassword}
|
|
254
|
+
</code>
|
|
255
|
+
<CopyButton value={resetResult.temporaryPassword} />
|
|
256
|
+
</div>
|
|
257
|
+
</Alert>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{/* Users table */}
|
|
261
|
+
{isLoading ? (
|
|
262
|
+
<LoadingState label="Loading users…" />
|
|
263
|
+
) : isError ? (
|
|
264
|
+
<ErrorState message="Could not load users." onRetry={() => void refetch()} />
|
|
265
|
+
) : (
|
|
266
|
+
<div className="rounded-2xl overflow-x-auto" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
267
|
+
<DataTable sort={sort} className="w-full min-w-[720px] text-sm">
|
|
268
|
+
<thead>
|
|
269
|
+
<tr style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
|
|
270
|
+
{['Email', 'Role', 'Status', 'Created', ''].map((h) => (
|
|
271
|
+
<DataTh
|
|
272
|
+
key={h}
|
|
273
|
+
resizable={h !== ''}
|
|
274
|
+
{...(h !== '' && { sortKey: h.toLowerCase() })}
|
|
275
|
+
className={`px-4 py-3 font-mono text-xs uppercase tracking-wider ${h === '' ? 'text-right' : 'text-left'}`}
|
|
276
|
+
style={{ color: 'var(--m3-secondary)', background: 'var(--m3-surface-container)' }}
|
|
277
|
+
>
|
|
278
|
+
{h === '' ? <span className="sr-only">Actions</span> : h}
|
|
279
|
+
</DataTh>
|
|
280
|
+
))}
|
|
281
|
+
</tr>
|
|
282
|
+
</thead>
|
|
283
|
+
<tbody>
|
|
284
|
+
{sortedUsers.map((user, i) => (
|
|
285
|
+
<tr
|
|
286
|
+
key={user.id}
|
|
287
|
+
style={{ borderTop: i > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}
|
|
288
|
+
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--m3-surface-container)')}
|
|
289
|
+
onMouseLeave={(e) => (e.currentTarget.style.background = '')}
|
|
290
|
+
>
|
|
291
|
+
<td className="px-4 py-3 font-medium" style={{ color: 'var(--m3-on-surface)' }}>{user.email}</td>
|
|
292
|
+
<td className="px-4 py-3">
|
|
293
|
+
{currentUser?.userId === user.id ? (
|
|
294
|
+
<span className="text-xs font-semibold px-2.5 py-1 rounded-lg" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)' }}>
|
|
295
|
+
{roleLabel(user.role)}
|
|
296
|
+
</span>
|
|
297
|
+
) : (
|
|
298
|
+
<div role="group" aria-label={`Role for ${user.email}`} className="flex rounded-lg overflow-hidden" style={{ border: '1px solid var(--m3-outline-variant)', width: 'fit-content' }}>
|
|
299
|
+
{ROLES.map((r) => {
|
|
300
|
+
const active = user.role === r.value
|
|
301
|
+
return (
|
|
302
|
+
<button
|
|
303
|
+
key={r.value}
|
|
304
|
+
type="button"
|
|
305
|
+
title={r.desc}
|
|
306
|
+
aria-pressed={active}
|
|
307
|
+
onClick={() => changeRole(user, r.value)}
|
|
308
|
+
className={`text-xs font-medium px-3 py-1.5 transition-colors focus-ring ${active ? 'selection-active' : ''}`}
|
|
309
|
+
style={{
|
|
310
|
+
background: 'transparent',
|
|
311
|
+
color: 'var(--m3-secondary)',
|
|
312
|
+
cursor: active ? 'default' : 'pointer',
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
{r.label}
|
|
316
|
+
</button>
|
|
317
|
+
)
|
|
318
|
+
})}
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
</td>
|
|
322
|
+
<td className="px-4 py-3">
|
|
323
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
324
|
+
{user.pendingTemporaryPassword ? (
|
|
325
|
+
<span className="text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style={{ background: 'var(--m3-degraded-bg)', color: 'var(--m3-degraded)' }}>
|
|
326
|
+
Temp password
|
|
327
|
+
</span>
|
|
328
|
+
) : (
|
|
329
|
+
<span className="text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style={{ background: 'var(--m3-up-bg)', color: 'var(--m3-up)' }}>
|
|
330
|
+
Active
|
|
331
|
+
</span>
|
|
332
|
+
)}
|
|
333
|
+
{!!user.twoFactorEnabled && (
|
|
334
|
+
<span className="text-xs font-medium px-2 py-0.5 rounded-full" style={{ background: 'var(--m3-surface-container-high)', color: 'var(--m3-on-surface)' }}>2FA</span>
|
|
335
|
+
)}
|
|
336
|
+
{user.ssoLinked && (
|
|
337
|
+
<span className="text-xs font-medium px-2 py-0.5 rounded-full" title="Signed in through single sign-on; matched by their identity-provider account" style={{ background: 'var(--m3-surface-container-high)', color: 'var(--m3-on-surface)' }}>SSO</span>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
</td>
|
|
341
|
+
<td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>
|
|
342
|
+
{formatDate(user.createdAt)}
|
|
343
|
+
</td>
|
|
344
|
+
<td className="px-4 py-3">
|
|
345
|
+
<div className="flex items-center justify-end gap-1">
|
|
346
|
+
{!!user.twoFactorEnabled && currentUser?.userId !== user.id && (
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
onClick={() => { resetTwoFactorMutation.reset(); setTwoFactorResetTarget(user); setMessage('') }}
|
|
350
|
+
title="Reset 2FA"
|
|
351
|
+
aria-label={`Reset 2FA for ${user.email}`}
|
|
352
|
+
className={ROW_ACTION}
|
|
353
|
+
style={{ color: 'var(--m3-secondary)' }}
|
|
354
|
+
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--m3-down-bg)'; event.currentTarget.style.color = 'var(--m3-down)' }}
|
|
355
|
+
onMouseLeave={(event) => { event.currentTarget.style.background = ''; event.currentTarget.style.color = 'var(--m3-secondary)' }}
|
|
356
|
+
>
|
|
357
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>no_encryption</span>
|
|
358
|
+
</button>
|
|
359
|
+
)}
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
onClick={() => confirmResetPassword(user)}
|
|
363
|
+
title="Reset password"
|
|
364
|
+
aria-label={`Reset password for ${user.email}`}
|
|
365
|
+
className={ROW_ACTION}
|
|
366
|
+
style={{ color: 'var(--m3-secondary)' }}
|
|
367
|
+
onMouseEnter={(e) => { (e.currentTarget).style.background = 'var(--m3-surface-container-high)'; (e.currentTarget).style.color = 'var(--m3-on-surface)' }}
|
|
368
|
+
onMouseLeave={(e) => { (e.currentTarget).style.background = ''; (e.currentTarget).style.color = 'var(--m3-secondary)' }}
|
|
369
|
+
>
|
|
370
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>lock_reset</span>
|
|
371
|
+
</button>
|
|
372
|
+
{currentUser?.userId !== user.id && (
|
|
373
|
+
<button
|
|
374
|
+
type="button"
|
|
375
|
+
onClick={() => setDeleteTarget(user)}
|
|
376
|
+
title="Delete user"
|
|
377
|
+
aria-label={`Delete ${user.email}`}
|
|
378
|
+
className={ROW_ACTION}
|
|
379
|
+
style={{ color: 'var(--m3-secondary)' }}
|
|
380
|
+
onMouseEnter={(e) => { (e.currentTarget).style.background = 'var(--m3-error-container)'; (e.currentTarget).style.color = 'var(--m3-on-error-container)' }}
|
|
381
|
+
onMouseLeave={(e) => { (e.currentTarget).style.background = ''; (e.currentTarget).style.color = 'var(--m3-secondary)' }}
|
|
382
|
+
>
|
|
383
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>delete</span>
|
|
384
|
+
</button>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
</td>
|
|
388
|
+
</tr>
|
|
389
|
+
))}
|
|
390
|
+
{users.length === 0 && (
|
|
391
|
+
<EmptyTableRow
|
|
392
|
+
colSpan={5}
|
|
393
|
+
icon="group"
|
|
394
|
+
title="No users yet."
|
|
395
|
+
description={<><EmptyStateLink onClick={() => setShowCreate(true)}>Add a user</EmptyStateLink> to share access to the admin panel.</>}
|
|
396
|
+
/>
|
|
397
|
+
)}
|
|
398
|
+
</tbody>
|
|
399
|
+
</DataTable>
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
{identityPrompt && <ConfirmIdentityModal {...identityPrompt} onClose={() => setIdentityPrompt(null)} />}
|
|
403
|
+
{deleteTarget && (
|
|
404
|
+
<ConfirmModal
|
|
405
|
+
title="Delete user"
|
|
406
|
+
message={`Delete user "${deleteTarget.email}"? This action cannot be undone.`}
|
|
407
|
+
confirmLabel="Delete"
|
|
408
|
+
pending={deleteMutation.isPending}
|
|
409
|
+
pendingLabel="Deleting…"
|
|
410
|
+
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
|
|
411
|
+
onCancel={() => setDeleteTarget(null)}
|
|
412
|
+
/>
|
|
413
|
+
)}
|
|
414
|
+
{twoFactorResetTarget && (
|
|
415
|
+
<ResetTwoFactorModal
|
|
416
|
+
email={twoFactorResetTarget.email}
|
|
417
|
+
pending={resetTwoFactorMutation.isPending}
|
|
418
|
+
{...(resetTwoFactorMutation.error ? { error: errorMessage(resetTwoFactorMutation.error, 'Failed to reset two-factor authentication') } : {})}
|
|
419
|
+
ssoConfirmation={ssoConfirmation}
|
|
420
|
+
onConfirm={(currentPassword) => resetTwoFactorMutation.mutate({ id: twoFactorResetTarget.id, currentPassword })}
|
|
421
|
+
onCancel={() => { if (!resetTwoFactorMutation.isPending) setTwoFactorResetTarget(null) }}
|
|
422
|
+
/>
|
|
423
|
+
)}
|
|
424
|
+
</PageContainer>
|
|
425
|
+
)
|
|
426
|
+
}
|