@prenta/admin 1.8.0 → 1.9.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 (85) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/design-system/tokens.css +38 -6
  3. package/design-system/tokens.json +36 -6
  4. package/design-system/tokens.scss +6 -6
  5. package/dist/__tests__/components/author-avatar.test.js +18 -1
  6. package/dist/__tests__/components/author-avatar.test.js.map +1 -1
  7. package/dist/__tests__/lib/utils.test.js +2 -1
  8. package/dist/__tests__/lib/utils.test.js.map +1 -1
  9. package/dist/__tests__/styles/tokens-drift.test.js +32 -0
  10. package/dist/__tests__/styles/tokens-drift.test.js.map +1 -1
  11. package/dist/__tests__/views/security-settings.render.test.js +1 -0
  12. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  13. package/dist/__tests__/views/trash.render.test.js +33 -0
  14. package/dist/__tests__/views/trash.render.test.js.map +1 -1
  15. package/dist/__tests__/views/user-detail-drawer.render.test.js +1 -0
  16. package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -1
  17. package/dist/__tests__/views/users-selected-deeplink.render.test.js +1 -0
  18. package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -1
  19. package/dist/components/LivePreview.d.ts.map +1 -1
  20. package/dist/components/LivePreview.js +2 -1
  21. package/dist/components/LivePreview.js.map +1 -1
  22. package/dist/components/PresenceIndicator.d.ts.map +1 -1
  23. package/dist/components/PresenceIndicator.js +3 -1
  24. package/dist/components/PresenceIndicator.js.map +1 -1
  25. package/dist/components/ui/AuthorAvatar.d.ts +12 -0
  26. package/dist/components/ui/AuthorAvatar.d.ts.map +1 -1
  27. package/dist/components/ui/AuthorAvatar.js +8 -8
  28. package/dist/components/ui/AuthorAvatar.js.map +1 -1
  29. package/dist/components/ui/DataTable.d.ts.map +1 -1
  30. package/dist/components/ui/DataTable.js +7 -1
  31. package/dist/components/ui/DataTable.js.map +1 -1
  32. package/dist/layout/Sidebar.js +1 -1
  33. package/dist/lib/sentry.d.ts.map +1 -1
  34. package/dist/lib/sentry.js +11 -0
  35. package/dist/lib/sentry.js.map +1 -1
  36. package/dist/prenta-admin.css +1 -1
  37. package/dist/views/CollectionList.d.ts.map +1 -1
  38. package/dist/views/CollectionList.js +6 -2
  39. package/dist/views/CollectionList.js.map +1 -1
  40. package/dist/views/Dashboard.d.ts.map +1 -1
  41. package/dist/views/Dashboard.js +4 -5
  42. package/dist/views/Dashboard.js.map +1 -1
  43. package/dist/views/MediaBrowser.d.ts.map +1 -1
  44. package/dist/views/MediaBrowser.js +6 -5
  45. package/dist/views/MediaBrowser.js.map +1 -1
  46. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  47. package/dist/views/Posts/PostsListView.js +6 -5
  48. package/dist/views/Posts/PostsListView.js.map +1 -1
  49. package/dist/views/Redirects.d.ts.map +1 -1
  50. package/dist/views/Redirects.js +2 -1
  51. package/dist/views/Redirects.js.map +1 -1
  52. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  53. package/dist/views/settings/SessionPolicyCard.js +3 -1
  54. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  55. package/dist/views/settings/useSecuritySettings.d.ts +2 -0
  56. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  57. package/dist/views/settings/useSecuritySettings.js +8 -0
  58. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  59. package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
  60. package/dist/views/users/UserDetailDrawer.js +5 -5
  61. package/dist/views/users/UserDetailDrawer.js.map +1 -1
  62. package/package.json +4 -3
  63. package/src/__tests__/components/author-avatar.test.tsx +22 -1
  64. package/src/__tests__/lib/utils.test.ts +2 -1
  65. package/src/__tests__/styles/tokens-drift.test.ts +46 -0
  66. package/src/__tests__/views/security-settings.render.test.tsx +1 -0
  67. package/src/__tests__/views/trash.render.test.tsx +38 -0
  68. package/src/__tests__/views/user-detail-drawer.render.test.tsx +1 -0
  69. package/src/__tests__/views/users-selected-deeplink.render.test.tsx +1 -0
  70. package/src/components/LivePreview.tsx +2 -1
  71. package/src/components/PresenceIndicator.tsx +3 -1
  72. package/src/components/ui/AuthorAvatar.tsx +8 -8
  73. package/src/components/ui/DataTable.tsx +13 -1
  74. package/src/layout/Sidebar.tsx +1 -1
  75. package/src/lib/sentry.ts +11 -0
  76. package/src/styles/prenta-tokens.css +38 -6
  77. package/src/styles/theme.css +4 -2
  78. package/src/views/CollectionList.tsx +8 -4
  79. package/src/views/Dashboard.tsx +5 -11
  80. package/src/views/MediaBrowser.tsx +35 -12
  81. package/src/views/Posts/PostsListView.tsx +13 -11
  82. package/src/views/Redirects.tsx +8 -5
  83. package/src/views/settings/SessionPolicyCard.tsx +40 -0
  84. package/src/views/settings/useSecuritySettings.ts +10 -0
  85. package/src/views/users/UserDetailDrawer.tsx +31 -5
