@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.
- package/CHANGELOG.md +11 -0
- package/design-system/tokens.css +38 -6
- package/design-system/tokens.json +36 -6
- package/design-system/tokens.scss +6 -6
- package/dist/__tests__/components/author-avatar.test.js +18 -1
- package/dist/__tests__/components/author-avatar.test.js.map +1 -1
- package/dist/__tests__/lib/utils.test.js +2 -1
- package/dist/__tests__/lib/utils.test.js.map +1 -1
- package/dist/__tests__/styles/tokens-drift.test.js +32 -0
- package/dist/__tests__/styles/tokens-drift.test.js.map +1 -1
- package/dist/__tests__/views/security-settings.render.test.js +1 -0
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/trash.render.test.js +33 -0
- package/dist/__tests__/views/trash.render.test.js.map +1 -1
- package/dist/__tests__/views/user-detail-drawer.render.test.js +1 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -1
- package/dist/__tests__/views/users-selected-deeplink.render.test.js +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -1
- package/dist/components/LivePreview.d.ts.map +1 -1
- package/dist/components/LivePreview.js +2 -1
- package/dist/components/LivePreview.js.map +1 -1
- package/dist/components/PresenceIndicator.d.ts.map +1 -1
- package/dist/components/PresenceIndicator.js +3 -1
- package/dist/components/PresenceIndicator.js.map +1 -1
- package/dist/components/ui/AuthorAvatar.d.ts +12 -0
- package/dist/components/ui/AuthorAvatar.d.ts.map +1 -1
- package/dist/components/ui/AuthorAvatar.js +8 -8
- package/dist/components/ui/AuthorAvatar.js.map +1 -1
- package/dist/components/ui/DataTable.d.ts.map +1 -1
- package/dist/components/ui/DataTable.js +7 -1
- package/dist/components/ui/DataTable.js.map +1 -1
- package/dist/layout/Sidebar.js +1 -1
- package/dist/lib/sentry.d.ts.map +1 -1
- package/dist/lib/sentry.js +11 -0
- package/dist/lib/sentry.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +6 -2
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +4 -5
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +6 -5
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +6 -5
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/Redirects.d.ts.map +1 -1
- package/dist/views/Redirects.js +2 -1
- package/dist/views/Redirects.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +3 -1
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +2 -0
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +8 -0
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
- package/dist/views/users/UserDetailDrawer.js +5 -5
- package/dist/views/users/UserDetailDrawer.js.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/components/author-avatar.test.tsx +22 -1
- package/src/__tests__/lib/utils.test.ts +2 -1
- package/src/__tests__/styles/tokens-drift.test.ts +46 -0
- package/src/__tests__/views/security-settings.render.test.tsx +1 -0
- package/src/__tests__/views/trash.render.test.tsx +38 -0
- package/src/__tests__/views/user-detail-drawer.render.test.tsx +1 -0
- package/src/__tests__/views/users-selected-deeplink.render.test.tsx +1 -0
- package/src/components/LivePreview.tsx +2 -1
- package/src/components/PresenceIndicator.tsx +3 -1
- package/src/components/ui/AuthorAvatar.tsx +8 -8
- package/src/components/ui/DataTable.tsx +13 -1
- package/src/layout/Sidebar.tsx +1 -1
- package/src/lib/sentry.ts +11 -0
- package/src/styles/prenta-tokens.css +38 -6
- package/src/styles/theme.css +4 -2
- package/src/views/CollectionList.tsx +8 -4
- package/src/views/Dashboard.tsx +5 -11
- package/src/views/MediaBrowser.tsx +35 -12
- package/src/views/Posts/PostsListView.tsx +13 -11
- package/src/views/Redirects.tsx +8 -5
- package/src/views/settings/SessionPolicyCard.tsx +40 -0
- package/src/views/settings/useSecuritySettings.ts +10 -0
- 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
|
|
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="
|
|
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
|
|
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-
|
|
603
|
-
: 'text-muted-foreground hover:
|
|
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-
|
|
611
|
+
active ? 'bg-card text-primary' : 'bg-muted text-muted-foreground'
|
|
610
612
|
}`}
|
|
611
613
|
>
|
|
612
614
|
{count}
|
package/src/views/Redirects.tsx
CHANGED
|
@@ -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={
|
|
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={
|
|
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}`,
|