@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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -3
  3. package/dist/api/client.d.ts +42 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +130 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/components/ConfirmIdentityModal.d.ts +18 -0
  8. package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
  9. package/dist/components/ConfirmIdentityModal.js +30 -0
  10. package/dist/components/ConfirmIdentityModal.js.map +1 -0
  11. package/dist/components/ConfirmModal.d.ts +18 -0
  12. package/dist/components/ConfirmModal.d.ts.map +1 -0
  13. package/dist/components/ConfirmModal.js +11 -0
  14. package/dist/components/ConfirmModal.js.map +1 -0
  15. package/dist/components/CopyButton.d.ts +11 -0
  16. package/dist/components/CopyButton.d.ts.map +1 -0
  17. package/dist/components/CopyButton.js +44 -0
  18. package/dist/components/CopyButton.js.map +1 -0
  19. package/dist/components/CredentialSection.d.ts +38 -0
  20. package/dist/components/CredentialSection.d.ts.map +1 -0
  21. package/dist/components/CredentialSection.js +69 -0
  22. package/dist/components/CredentialSection.js.map +1 -0
  23. package/dist/components/ErrorBoundary.d.ts +18 -0
  24. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  25. package/dist/components/ErrorBoundary.js +25 -0
  26. package/dist/components/ErrorBoundary.js.map +1 -0
  27. package/dist/components/ModalShell.d.ts +58 -0
  28. package/dist/components/ModalShell.d.ts.map +1 -0
  29. package/dist/components/ModalShell.js +92 -0
  30. package/dist/components/ModalShell.js.map +1 -0
  31. package/dist/components/ResetTwoFactorModal.d.ts +14 -0
  32. package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
  33. package/dist/components/ResetTwoFactorModal.js +16 -0
  34. package/dist/components/ResetTwoFactorModal.js.map +1 -0
  35. package/dist/components/SidePanel.d.ts +35 -0
  36. package/dist/components/SidePanel.d.ts.map +1 -0
  37. package/dist/components/SidePanel.js +19 -0
  38. package/dist/components/SidePanel.js.map +1 -0
  39. package/dist/components/SingleSignOnModal.d.ts +4 -0
  40. package/dist/components/SingleSignOnModal.d.ts.map +1 -0
  41. package/dist/components/SingleSignOnModal.js +64 -0
  42. package/dist/components/SingleSignOnModal.js.map +1 -0
  43. package/dist/components/SsoConfirmation.d.ts +22 -0
  44. package/dist/components/SsoConfirmation.d.ts.map +1 -0
  45. package/dist/components/SsoConfirmation.js +87 -0
  46. package/dist/components/SsoConfirmation.js.map +1 -0
  47. package/dist/components/VaultTypePicker.d.ts +13 -0
  48. package/dist/components/VaultTypePicker.d.ts.map +1 -0
  49. package/dist/components/VaultTypePicker.js +62 -0
  50. package/dist/components/VaultTypePicker.js.map +1 -0
  51. package/dist/components/WhatsNew.d.ts +18 -0
  52. package/dist/components/WhatsNew.d.ts.map +1 -0
  53. package/dist/components/WhatsNew.js +50 -0
  54. package/dist/components/WhatsNew.js.map +1 -0
  55. package/dist/components/formParts.d.ts +40 -0
  56. package/dist/components/formParts.d.ts.map +1 -0
  57. package/dist/components/formParts.js +31 -0
  58. package/dist/components/formParts.js.map +1 -0
  59. package/dist/components/ui/Alert.d.ts +15 -0
  60. package/dist/components/ui/Alert.d.ts.map +1 -0
  61. package/dist/components/ui/Alert.js +14 -0
  62. package/dist/components/ui/Alert.js.map +1 -0
  63. package/dist/components/ui/DataTable.d.ts +52 -0
  64. package/dist/components/ui/DataTable.d.ts.map +1 -0
  65. package/dist/components/ui/DataTable.js +112 -0
  66. package/dist/components/ui/DataTable.js.map +1 -0
  67. package/dist/components/ui/Field.d.ts +27 -0
  68. package/dist/components/ui/Field.d.ts.map +1 -0
  69. package/dist/components/ui/Field.js +25 -0
  70. package/dist/components/ui/Field.js.map +1 -0
  71. package/dist/components/ui/PageHeader.d.ts +18 -0
  72. package/dist/components/ui/PageHeader.d.ts.map +1 -0
  73. package/dist/components/ui/PageHeader.js +10 -0
  74. package/dist/components/ui/PageHeader.js.map +1 -0
  75. package/dist/components/ui/Pagination.d.ts +21 -0
  76. package/dist/components/ui/Pagination.d.ts.map +1 -0
  77. package/dist/components/ui/Pagination.js +10 -0
  78. package/dist/components/ui/Pagination.js.map +1 -0
  79. package/dist/components/ui/QuickStartPanel.d.ts +21 -0
  80. package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
  81. package/dist/components/ui/QuickStartPanel.js +11 -0
  82. package/dist/components/ui/QuickStartPanel.js.map +1 -0
  83. package/dist/components/ui/StateViews.d.ts +50 -0
  84. package/dist/components/ui/StateViews.d.ts.map +1 -0
  85. package/dist/components/ui/StateViews.js +33 -0
  86. package/dist/components/ui/StateViews.js.map +1 -0
  87. package/dist/components/ui/Switch.d.ts +16 -0
  88. package/dist/components/ui/Switch.d.ts.map +1 -0
  89. package/dist/components/ui/Switch.js +15 -0
  90. package/dist/components/ui/Switch.js.map +1 -0
  91. package/dist/components/ui/Toast.d.ts +20 -0
  92. package/dist/components/ui/Toast.d.ts.map +1 -0
  93. package/dist/components/ui/Toast.js +46 -0
  94. package/dist/components/ui/Toast.js.map +1 -0
  95. package/dist/components/ui/index.d.ts +10 -0
  96. package/dist/components/ui/index.d.ts.map +1 -0
  97. package/dist/components/ui/index.js +10 -0
  98. package/dist/components/ui/index.js.map +1 -0
  99. package/dist/config.d.ts +46 -0
  100. package/dist/config.d.ts.map +1 -0
  101. package/dist/config.js +12 -0
  102. package/dist/config.js.map +1 -0
  103. package/dist/hooks/useDarkMode.d.ts +2 -0
  104. package/dist/hooks/useDarkMode.d.ts.map +1 -0
  105. package/dist/hooks/useDarkMode.js +37 -0
  106. package/dist/hooks/useDarkMode.js.map +1 -0
  107. package/dist/hooks/useDebouncedValue.d.ts +6 -0
  108. package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
  109. package/dist/hooks/useDebouncedValue.js +19 -0
  110. package/dist/hooks/useDebouncedValue.js.map +1 -0
  111. package/dist/index.d.ts +36 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +36 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/lib/changelog.d.ts +32 -0
  116. package/dist/lib/changelog.d.ts.map +1 -0
  117. package/dist/lib/changelog.js +71 -0
  118. package/dist/lib/changelog.js.map +1 -0
  119. package/dist/lib/dateFormat.d.ts +24 -0
  120. package/dist/lib/dateFormat.d.ts.map +1 -0
  121. package/dist/lib/dateFormat.js +67 -0
  122. package/dist/lib/dateFormat.js.map +1 -0
  123. package/dist/navigation.d.ts +5 -0
  124. package/dist/navigation.d.ts.map +1 -0
  125. package/dist/navigation.js +7 -0
  126. package/dist/navigation.js.map +1 -0
  127. package/dist/pages/ApiTokens.d.ts +9 -0
  128. package/dist/pages/ApiTokens.d.ts.map +1 -0
  129. package/dist/pages/ApiTokens.js +116 -0
  130. package/dist/pages/ApiTokens.js.map +1 -0
  131. package/dist/pages/AuditLog.d.ts +14 -0
  132. package/dist/pages/AuditLog.d.ts.map +1 -0
  133. package/dist/pages/AuditLog.js +104 -0
  134. package/dist/pages/AuditLog.js.map +1 -0
  135. package/dist/pages/Backups.d.ts +2 -0
  136. package/dist/pages/Backups.d.ts.map +1 -0
  137. package/dist/pages/Backups.js +118 -0
  138. package/dist/pages/Backups.js.map +1 -0
  139. package/dist/pages/ChangePassword.d.ts +2 -0
  140. package/dist/pages/ChangePassword.d.ts.map +1 -0
  141. package/dist/pages/ChangePassword.js +40 -0
  142. package/dist/pages/ChangePassword.js.map +1 -0
  143. package/dist/pages/Login.d.ts +12 -0
  144. package/dist/pages/Login.d.ts.map +1 -0
  145. package/dist/pages/Login.js +69 -0
  146. package/dist/pages/Login.js.map +1 -0
  147. package/dist/pages/Settings.d.ts +2 -0
  148. package/dist/pages/Settings.d.ts.map +1 -0
  149. package/dist/pages/Settings.js +149 -0
  150. package/dist/pages/Settings.js.map +1 -0
  151. package/dist/pages/Setup.d.ts +2 -0
  152. package/dist/pages/Setup.d.ts.map +1 -0
  153. package/dist/pages/Setup.js +113 -0
  154. package/dist/pages/Setup.js.map +1 -0
  155. package/dist/pages/SsoConfirm.d.ts +3 -0
  156. package/dist/pages/SsoConfirm.d.ts.map +1 -0
  157. package/dist/pages/SsoConfirm.js +20 -0
  158. package/dist/pages/SsoConfirm.js.map +1 -0
  159. package/dist/pages/SsoTestResult.d.ts +2 -0
  160. package/dist/pages/SsoTestResult.d.ts.map +1 -0
  161. package/dist/pages/SsoTestResult.js +49 -0
  162. package/dist/pages/SsoTestResult.js.map +1 -0
  163. package/dist/pages/Users.d.ts +22 -0
  164. package/dist/pages/Users.d.ts.map +1 -0
  165. package/dist/pages/Users.js +124 -0
  166. package/dist/pages/Users.js.map +1 -0
  167. package/dist/pages/Vault.d.ts +2 -0
  168. package/dist/pages/Vault.d.ts.map +1 -0
  169. package/dist/pages/Vault.js +372 -0
  170. package/dist/pages/Vault.js.map +1 -0
  171. package/dist/shell/AdminShell.d.ts +46 -0
  172. package/dist/shell/AdminShell.d.ts.map +1 -0
  173. package/dist/shell/AdminShell.js +76 -0
  174. package/dist/shell/AdminShell.js.map +1 -0
  175. package/dist/shell/guards.d.ts +28 -0
  176. package/dist/shell/guards.d.ts.map +1 -0
  177. package/dist/shell/guards.js +71 -0
  178. package/dist/shell/guards.js.map +1 -0
  179. package/icons.json +170 -0
  180. package/package.json +71 -6
  181. package/src/api/client.ts +139 -0
  182. package/src/components/ConfirmIdentityModal.tsx +82 -0
  183. package/src/components/ConfirmModal.tsx +83 -0
  184. package/src/components/CopyButton.tsx +71 -0
  185. package/src/components/CredentialSection.tsx +270 -0
  186. package/src/components/ErrorBoundary.tsx +47 -0
  187. package/src/components/ModalShell.tsx +160 -0
  188. package/src/components/ResetTwoFactorModal.tsx +74 -0
  189. package/src/components/SidePanel.tsx +96 -0
  190. package/src/components/SingleSignOnModal.tsx +161 -0
  191. package/src/components/SsoConfirmation.tsx +96 -0
  192. package/src/components/VaultTypePicker.tsx +131 -0
  193. package/src/components/WhatsNew.tsx +84 -0
  194. package/src/components/formParts.tsx +59 -0
  195. package/src/components/ui/Alert.tsx +45 -0
  196. package/src/components/ui/DataTable.tsx +225 -0
  197. package/src/components/ui/Field.tsx +63 -0
  198. package/src/components/ui/PageHeader.tsx +36 -0
  199. package/src/components/ui/Pagination.tsx +37 -0
  200. package/src/components/ui/QuickStartPanel.tsx +57 -0
  201. package/src/components/ui/StateViews.tsx +135 -0
  202. package/src/components/ui/Switch.tsx +65 -0
  203. package/src/components/ui/Toast.tsx +104 -0
  204. package/src/components/ui/index.ts +9 -0
  205. package/src/config.ts +46 -0
  206. package/src/hooks/useDarkMode.ts +38 -0
  207. package/src/hooks/useDebouncedValue.ts +22 -0
  208. package/src/index.ts +42 -0
  209. package/src/lib/changelog.ts +93 -0
  210. package/src/lib/dateFormat.ts +90 -0
  211. package/src/navigation.ts +6 -0
  212. package/src/pages/ApiTokens.tsx +361 -0
  213. package/src/pages/AuditLog.tsx +388 -0
  214. package/src/pages/Backups.tsx +170 -0
  215. package/src/pages/ChangePassword.tsx +84 -0
  216. package/src/pages/Login.tsx +275 -0
  217. package/src/pages/Settings.tsx +264 -0
  218. package/src/pages/Setup.tsx +438 -0
  219. package/src/pages/SsoConfirm.tsx +26 -0
  220. package/src/pages/SsoTestResult.tsx +104 -0
  221. package/src/pages/Users.tsx +426 -0
  222. package/src/pages/Vault.tsx +1036 -0
  223. package/src/shell/AdminShell.tsx +256 -0
  224. package/src/shell/guards.tsx +88 -0
  225. package/styles/base.css +521 -0
  226. package/styles/fonts/inter-latin-1.woff2 +0 -0
  227. package/styles/fonts/inter-latin-ext.woff2 +0 -0
  228. package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
  229. package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
  230. package/styles/fonts/manrope-latin-5.woff2 +0 -0
  231. package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
  232. package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
  233. package/styles/icons.css +4 -0
  234. package/tailwind-preset.js +79 -0
