@prenta/admin 1.8.0 → 1.9.1
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 +17 -0
- package/design-system/tokens.css +38 -6
- package/design-system/tokens.json +36 -6
- package/design-system/tokens.scss +6 -6
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +14 -4
- package/dist/AdminRoot.js.map +1 -1
- 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/logout-redirect.test.d.ts +2 -0
- package/dist/__tests__/lib/logout-redirect.test.d.ts.map +1 -0
- package/dist/__tests__/lib/logout-redirect.test.js +29 -0
- package/dist/__tests__/lib/logout-redirect.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service.test.js +21 -6
- package/dist/__tests__/lib/pages-service.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/login-root.render.test.js +6 -0
- package/dist/__tests__/views/login-root.render.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/logout-redirect.d.ts +19 -0
- package/dist/lib/logout-redirect.d.ts.map +1 -0
- package/dist/lib/logout-redirect.js +20 -0
- package/dist/lib/logout-redirect.js.map +1 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +2 -16
- package/dist/lib/pages-service.js.map +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/Login.d.ts.map +1 -1
- package/dist/views/Login.js +4 -2
- package/dist/views/Login.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/AdminRoot.tsx +13 -4
- package/src/__tests__/components/author-avatar.test.tsx +22 -1
- package/src/__tests__/lib/logout-redirect.test.ts +35 -0
- package/src/__tests__/lib/pages-service.test.ts +22 -8
- package/src/__tests__/lib/utils.test.ts +2 -1
- package/src/__tests__/styles/tokens-drift.test.ts +46 -0
- package/src/__tests__/views/login-root.render.test.tsx +7 -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/logout-redirect.ts +33 -0
- package/src/lib/pages-service.ts +1 -15
- 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/Login.tsx +15 -1
- 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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-logout destination. Split-login installs must leave the admin mount
|
|
3
|
+
* (it renders a 404 lookalike for signed-out visitors) and land on the
|
|
4
|
+
* hidden login path. Inline-login installs stay on the admin mount and just
|
|
5
|
+
* show the login form. `?loggedOut=1` lets the login screen confirm the
|
|
6
|
+
* sign-out instead of dead-ending.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const LOGGED_OUT_QUERY = 'loggedOut'
|
|
10
|
+
|
|
11
|
+
export interface LogoutRedirect {
|
|
12
|
+
/** Full-page navigation to a different mount (split login). */
|
|
13
|
+
kind: 'external' | 'internal'
|
|
14
|
+
href: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveLogoutRedirect(opts: {
|
|
18
|
+
loginPath?: string
|
|
19
|
+
basePath?: string
|
|
20
|
+
}): LogoutRedirect {
|
|
21
|
+
const basePath = opts.basePath ?? '/admin'
|
|
22
|
+
const loginPath = opts.loginPath
|
|
23
|
+
if (loginPath && loginPath !== basePath) {
|
|
24
|
+
return { kind: 'external', href: `${loginPath}?${LOGGED_OUT_QUERY}=1` }
|
|
25
|
+
}
|
|
26
|
+
return { kind: 'internal', href: `/?${LOGGED_OUT_QUERY}=1` }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hasLoggedOutQuery(
|
|
30
|
+
search: string = typeof window !== 'undefined' ? window.location.search : '',
|
|
31
|
+
): boolean {
|
|
32
|
+
return new URLSearchParams(search).get(LOGGED_OUT_QUERY) === '1'
|
|
33
|
+
}
|
package/src/lib/pages-service.ts
CHANGED
|
@@ -307,21 +307,7 @@ export function applyPagesQuery(all: Page[], query: PagesQuery): Page[] {
|
|
|
307
307
|
if (query.authorId && query.authorId !== 'all' && p.authorId !== query.authorId) return false
|
|
308
308
|
// Tag chips use OR semantics: keep a page if it has any selected tag.
|
|
309
309
|
if (tagFilter && !p.tags.some((t) => tagFilter.has(t))) return false
|
|
310
|
-
if (search)
|
|
311
|
-
const haystack = [
|
|
312
|
-
p.title,
|
|
313
|
-
p.path,
|
|
314
|
-
p.slug,
|
|
315
|
-
p.authorName,
|
|
316
|
-
p.status,
|
|
317
|
-
p.seoTitle ?? '',
|
|
318
|
-
p.seoDescription ?? '',
|
|
319
|
-
p.tags.join(' '),
|
|
320
|
-
]
|
|
321
|
-
.join(' ')
|
|
322
|
-
.toLowerCase()
|
|
323
|
-
if (!haystack.includes(search)) return false
|
|
324
|
-
}
|
|
310
|
+
if (search && !p.title.toLowerCase().includes(search)) return false
|
|
325
311
|
return true
|
|
326
312
|
})
|
|
327
313
|
|
package/src/lib/sentry.ts
CHANGED
|
@@ -10,6 +10,17 @@ export function initAdminSentry(dsn: string | undefined): void {
|
|
|
10
10
|
dsn: dsn.trim(),
|
|
11
11
|
environment: process.env.NODE_ENV ?? 'development',
|
|
12
12
|
tracesSampleRate: 0,
|
|
13
|
+
beforeSend(event) {
|
|
14
|
+
const str = JSON.stringify(event)
|
|
15
|
+
if (/\b(sk-|act_|eyJ)[A-Za-z0-9._-]{12,}/.test(str)) {
|
|
16
|
+
return {
|
|
17
|
+
...event,
|
|
18
|
+
extra: { redacted: true },
|
|
19
|
+
breadcrumbs: [],
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return event
|
|
23
|
+
},
|
|
13
24
|
})
|
|
14
25
|
initialized = true
|
|
15
26
|
}
|
|
@@ -36,13 +36,35 @@
|
|
|
36
36
|
--acc-m: #c4b5fd; /* Accent mid (focus ring) */
|
|
37
37
|
|
|
38
38
|
/* Semantic */
|
|
39
|
-
|
|
39
|
+
/*
|
|
40
|
+
* Success text/fill. The handoff value #059669 is 3.57:1 on --suc-l at
|
|
41
|
+
* 11.5px (axe on Pages Published chips). Emerald-700 meets 4.5:1 on that
|
|
42
|
+
* tint, on bg-success/10 over a card, and with white. Handoff value: #059669
|
|
43
|
+
* — deliberately overridden.
|
|
44
|
+
*/
|
|
45
|
+
--suc: #047857;
|
|
40
46
|
--suc-l: #ecfdf5;
|
|
41
|
-
|
|
47
|
+
/*
|
|
48
|
+
* Warning text/fill. The handoff value #D97706 is 2.85:1 on --warn-l.
|
|
49
|
+
* Amber-700 #B45309 is 4.39:1 on bg-warning/10 over a card (axe on dashboard
|
|
50
|
+
* Scheduled chips). Amber-800 meets 4.5:1 on that mixed tint and with white.
|
|
51
|
+
* Handoff value: #d97706 — deliberately overridden.
|
|
52
|
+
*/
|
|
53
|
+
--warn: #92400e;
|
|
42
54
|
--warn-l: #fffbeb;
|
|
43
|
-
|
|
55
|
+
/*
|
|
56
|
+
* Error text/fill. The handoff value #DC2626 is 4.41:1 on --err-l — just
|
|
57
|
+
* under AA for 11.5px StatusBadge text. Red-700 meets 4.5:1 on that tint and
|
|
58
|
+
* with white. Handoff value: #dc2626 — deliberately overridden.
|
|
59
|
+
*/
|
|
60
|
+
--err: #b91c1c;
|
|
44
61
|
--err-l: #fef2f2;
|
|
45
|
-
|
|
62
|
+
/*
|
|
63
|
+
* Info text/fill. The handoff value #0891B2 is 3.31:1 on --info-l. Cyan-700
|
|
64
|
+
* meets 4.5:1 on that tint and with white. Handoff value: #0891b2 —
|
|
65
|
+
* deliberately overridden.
|
|
66
|
+
*/
|
|
67
|
+
--info: #0e7490;
|
|
46
68
|
--info-l: #e0f7fa;
|
|
47
69
|
|
|
48
70
|
/* Tag palette */
|
|
@@ -56,9 +78,19 @@
|
|
|
56
78
|
--tag-red-soft: #ffe4e6;
|
|
57
79
|
--tag-blue: #1d4ed8;
|
|
58
80
|
--tag-blue-soft: #dbeafe;
|
|
59
|
-
|
|
81
|
+
/*
|
|
82
|
+
* Pink type pill text. The handoff value #DB2777 is 3.91:1 on
|
|
83
|
+
* --tag-pink-soft at 11.5px. Pink-700 meets 4.5:1. Handoff value: #db2777 —
|
|
84
|
+
* deliberately overridden.
|
|
85
|
+
*/
|
|
86
|
+
--tag-pink: #be185d;
|
|
60
87
|
--tag-pink-soft: #fce7f3;
|
|
61
|
-
|
|
88
|
+
/*
|
|
89
|
+
* Gray type pill text. The handoff value #64748B is 4.34:1 on
|
|
90
|
+
* --tag-gray-soft. Slate-600 meets 4.5:1. Handoff value: #64748b —
|
|
91
|
+
* deliberately overridden.
|
|
92
|
+
*/
|
|
93
|
+
--tag-gray: #475569;
|
|
62
94
|
--tag-gray-soft: #f1f5f9;
|
|
63
95
|
|
|
64
96
|
/* AI */
|
package/src/styles/theme.css
CHANGED
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
--success: var(--suc);
|
|
35
35
|
--success-foreground: #ffffff;
|
|
36
36
|
--warning: var(--warn);
|
|
37
|
-
|
|
37
|
+
/* Solid `bg-warning` fills (buttons, chips). Light --warn is amber-700 so
|
|
38
|
+
* `text-warning` on tint passes AA; the paired foreground must be light. */
|
|
39
|
+
--warning-foreground: #ffffff;
|
|
38
40
|
--info: var(--info);
|
|
39
41
|
--info-foreground: #ffffff;
|
|
40
42
|
--brand: var(--acc);
|
|
@@ -84,7 +86,7 @@
|
|
|
84
86
|
.prenta-admin.dark {
|
|
85
87
|
--primary-foreground: #0d0c18;
|
|
86
88
|
--secondary-foreground: var(--txt);
|
|
87
|
-
--warning-foreground:
|
|
89
|
+
--warning-foreground: #0d0c18;
|
|
88
90
|
--brand-foreground: #0d0c18;
|
|
89
91
|
--success-foreground: #0d0c18;
|
|
90
92
|
--info-foreground: #0d0c18;
|
|
@@ -256,7 +256,8 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
256
256
|
const toggleSelect = (id: string) =>
|
|
257
257
|
setSelected((prev) => {
|
|
258
258
|
const s = new Set(prev)
|
|
259
|
-
s.has(id)
|
|
259
|
+
if (s.has(id)) s.delete(id)
|
|
260
|
+
else s.add(id)
|
|
260
261
|
return s
|
|
261
262
|
})
|
|
262
263
|
|
|
@@ -282,6 +283,9 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
282
283
|
refetch()
|
|
283
284
|
}
|
|
284
285
|
|
|
286
|
+
const ariaSortFor = (field: SortField): 'ascending' | 'descending' | 'none' =>
|
|
287
|
+
sort === field ? (order === 'asc' ? 'ascending' : 'descending') : 'none'
|
|
288
|
+
|
|
285
289
|
const SortCol = ({ field, children }: { field: SortField; children: string }) => (
|
|
286
290
|
<button
|
|
287
291
|
type="button"
|
|
@@ -490,7 +494,7 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
490
494
|
className="rounded"
|
|
491
495
|
/>
|
|
492
496
|
</th>
|
|
493
|
-
<th className="px-3 py-2 text-left">
|
|
497
|
+
<th className="px-3 py-2 text-left" aria-sort={ariaSortFor('title')}>
|
|
494
498
|
<SortCol field="title">Title</SortCol>
|
|
495
499
|
</th>
|
|
496
500
|
{facets.map((facet) => (
|
|
@@ -502,10 +506,10 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
502
506
|
{facet.label}
|
|
503
507
|
</th>
|
|
504
508
|
))}
|
|
505
|
-
<th className="px-3 py-2 text-left">
|
|
509
|
+
<th className="px-3 py-2 text-left" aria-sort={ariaSortFor('status')}>
|
|
506
510
|
<SortCol field="status">Status</SortCol>
|
|
507
511
|
</th>
|
|
508
|
-
<th className="px-3 py-2 text-left">
|
|
512
|
+
<th className="px-3 py-2 text-left" aria-sort={ariaSortFor('updatedAt')}>
|
|
509
513
|
<SortCol field="updatedAt">Updated</SortCol>
|
|
510
514
|
</th>
|
|
511
515
|
<th
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
} from 'lucide-react'
|
|
43
43
|
import { useApiData } from '../lib/useApiData.js'
|
|
44
44
|
import { editPathForDoc } from '../lib/collection-routes.js'
|
|
45
|
-
import {
|
|
45
|
+
import { AuthorAvatar } from '../components/ui/AuthorAvatar.js'
|
|
46
46
|
import { PanelSection, PANEL_LIST_ROW_CLASS } from '../components/ui/PanelSection.js'
|
|
47
47
|
import { OnboardingBlock, shouldShowOnboarding } from './dashboard/OnboardingBlock.js'
|
|
48
48
|
import type { OnboardingSignals } from './dashboard/OnboardingBlock.js'
|
|
@@ -52,6 +52,7 @@ interface DashboardStats {
|
|
|
52
52
|
totalMedia: number
|
|
53
53
|
totalUsers: number
|
|
54
54
|
formCount: number
|
|
55
|
+
formSubmissionCount?: number
|
|
55
56
|
avgSeoScore: number
|
|
56
57
|
webhookCount: number
|
|
57
58
|
webhookActiveCount: number
|
|
@@ -340,8 +341,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
340
341
|
|
|
341
342
|
// `formCount` in /stats is the *submission* count; the number of
|
|
342
343
|
// forms is the count of `collection: forms` documents.
|
|
343
|
-
const formsCount = counts['forms'] ?? 0
|
|
344
|
-
const submissions = stats?.
|
|
344
|
+
const formsCount = stats?.formCount ?? counts['forms'] ?? 0
|
|
345
|
+
const submissions = stats?.formSubmissionCount ?? 0
|
|
345
346
|
cards.push({
|
|
346
347
|
label: 'Forms',
|
|
347
348
|
value: String(formsCount),
|
|
@@ -411,7 +412,6 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
411
412
|
...d,
|
|
412
413
|
typeLabel: col ? collectionLabel(col, false) : d.collection,
|
|
413
414
|
relTime: relativeTime(d.updatedAt),
|
|
414
|
-
avatar: authorAvatar(d.author),
|
|
415
415
|
statusInfo: statusBadge(d.status),
|
|
416
416
|
editPath: editPathForDoc(col, d.collection, d.id),
|
|
417
417
|
}
|
|
@@ -722,13 +722,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
|
|
|
722
722
|
className={PANEL_LIST_ROW_CLASS}
|
|
723
723
|
>
|
|
724
724
|
<div className="flex min-w-0 items-start gap-3">
|
|
725
|
-
<
|
|
726
|
-
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[11px] font-medium text-white"
|
|
727
|
-
style={{ background: it.avatar.color }}
|
|
728
|
-
aria-hidden
|
|
729
|
-
>
|
|
730
|
-
{it.avatar.initials}
|
|
731
|
-
</div>
|
|
725
|
+
<AuthorAvatar name={it.author} size={28} className="mt-0.5" />
|
|
732
726
|
<div className="min-w-0 flex-1">
|
|
733
727
|
<p className="text-foreground truncate text-sm leading-snug">
|
|
734
728
|
<span className="font-medium">
|
package/src/views/Login.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, type FormEvent } from 'react'
|
|
4
|
-
import { Eye, EyeOff, AlertTriangle, Loader2 } from 'lucide-react'
|
|
4
|
+
import { Eye, EyeOff, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'
|
|
5
5
|
import { PrentaBrandLogo } from '../assets/prenta-logo.js'
|
|
6
6
|
import { LogoPlaceholder } from '../components/LogoPlaceholder.js'
|
|
7
7
|
import { useTheme } from '../components/ThemeProvider.js'
|
|
8
8
|
import { cmsApi } from '../lib/api.js'
|
|
9
|
+
import { hasLoggedOutQuery } from '../lib/logout-redirect.js'
|
|
9
10
|
|
|
10
11
|
export interface CaptchaConfig {
|
|
11
12
|
provider: 'recaptcha' | 'turnstile' | 'none'
|
|
@@ -269,6 +270,7 @@ export function Login({
|
|
|
269
270
|
}
|
|
270
271
|
}, [adminBrand.primaryUrl, branding?.logo])
|
|
271
272
|
|
|
273
|
+
const loggedOut = hasLoggedOutQuery()
|
|
272
274
|
const canSubmit = email.trim() && password && !submitting
|
|
273
275
|
|
|
274
276
|
const handleSubmit = async (e: FormEvent) => {
|
|
@@ -489,6 +491,18 @@ export function Login({
|
|
|
489
491
|
onSubmit={handleSubmit}
|
|
490
492
|
className="border-border bg-card space-y-5 rounded-xl border p-6 shadow-sm"
|
|
491
493
|
>
|
|
494
|
+
{loggedOut && !error && (
|
|
495
|
+
<div
|
|
496
|
+
className="border-success/30 bg-success/10 flex items-start gap-3 rounded-lg border p-3"
|
|
497
|
+
role="status"
|
|
498
|
+
>
|
|
499
|
+
<CheckCircle2 className="text-success mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
|
|
500
|
+
<p className="text-success text-sm">
|
|
501
|
+
Logged out successfully. Sign in to continue.
|
|
502
|
+
</p>
|
|
503
|
+
</div>
|
|
504
|
+
)}
|
|
505
|
+
|
|
492
506
|
{error && (
|
|
493
507
|
<div className="border-destructive/30 bg-destructive/10 flex items-start gap-3 rounded-lg border p-3">
|
|
494
508
|
<AlertTriangle className="text-destructive mt-0.5 h-5 w-5 shrink-0" />
|
|
@@ -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
|
|