@@ -478,6 +478,9 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
478
478
  ]
479
479
  : []
480
480
 
481
+ const ariaSortFor = (key: MediaSortKey): 'ascending' | 'descending' | 'none' =>
482
+ sortConfig?.key === key ? (sortConfig.direction === 'asc' ? 'ascending' : 'descending') : 'none'
483
+
481
484
  function SortHeader({ label, sortKey }: { label: string; sortKey: MediaSortKey }) {
482
485
  const active = sortConfig?.key === sortKey
483
486
  return (
@@ -530,8 +533,9 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
530
533
  onClick={() => setSidebarOpen((prev) => !prev)}
531
534
  className="hover:bg-muted rounded-lg p-1.5 transition-colors"
532
535
  title={sidebarOpen ? 'Hide folders' : 'Show folders'}
536
+ aria-label={sidebarOpen ? 'Hide folders' : 'Show folders'}
533
537
  >
534
- <FolderInput className="text-muted-foreground h-5 w-5" />
538
+ <FolderInput className="text-muted-foreground h-5 w-5" aria-hidden />
535
539
  </button>
536
540
  <div>
537
541
  <h1 className="text-foreground mb-1">Media Library</h1>
@@ -641,10 +645,14 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
641
645
  <div className="flex items-center justify-between p-3">
642
646
  <div className="flex flex-1 items-center gap-3">
643
647
  <div className="relative max-w-md flex-1">
644
- <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
648
+ <Search
649
+ className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
650
+ aria-hidden
651
+ />
645
652
  <input
646
- type="text"
653
+ type="search"
647
654
  placeholder="Search media..."
655
+ aria-label="Search media"
648
656
  value={searchQuery}
649
657
  onChange={(e) => setSearchQuery(e.target.value)}
650
658
  className="border-input focus:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus:ring-2 focus:outline-none"
@@ -653,6 +661,7 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
653
661
  <select
654
662
  value={filterType}
655
663
  onChange={(e) => setFilterType(e.target.value)}
664
+ aria-label="Filter by type"
656
665
  className="border-input focus:ring-ring rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
657
666
  >
658
667
  <option value="all">All Types</option>
@@ -661,18 +670,28 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
661
670
  <option value="document">Documents</option>
662
671
  </select>
663
672
  </div>
664
- <div className="bg-muted flex items-center gap-1 rounded-lg p-1">
673
+ <div
674
+ className="bg-muted flex items-center gap-1 rounded-lg p-1"
675
+ role="group"
676
+ aria-label="Media view"
677
+ >
665
678
  <button
679
+ type="button"
666
680
  onClick={() => setViewMode('grid')}
681
+ aria-label="Grid view"
682
+ aria-pressed={viewMode === 'grid'}
667
683
  className={`rounded p-1.5 transition-colors ${viewMode === 'grid' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
668
684
  >
669
- <Grid3x3 className="h-4 w-4" />
685
+ <Grid3x3 className="h-4 w-4" aria-hidden />
670
686
  </button>
671
687
  <button
688
+ type="button"
672
689
  onClick={() => setViewMode('list')}
690
+ aria-label="List view"
691
+ aria-pressed={viewMode === 'list'}
673
692
  className={`rounded p-1.5 transition-colors ${viewMode === 'list' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
674
693
  >
675
- <List className="h-4 w-4" />
694
+ <List className="h-4 w-4" aria-hidden />
676
695
  </button>
677
696
  </div>
678
697
  </div>
@@ -838,16 +857,16 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
838
857
  />
839
858
  </th>
840
859
  <th className="w-6 px-1 py-2"></th>
841
- <th className="px-3 py-2 text-left">
860
+ <th className="px-3 py-2 text-left" aria-sort={ariaSortFor('name')}>
842
861
  <SortHeader label="Name" sortKey="name" />
843
862
  </th>
844
- <th className="px-3 py-2 text-left">
863
+ <th className="px-3 py-2 text-left" aria-sort={ariaSortFor('type')}>
845
864
  <SortHeader label="Type" sortKey="type" />
846
865
  </th>
847
- <th className="px-3 py-2 text-left">
866
+ <th className="px-3 py-2 text-left" aria-sort={ariaSortFor('size')}>
848
867
  <SortHeader label="Size" sortKey="size" />
849
868
  </th>
850
- <th className="px-3 py-2 text-left">
869
+ <th className="px-3 py-2 text-left" aria-sort={ariaSortFor('date')}>
851
870
  <SortHeader label="Uploaded" sortKey="date" />
852
871
  </th>
853
872
  <th className="text-foreground px-3 py-2 text-left text-xs font-medium">
@@ -1016,9 +1035,11 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
1016
1035
  {activeItem.url}
1017
1036
  </code>
1018
1037
  <button
1038
+ type="button"
1019
1039
  onClick={handleCopyUrl}
1020
1040
  className="hover:bg-muted shrink-0 rounded p-1.5 transition-colors"
1021
1041
  title="Copy URL"
1042
+ aria-label="Copy URL"
1022
1043
  >
1023
1044
  <Copy className="text-muted-foreground h-3.5 w-3.5" />
1024
1045
  </button>
@@ -1278,16 +1299,18 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
1278
1299
  type="button"
1279
1300
  className="hover:bg-muted rounded-lg p-2 transition-colors"
1280
1301
  title="Download"
1302
+ aria-label="Download"
1281
1303
  >
1282
- <Download className="text-muted-foreground h-4 w-4" />
1304
+ <Download className="text-muted-foreground h-4 w-4" aria-hidden />
1283
1305
  </button>
1284
1306
  <button
1285
1307
  type="button"
1286
1308
  onClick={() => deleteMedia(activeItem.id)}
1287
1309
  className="hover:bg-muted rounded-lg p-2 transition-colors"
1288
1310
  title="Delete"
1311
+ aria-label="Delete"
1289
1312
  >
1290
- <Trash2 className="text-destructive h-4 w-4" />
1313
+ <Trash2 className="text-destructive h-4 w-4" aria-hidden />
1291
1314
  </button>
1292
1315
  </div>
1293
1316
  </div>
@@ -176,6 +176,10 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
176
176
  [sortBy, dir],
177
177
  )
178
178
 
179
+ // `aria-sort` belongs on the column header cell (`<th>`), not the button.
180
+ const ariaSortFor = (field: SortBy): 'ascending' | 'descending' | 'none' =>
181
+ sortBy === field ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
182
+
179
183
  const clearFilters = () => {
180
184
  setSearch('')
181
185
  setStatus('all')
@@ -369,7 +373,7 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
369
373
  className="border-border accent-primary rounded"
370
374
  />
371
375
  </th>
372
- <th scope="col" className="px-3 py-2.5 text-left">
376
+ <th scope="col" aria-sort={ariaSortFor('title')} className="px-3 py-2.5 text-left">
373
377
  <SortHeader
374
378
  label="Title"
375
379
  field="title"
@@ -378,7 +382,7 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
378
382
  onSort={handleSort}
379
383
  />
380
384
  </th>
381
- <th scope="col" className="px-3 py-2.5 text-left">
385
+ <th scope="col" aria-sort={ariaSortFor('type')} className="px-3 py-2.5 text-left">
382
386
  <SortHeader
383
387
  label="Type"
384
388
  field="type"
@@ -387,7 +391,7 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
387
391
  onSort={handleSort}
388
392
  />
389
393
  </th>
390
- <th scope="col" className="px-3 py-2.5 text-left">
394
+ <th scope="col" aria-sort={ariaSortFor('author')} className="px-3 py-2.5 text-left">
391
395
  <SortHeader
392
396
  label="Author"
393
397
  field="author"
@@ -396,7 +400,7 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
396
400
  onSort={handleSort}
397
401
  />
398
402
  </th>
399
- <th scope="col" className="px-3 py-2.5 text-left">
403
+ <th scope="col" aria-sort={ariaSortFor('status')} className="px-3 py-2.5 text-left">
400
404
  <SortHeader
401
405
  label="Status"
402
406
  field="status"
@@ -405,7 +409,7 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
405
409
  onSort={handleSort}
406
410
  />
407
411
  </th>
408
- <th scope="col" className="px-3 py-2.5 text-left">
412
+ <th scope="col" aria-sort={ariaSortFor('date')} className="px-3 py-2.5 text-left">
409
413
  <SortHeader
410
414
  label="Date"
411
415
  field="date"
@@ -414,7 +418,7 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
414
418
  onSort={handleSort}
415
419
  />
416
420
  </th>
417
- <th scope="col" className="px-3 py-2.5 text-left">
421
+ <th scope="col" aria-sort={ariaSortFor('seo')} className="px-3 py-2.5 text-left">
418
422
  <SortHeader
419
423
  label="SEO"
420
424
  field="seo"
@@ -523,12 +527,10 @@ function SortHeader({
523
527
  onSort: (f: SortBy) => void
524
528
  }) {
525
529
  const active = current === field
526
- const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
527
530
  return (
528
531
  <button
529
532
  type="button"
530
533
  onClick={() => onSort(field)}
531
- aria-sort={ariaSort}
532
534
  className={`hover:text-foreground inline-flex items-center gap-1 text-xs font-medium tracking-wider uppercase transition-colors ${
533
535
  active ? 'text-foreground' : 'text-muted-foreground'
534
536
  }`}
@@ -599,14 +601,14 @@ function TabPill({
599
601
  onClick={onClick}
600
602
  className={`relative inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
601
603
  active
602
- ? 'bg-primary/10 text-primary'
603
- : 'text-muted-foreground hover:text-foreground hover:bg-accent'
604
+ ? 'bg-secondary text-primary'
605
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
604
606
  }`}
605
607
  >
606
608
  <span>{label}</span>
607
609
  <span
608
610
  className={`rounded-full px-1.5 text-xs tabular-nums ${
609
- active ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'
611
+ active ? 'bg-card text-primary' : 'bg-muted text-muted-foreground'
610
612
  }`}
611
613
  >
612
614
  {count}
@@ -100,6 +100,9 @@ export function Redirects({ onNavigate }: RedirectsProps) {
100
100
  }
101
101
  }
102
102
 
103
+ const ariaSortFor = (key: RedirectSortKey): 'ascending' | 'descending' | 'none' =>
104
+ sortConfig?.key === key ? (sortConfig.direction === 'asc' ? 'ascending' : 'descending') : 'none'
105
+
103
106
  function SortHeader({ label, sortKey }: { label: string; sortKey: RedirectSortKey }) {
104
107
  const active = sortConfig?.key === sortKey
105
108
  return (
@@ -200,19 +203,19 @@ export function Redirects({ onNavigate }: RedirectsProps) {
200
203
  <table className="w-full">
201
204
  <thead className="border-border bg-muted border-b">
202
205
  <tr>
203
- <th className="px-4 py-2 text-left">
206
+ <th className="px-4 py-2 text-left" aria-sort={ariaSortFor('from')}>
204
207
  <SortHeader label="Source URL" sortKey="from" />
205
208
  </th>
206
- <th className="px-4 py-2 text-left">
209
+ <th className="px-4 py-2 text-left" aria-sort={ariaSortFor('to')}>
207
210
  <SortHeader label="Destination URL" sortKey="to" />
208
211
  </th>
209
- <th className="px-4 py-2 text-left">
212
+ <th className="px-4 py-2 text-left" aria-sort={ariaSortFor('type')}>
210
213
  <SortHeader label="Type" sortKey="type" />
211
214
  </th>
212
- <th className="px-4 py-2 text-left">
215
+ <th className="px-4 py-2 text-left" aria-sort={ariaSortFor('hits')}>
213
216
  <SortHeader label="Hits" sortKey="hits" />
214
217
  </th>
215
- <th className="px-4 py-2 text-left">
218
+ <th className="px-4 py-2 text-left" aria-sort={ariaSortFor('active')}>
216
219
  <SortHeader label="Status" sortKey="active" />
217
220
  </th>
218
221
  <th className="text-foreground px-4 py-2 text-left text-xs font-medium">
@@ -38,6 +38,8 @@ export function SessionPolicyCard({
38
38
  const headingId = useId()
39
39
  const maxId = useId()
40
40
  const timeoutId = useId()
41
+ const thresholdId = useId()
42
+ const minutesId = useId()
41
43
  const [confirmRevoke, setConfirmRevoke] = useState(false)
42
44
 
43
45
  return (
@@ -101,6 +103,44 @@ export function SessionPolicyCard({
101
103
  ))}
102
104
  </select>
103
105
  </div>
106
+
107
+ <div className="flex items-center justify-between gap-4">
108
+ <label htmlFor={thresholdId} className="flex-1">
109
+ <span className="text-foreground text-base font-medium">Failed-login lockout</span>
110
+ <p className="text-muted-foreground mt-0.5 text-sm">
111
+ Lock the account after this many consecutive wrong passwords.
112
+ </p>
113
+ </label>
114
+ <input
115
+ id={thresholdId}
116
+ type="number"
117
+ min={3}
118
+ max={100}
119
+ value={form.lockoutThreshold}
120
+ disabled={!canEdit}
121
+ onChange={(e) => setField('lockoutThreshold', Number(e.target.value))}
122
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-20 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
123
+ />
124
+ </div>
125
+
126
+ <div className="flex items-center justify-between gap-4">
127
+ <label htmlFor={minutesId} className="flex-1">
128
+ <span className="text-foreground text-base font-medium">Lockout duration</span>
129
+ <p className="text-muted-foreground mt-0.5 text-sm">
130
+ Minutes the account stays locked before a correct password can unlock it.
131
+ </p>
132
+ </label>
133
+ <input
134
+ id={minutesId}
135
+ type="number"
136
+ min={1}
137
+ max={1440}
138
+ value={form.lockoutMinutes}
139
+ disabled={!canEdit}
140
+ onChange={(e) => setField('lockoutMinutes', Number(e.target.value))}
141
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-20 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
142
+ />
143
+ </div>
104
144
  </div>
105
145
 
106
146
  <div className="border-border mt-4 space-y-2 border-t pt-4">
@@ -19,6 +19,8 @@ export interface SecurityForm {
19
19
  /** Raw textarea contents — one IP/CIDR entry per line. */
20
20
  ipAllowlistText: string
21
21
  auditLoggingEnabled: boolean
22
+ lockoutThreshold: number
23
+ lockoutMinutes: number
22
24
  }
23
25
 
24
26
  type SaveState = 'idle' | 'saving' | 'success' | 'error'
@@ -31,6 +33,8 @@ const EMPTY_FORM: SecurityForm = {
31
33
  idleLogoutEnabled: false,
32
34
  ipAllowlistText: '',
33
35
  auditLoggingEnabled: true,
36
+ lockoutThreshold: 10,
37
+ lockoutMinutes: 15,
34
38
  }
35
39
 
36
40
  /** Split the textarea into trimmed, de-duplicated entries (one per line). */
@@ -52,6 +56,8 @@ function formFromSettings(s: SecuritySettings): SecurityForm {
52
56
  idleLogoutEnabled: s.session.idleLogoutEnabled ?? false,
53
57
  ipAllowlistText: (s.access?.ipAllowlist ?? []).join('\n'),
54
58
  auditLoggingEnabled: s.access?.auditLoggingEnabled ?? true,
59
+ lockoutThreshold: s.lockout?.threshold ?? 10,
60
+ lockoutMinutes: s.lockout?.minutes ?? 15,
55
61
  }
56
62
  }
57
63
 
@@ -67,6 +73,10 @@ function formToSettings(f: SecurityForm): SecuritySettings {
67
73
  ipAllowlist: parseAllowlistText(f.ipAllowlistText),
68
74
  auditLoggingEnabled: f.auditLoggingEnabled,
69
75
  },
76
+ lockout: {
77
+ threshold: f.lockoutThreshold,
78
+ minutes: f.lockoutMinutes,
79
+ },
70
80
  }
71
81
  }
72
82
 
@@ -72,6 +72,7 @@ interface UserDetail {
72
72
  invitedBy: { id: string; name: string; email: string } | null
73
73
  externalDomain: boolean
74
74
  contentOwnedCount: number
75
+ liveApiKeyCount: number
75
76
  isCurrentUser: boolean
76
77
  }
77
78
 
@@ -505,6 +506,18 @@ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: User
505
506
  label="Content owned"
506
507
  value={`${u.contentOwnedCount} ${u.contentOwnedCount === 1 ? 'item' : 'items'}`}
507
508
  />
509
+ <Fact
510
+ label="Live API keys"
511
+ value={
512
+ u.liveApiKeyCount > 0 ? (
513
+ <span className="text-warning">
514
+ {u.liveApiKeyCount} {u.liveApiKeyCount === 1 ? 'key' : 'keys'}
515
+ </span>
516
+ ) : (
517
+ 'None'
518
+ )
519
+ }
520
+ />
508
521
  <Fact
509
522
  label="Email verified"
510
523
  value={u.emailVerified ? 'Yes' : <span className="text-warning">No</span>}
@@ -541,7 +554,13 @@ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: User
541
554
  {/* Security ----------------------------------------------------- */}
542
555
  <Tabs.Content value="security" tabIndex={-1} className="focus:outline-none">
543
556
  <SecurityRow
544
- icon={u.mfaEnabled ? <ShieldCheck size={16} /> : <ShieldAlert size={16} />}
557
+ icon={
558
+ u.mfaEnabled ? (
559
+ <ShieldCheck size={16} aria-hidden />
560
+ ) : (
561
+ <ShieldAlert size={16} aria-hidden />
562
+ )
563
+ }
545
564
  title="Multi-factor authentication"
546
565
  description={
547
566
  u.mfaEnabled
@@ -600,7 +619,7 @@ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: User
600
619
  }
601
620
  />
602
621
  <SecurityRow
603
- icon={<KeyRound size={16} />}
622
+ icon={<KeyRound size={16} aria-hidden />}
604
623
  title="Password"
605
624
  description={
606
625
  u.ssoOnly
@@ -660,13 +679,19 @@ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: User
660
679
  }
661
680
  />
662
681
  <SecurityRow
663
- icon={<ShieldAlert size={16} />}
682
+ icon={<ShieldAlert size={16} aria-hidden />}
664
683
  title="Failed sign-ins"
665
684
  description={`${u.failedLoginCount} failed ${u.failedLoginCount === 1 ? 'attempt' : 'attempts'} on record.`}
666
685
  action={null}
667
686
  />
668
687
  <SecurityRow
669
- icon={u.status === 'locked' ? <Lock size={16} /> : <Unlock size={16} />}
688
+ icon={
689
+ u.status === 'locked' ? (
690
+ <Lock size={16} aria-hidden />
691
+ ) : (
692
+ <Unlock size={16} aria-hidden />
693
+ )
694
+ }
670
695
  title="Account lock"
671
696
  description={
672
697
  u.status === 'locked' ? 'Locked by a security policy or an admin.' : 'Not locked.'
@@ -717,7 +742,7 @@ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: User
717
742
  }
718
743
  />
719
744
  <SecurityRow
720
- icon={<Ban size={16} />}
745
+ icon={<Ban size={16} aria-hidden />}
721
746
  title="Account access"
722
747
  description={
723
748
  u.status === 'suspended'
@@ -850,6 +875,7 @@ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: User
850
875
  <button
851
876
  type="button"
852
877
  disabled={pending === `revoke-${s.id}`}
878
+ aria-label={`Revoke session on ${s.deviceLabel}`}
853
879
  onClick={() =>
854
880
  runAction(
855
881
  `revoke-${s.id}`,