@@ -0,0 +1,90 @@
1
+ import { safeLocalStorage } from '@better-forge/contracts'
2
+ import { useSyncExternalStore } from 'react'
3
+ import { storageKey } from '../config'
4
+
5
+ /** Admin date and time display, chosen per browser in Settings. */
6
+ export type DateFormat = 'dmy' | 'dmyDash' | 'mdy' | 'ymd' | 'ymdDot'
7
+ export type TimeFormat = '24h' | '12h'
8
+ export interface DatePrefs { date: DateFormat; time: TimeFormat }
9
+
10
+ interface DatePattern { label: string; full: (y: number, m: string, d: string) => string; dayMonth: (m: string, d: string) => string }
11
+
12
+ const DATE_PATTERNS: Record<DateFormat, DatePattern> = {
13
+ dmy: { label: 'dd.mm.yyyy', full: (y, m, d) => `${d}.${m}.${y}`, dayMonth: (m, d) => `${d}.${m}` },
14
+ dmyDash: { label: 'dd-mm-yyyy', full: (y, m, d) => `${d}-${m}-${y}`, dayMonth: (m, d) => `${d}-${m}` },
15
+ mdy: { label: 'mm/dd/yyyy', full: (y, m, d) => `${m}/${d}/${y}`, dayMonth: (m, d) => `${m}/${d}` },
16
+ ymd: { label: 'yyyy-mm-dd', full: (y, m, d) => `${y}-${m}-${d}`, dayMonth: (m, d) => `${m}-${d}` },
17
+ ymdDot: { label: 'yyyy.mm.dd', full: (y, m, d) => `${y}.${m}.${d}`, dayMonth: (m, d) => `${m}.${d}` },
18
+ }
19
+
20
+ export const DATE_FORMATS = (Object.keys(DATE_PATTERNS) as DateFormat[]).map((value) => ({ value, label: DATE_PATTERNS[value].label }))
21
+ export const TIME_FORMATS: ReadonlyArray<{ value: TimeFormat; label: string }> = [
22
+ { value: '24h', label: '24-hour' },
23
+ { value: '12h', label: '12-hour' },
24
+ ]
25
+
26
+ const DEFAULTS: DatePrefs = { date: 'dmy', time: '24h' }
27
+
28
+ function load(): DatePrefs {
29
+ try {
30
+ const raw = JSON.parse(safeLocalStorage.get(storageKey('date-format')) ?? 'null') as Partial<DatePrefs> | null
31
+ return {
32
+ date: DATE_FORMATS.some((f) => f.value === raw?.date) ? raw!.date! : DEFAULTS.date,
33
+ time: TIME_FORMATS.some((f) => f.value === raw?.time) ? raw!.time! : DEFAULTS.time,
34
+ }
35
+ } catch {
36
+ return DEFAULTS
37
+ }
38
+ }
39
+
40
+ let prefs: DatePrefs | null = null
41
+ const listeners = new Set<() => void>()
42
+
43
+ export function getDatePrefs(): DatePrefs { return (prefs ??= load()) }
44
+
45
+ export function setDatePrefs(next: DatePrefs): void {
46
+ prefs = next
47
+ safeLocalStorage.set(storageKey('date-format'), JSON.stringify(next))
48
+ listeners.forEach((listener) => listener())
49
+ }
50
+
51
+ function subscribe(listener: () => void): () => void {
52
+ listeners.add(listener)
53
+ return () => { listeners.delete(listener) }
54
+ }
55
+
56
+ export function useDatePrefs(): DatePrefs {
57
+ return useSyncExternalStore(subscribe, getDatePrefs)
58
+ }
59
+
60
+ const pad = (n: number): string => String(n).padStart(2, '0')
61
+
62
+ /** `utc` formats the UTC calendar day instead of the local one (for UTC-bucketed data). */
63
+ export function formatDate(ms: number, utc = false): string {
64
+ const d = new Date(ms)
65
+ return DATE_PATTERNS[getDatePrefs().date].full(
66
+ utc ? d.getUTCFullYear() : d.getFullYear(),
67
+ pad((utc ? d.getUTCMonth() : d.getMonth()) + 1),
68
+ pad(utc ? d.getUTCDate() : d.getDate()),
69
+ )
70
+ }
71
+
72
+ export function formatDayMonth(ms: number): string {
73
+ const d = new Date(ms)
74
+ return DATE_PATTERNS[getDatePrefs().date].dayMonth(pad(d.getMonth() + 1), pad(d.getDate()))
75
+ }
76
+
77
+ export function formatClock(ms: number, seconds = false): string {
78
+ const d = new Date(ms)
79
+ const mm = pad(d.getMinutes())
80
+ const ss = seconds ? `:${pad(d.getSeconds())}` : ''
81
+ if (getDatePrefs().time === '12h') {
82
+ const h = d.getHours()
83
+ return `${h % 12 || 12}:${mm}${ss} ${h < 12 ? 'AM' : 'PM'}`
84
+ }
85
+ return `${pad(d.getHours())}:${mm}${ss}`
86
+ }
87
+
88
+ export function formatDateTime(ms: number, seconds = false): string {
89
+ return `${formatDate(ms)} ${formatClock(ms, seconds)}`
90
+ }
@@ -0,0 +1,6 @@
1
+ /** Full-page navigation, kept in one object so tests can observe it (jsdom cannot navigate). */
2
+ export const navigation = {
3
+ assign(url: string): void {
4
+ window.location.assign(url)
5
+ },
6
+ }
@@ -0,0 +1,361 @@
1
+ import { useId, useState, type ReactNode } from 'react'
2
+ import { VAULT_USE_SCOPE, type TokenPreset, type TokenResource } from '@better-forge/contracts'
3
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
4
+ import { api } from '../api/client'
5
+ import { uiConfig } from '../config'
6
+ import { ConfirmIdentityModal } from '../components/ConfirmIdentityModal'
7
+ import { ConfirmModal } from '../components/ConfirmModal'
8
+ import { ModalFooter, ModalHeader, ModalShell } from '../components/ModalShell'
9
+ import { CopyButton } from '../components/CopyButton'
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 ApiToken {
14
+ id: number
15
+ name: string
16
+ prefix: string
17
+ scopes: string[]
18
+ createdAt: number
19
+ expiresAt: number | null
20
+ lastUsedAt: number | null
21
+ }
22
+
23
+ /** Ninety days unless a person decides otherwise: a forgotten token should not work forever. */
24
+ const DEFAULT_EXPIRY = '90'
25
+ const EXPIRIES = [
26
+ { value: '30', label: '30 days' },
27
+ { value: '90', label: '90 days' },
28
+ { value: '365', label: '1 year' },
29
+ { value: 'never', label: 'Never expires' },
30
+ ]
31
+
32
+ const read = (key: string) => `${key}:read`
33
+ const write = (key: string) => `${key}:write`
34
+
35
+ /** What a token may do, for the list: each part with its highest level, the vault permission last. */
36
+ function permissionLabels(scopes: string[], resources: readonly TokenResource[]): string[] {
37
+ const labels = resources.flatMap((resource) => {
38
+ if (scopes.includes(write(resource.key))) return [`${resource.label} · write`]
39
+ if (scopes.includes(read(resource.key))) return [`${resource.label} · read`]
40
+ return []
41
+ })
42
+ return scopes.includes(VAULT_USE_SCOPE) ? [...labels, 'Vault secrets'] : labels
43
+ }
44
+
45
+ function errorMessage(err: unknown, fallback: string) {
46
+ return err instanceof Error && err.message ? err.message : fallback
47
+ }
48
+
49
+ function expiry(token: ApiToken) {
50
+ if (token.expiresAt === null) return 'Never'
51
+ return token.expiresAt <= Date.now() ? `Expired ${formatDate(token.expiresAt)}` : formatDate(token.expiresAt)
52
+ }
53
+
54
+ /** The parts of the API a token can be given, and the ready-made sets of permissions, are the application's. */
55
+ export default function ApiTokensPage({ resources, presets, actions }: { resources: readonly TokenResource[]; presets: readonly TokenPreset[]; /** Buttons shown next to New token, for what a product adds to tokens. */ actions?: ReactNode }) {
56
+ const qc = useQueryClient()
57
+ const toast = useToast()
58
+ const nameInputId = useId()
59
+ const [showCreate, setShowCreate] = useState(false)
60
+ const [name, setName] = useState('')
61
+ const [scopes, setScopes] = useState<string[]>([])
62
+ const [expiresInDays, setExpiresInDays] = useState(DEFAULT_EXPIRY)
63
+ const [created, setCreated] = useState<{ name: string; token: string } | null>(null)
64
+ const [revokeTarget, setRevokeTarget] = useState<ApiToken | null>(null)
65
+ const [confirming, setConfirming] = useState(false)
66
+
67
+ const { data: tokens = [], isLoading, isError, refetch } = useQuery<ApiToken[]>({
68
+ queryKey: ['api-tokens'],
69
+ queryFn: () => api.get('/admin/api-tokens'),
70
+ })
71
+ const { rows: sortedTokens, sort } = useTableSort(tokens, {
72
+ name: (t) => t.name,
73
+ token: (t) => t.prefix,
74
+ expires: (t) => t.expiresAt ?? Number.MAX_SAFE_INTEGER,
75
+ 'last used': (t) => t.lastUsedAt,
76
+ })
77
+
78
+ async function createToken(currentPassword: string | undefined) {
79
+ const data = await api.post<ApiToken & { token: string }>('/admin/api-tokens', {
80
+ name,
81
+ scopes,
82
+ expiresInDays: expiresInDays === 'never' ? null : Number(expiresInDays),
83
+ currentPassword,
84
+ })
85
+ qc.invalidateQueries({ queryKey: ['api-tokens'] })
86
+ setCreated({ name: data.name, token: data.token })
87
+ setShowCreate(false)
88
+ setName('')
89
+ setScopes([])
90
+ setExpiresInDays(DEFAULT_EXPIRY)
91
+ }
92
+
93
+ const revokeMutation = useMutation({
94
+ mutationFn: (id: number) => api.delete(`/admin/api-tokens/${id}`),
95
+ onSuccess: () => {
96
+ toast.success(revokeTarget ? `Revoked ${revokeTarget.name}.` : 'Token revoked.')
97
+ setRevokeTarget(null)
98
+ qc.invalidateQueries({ queryKey: ['api-tokens'] })
99
+ },
100
+ onError: (err) => {
101
+ setRevokeTarget(null)
102
+ toast.error(errorMessage(err, 'Failed to revoke token'))
103
+ },
104
+ })
105
+
106
+ function openCreate() {
107
+ setShowCreate(true)
108
+ setCreated(null)
109
+ }
110
+
111
+ /** Writing a part includes reading it, so ticking Write ticks Read and unticking Read unticks Write. */
112
+ function toggle(scope: string, on: boolean) {
113
+ setScopes((current) => {
114
+ const next = new Set(current)
115
+ const [key, level] = scope.split(':') as [string, string]
116
+ if (on) {
117
+ next.add(scope)
118
+ if (level === 'write') next.add(read(key))
119
+ } else {
120
+ next.delete(scope)
121
+ if (level === 'read') next.delete(write(key))
122
+ }
123
+ return [...next]
124
+ })
125
+ }
126
+
127
+ const canCreate = name.trim().length > 0 && scopes.length > 0
128
+
129
+ return (
130
+ <PageContainer>
131
+ <PageHeader
132
+ title="API tokens"
133
+ subtitle={isLoading || isError ? undefined : `${tokens.length} token${tokens.length !== 1 ? 's' : ''}`}
134
+ actions={
135
+ <>
136
+ {actions}
137
+ <button type="button" onClick={openCreate} className="btn btn-primary">
138
+ <span className="material-symbols-outlined" aria-hidden="true">add</span>
139
+ New token
140
+ </button>
141
+ </>
142
+ }
143
+ />
144
+
145
+ <p className="text-sm max-w-2xl" style={{ color: 'var(--m3-secondary)' }}>
146
+ Tokens let scripts and CI call the admin API with <code className="font-mono">Authorization: Bearer &lt;token&gt;</code>.
147
+ A token has only the permissions you tick. It cannot manage {uiConfig().texts.tokenCannotManage}. It belongs to no user, so revoke the tokens of an administrator who leaves.
148
+ </p>
149
+
150
+ {showCreate && (
151
+ <ModalShell align="top" onClose={() => setShowCreate(false)} label="New token">
152
+ <div className="rounded-2xl w-full max-w-2xl my-8" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
153
+ <ModalHeader icon="key" title="New token" onClose={() => setShowCreate(false)} />
154
+ <form onSubmit={(e) => { e.preventDefault(); if (canCreate) setConfirming(true) }}>
155
+ <div className="p-4 sm:p-6">
156
+ <label htmlFor={nameInputId} className="block text-xs mb-1.5" style={{ color: 'var(--m3-secondary)' }}>Name</label>
157
+ <div className="flex flex-wrap gap-3">
158
+ <input
159
+ id={nameInputId}
160
+ value={name}
161
+ onChange={(e) => setName(e.target.value)}
162
+ maxLength={80}
163
+ placeholder="GitHub Actions"
164
+ className="input-sig flex-1 min-w-[200px]"
165
+ />
166
+ <select aria-label="Expires" value={expiresInDays} onChange={(e) => setExpiresInDays(e.target.value)} className="input-sig w-auto">
167
+ {EXPIRIES.map((x) => <option key={x.value} value={x.value}>{x.label}</option>)}
168
+ </select>
169
+ </div>
170
+
171
+ <fieldset className="mt-4">
172
+ <legend className="text-xs mb-1.5" style={{ color: 'var(--m3-secondary)' }}>Permissions</legend>
173
+ <div className="flex flex-wrap gap-2 mb-3">
174
+ {presets.map((preset) => (
175
+ <button
176
+ key={preset.label}
177
+ type="button"
178
+ title={preset.description}
179
+ onClick={() => setScopes([...preset.scopes])}
180
+ className="btn btn-secondary btn-sm"
181
+ >
182
+ {preset.label}
183
+ </button>
184
+ ))}
185
+ <button type="button" onClick={() => setScopes([])} className="btn btn-ghost btn-sm">Clear</button>
186
+ </div>
187
+ <div className="rounded-xl overflow-x-auto" style={{ border: '1px solid var(--m3-outline-variant)' }}>
188
+ <table className="w-full min-w-[480px] text-sm">
189
+ <thead>
190
+ <tr style={{ background: 'var(--m3-surface-container)' }}>
191
+ <th className="px-4 py-2 text-left font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Part</th>
192
+ <th className="px-4 py-2 w-20 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Read</th>
193
+ <th className="px-4 py-2 w-20 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Write</th>
194
+ </tr>
195
+ </thead>
196
+ <tbody>
197
+ {resources.map((resource, index) => (
198
+ <tr key={resource.key} style={{ borderTop: index > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}>
199
+ <td className="px-4 py-2">
200
+ <span style={{ color: 'var(--m3-on-surface)' }}>{resource.label}</span>
201
+ <span className="block text-xs" style={{ color: 'var(--m3-secondary)' }}>{resource.description}</span>
202
+ </td>
203
+ {(['read', 'write'] as const).map((level) => (
204
+ <td key={level} className="px-4 py-2 text-center">
205
+ {resource.levels.includes(level) && (
206
+ <input
207
+ type="checkbox"
208
+ aria-label={`${resource.label} ${level}`}
209
+ checked={scopes.includes(`${resource.key}:${level}`)}
210
+ onChange={(e) => toggle(`${resource.key}:${level}`, e.target.checked)}
211
+ />
212
+ )}
213
+ </td>
214
+ ))}
215
+ </tr>
216
+ ))}
217
+ </tbody>
218
+ </table>
219
+ </div>
220
+ <label className="flex items-start gap-3 mt-3 text-sm" style={{ color: 'var(--m3-on-surface)' }}>
221
+ <input
222
+ type="checkbox"
223
+ className="mt-1"
224
+ checked={scopes.includes(VAULT_USE_SCOPE)}
225
+ onChange={(e) => toggle(VAULT_USE_SCOPE, e.target.checked)}
226
+ />
227
+ <span>
228
+ Use vault secrets
229
+ <span className="block text-xs" style={{ color: 'var(--m3-secondary)' }}>
230
+ Lets the token attach a vault secret to {uiConfig().texts.tokenVaultTargets}, or change one that already uses a vault. Without it the token cannot read or use credentials from a vault.
231
+ </span>
232
+ </span>
233
+ </label>
234
+ </fieldset>
235
+
236
+ </div>
237
+
238
+ <ModalFooter>
239
+ <button type="button" onClick={() => setShowCreate(false)} className="btn btn-secondary">Cancel</button>
240
+ <button type="submit" disabled={!canCreate} className="btn btn-primary">
241
+ Create
242
+ </button>
243
+ </ModalFooter>
244
+ </form>
245
+ </div>
246
+ </ModalShell>
247
+ )}
248
+
249
+ {confirming && (
250
+ <ConfirmIdentityModal
251
+ title="Create token"
252
+ message="A token can act on the admin API without you being signed in. Confirm it's you to create it."
253
+ confirmLabel="Create token"
254
+ pendingLabel="Creating…"
255
+ action={createToken}
256
+ onClose={() => setConfirming(false)}
257
+ />
258
+ )}
259
+
260
+ {created && (
261
+ <Alert tone="success" title={<>Token <strong>{created.name}</strong> created</>} onDismiss={() => setCreated(null)}>
262
+ <p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
263
+ Copy it now. It will not be shown again; if you lose it, revoke it and create a new one.
264
+ </p>
265
+ <div className="flex flex-wrap items-center gap-3">
266
+ <code
267
+ className="font-mono text-sm px-4 py-2 rounded-xl select-all break-all"
268
+ style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)' }}
269
+ >
270
+ {created.token}
271
+ </code>
272
+ <CopyButton value={created.token} />
273
+ </div>
274
+ </Alert>
275
+ )}
276
+
277
+ {isLoading ? (
278
+ <LoadingState label="Loading tokens…" />
279
+ ) : isError ? (
280
+ <ErrorState message="Could not load tokens." onRetry={() => void refetch()} />
281
+ ) : (
282
+ <div className="rounded-2xl overflow-x-auto" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
283
+ <DataTable sort={sort} className="w-full min-w-[760px] text-sm">
284
+ <thead>
285
+ <tr style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
286
+ {['Name', 'Token', 'Permissions', 'Expires', 'Last used', ''].map((h) => (
287
+ <DataTh
288
+ key={h}
289
+ resizable={h !== ''}
290
+ {...(h !== '' && h !== 'Permissions' && { sortKey: h.toLowerCase() })}
291
+ className={`px-4 py-3 font-mono text-xs uppercase tracking-wider ${h === '' ? 'text-right' : 'text-left'}`}
292
+ style={{ color: 'var(--m3-secondary)', background: 'var(--m3-surface-container)' }}
293
+ >
294
+ {h === '' ? <span className="sr-only">Actions</span> : h}
295
+ </DataTh>
296
+ ))}
297
+ </tr>
298
+ </thead>
299
+ <tbody>
300
+ {sortedTokens.map((token, i) => (
301
+ <tr key={token.id} style={{ borderTop: i > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}>
302
+ <td className="px-4 py-3 font-medium" style={{ color: 'var(--m3-on-surface)' }}>{token.name}</td>
303
+ <td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>{token.prefix}…</td>
304
+ <td className="px-4 py-3">
305
+ <ul className="flex flex-wrap gap-1.5" aria-label={`Permissions of ${token.name}`}>
306
+ {permissionLabels(token.scopes, resources).map((label) => (
307
+ <li key={label} className="text-xs font-semibold px-2 py-0.5 rounded-lg" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)' }}>
308
+ {label}
309
+ </li>
310
+ ))}
311
+ </ul>
312
+ </td>
313
+ <td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>{expiry(token)}</td>
314
+ <td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>
315
+ {token.lastUsedAt === null ? 'Never' : formatDate(token.lastUsedAt)}
316
+ </td>
317
+ <td className="px-4 py-3">
318
+ <div className="flex items-center justify-end">
319
+ <button
320
+ type="button"
321
+ onClick={() => setRevokeTarget(token)}
322
+ title="Revoke token"
323
+ aria-label={`Revoke ${token.name}`}
324
+ className="w-8 h-8 flex items-center justify-center rounded-lg transition-colors text-xs focus-ring"
325
+ style={{ color: 'var(--m3-secondary)' }}
326
+ onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--m3-error-container)'; e.currentTarget.style.color = 'var(--m3-on-error-container)' }}
327
+ onMouseLeave={(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = 'var(--m3-secondary)' }}
328
+ >
329
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>delete</span>
330
+ </button>
331
+ </div>
332
+ </td>
333
+ </tr>
334
+ ))}
335
+ {tokens.length === 0 && (
336
+ <EmptyTableRow
337
+ colSpan={7}
338
+ icon="key"
339
+ title="No API tokens yet."
340
+ description={<><EmptyStateLink onClick={openCreate}>Create a token</EmptyStateLink> to call the admin API from scripts and CI.</>}
341
+ />
342
+ )}
343
+ </tbody>
344
+ </DataTable>
345
+ </div>
346
+ )}
347
+
348
+ {revokeTarget && (
349
+ <ConfirmModal
350
+ title="Revoke token"
351
+ message={`Revoke "${revokeTarget.name}"? Anything still using it stops working immediately.`}
352
+ confirmLabel="Revoke"
353
+ pending={revokeMutation.isPending}
354
+ pendingLabel="Revoking…"
355
+ onConfirm={() => revokeMutation.mutate(revokeTarget.id)}
356
+ onCancel={() => setRevokeTarget(null)}
357
+ />
358
+ )}
359
+ </PageContainer>
360
+ )
361
+ }