@prenta/admin 1.9.0 → 1.9.2
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 +12 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +14 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/assets/prenta-logo.test.d.ts +2 -0
- package/dist/__tests__/assets/prenta-logo.test.d.ts.map +1 -0
- package/dist/__tests__/assets/prenta-logo.test.js +26 -0
- package/dist/__tests__/assets/prenta-logo.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-brand.render.test.js +13 -0
- package/dist/__tests__/layout/sidebar-brand.render.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__/views/login-branding.render.test.js +2 -1
- package/dist/__tests__/views/login-branding.render.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/assets/prenta-logo.d.ts +15 -7
- package/dist/assets/prenta-logo.d.ts.map +1 -1
- package/dist/assets/prenta-logo.js +12 -4
- package/dist/assets/prenta-logo.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +2 -12
- package/dist/layout/Sidebar.js.map +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/views/Login.d.ts +1 -1
- package/dist/views/Login.d.ts.map +1 -1
- package/dist/views/Login.js +8 -5
- package/dist/views/Login.js.map +1 -1
- package/package.json +4 -4
- package/src/AdminRoot.tsx +13 -4
- package/src/__tests__/assets/prenta-logo.test.tsx +26 -0
- package/src/__tests__/layout/sidebar-brand.render.test.tsx +15 -0
- package/src/__tests__/lib/logout-redirect.test.ts +35 -0
- package/src/__tests__/lib/pages-service.test.ts +22 -8
- package/src/__tests__/views/login-branding.render.test.tsx +2 -1
- package/src/__tests__/views/login-root.render.test.tsx +7 -0
- package/src/assets/prenta-logo.tsx +47 -21
- package/src/layout/Sidebar.tsx +2 -27
- package/src/lib/logout-redirect.ts +33 -0
- package/src/lib/pages-service.ts +1 -15
- package/src/views/Login.tsx +20 -5
|
@@ -25,8 +25,8 @@ function page(partial: Partial<Page> & Pick<Page, 'id' | 'title'>): Page {
|
|
|
25
25
|
scheduledDate: null,
|
|
26
26
|
lastModified: partial.lastModified ?? '2026-05-01',
|
|
27
27
|
seoScore: partial.seoScore ?? null,
|
|
28
|
-
seoTitle: null,
|
|
29
|
-
seoDescription: null,
|
|
28
|
+
seoTitle: partial.seoTitle ?? null,
|
|
29
|
+
seoDescription: partial.seoDescription ?? null,
|
|
30
30
|
templateId: null,
|
|
31
31
|
isHomepage: partial.isHomepage ?? false,
|
|
32
32
|
isSystemPage: false,
|
|
@@ -106,13 +106,27 @@ describe('applyPagesQuery', () => {
|
|
|
106
106
|
expect(out.map((p) => p.id).sort()).toEqual(['1', '2', '3'])
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
-
it('searches
|
|
109
|
+
it('searches the page title only', () => {
|
|
110
110
|
expect(applyPagesQuery(PAGES, { search: 'seo' }).map((p) => p.id)).toEqual(['3'])
|
|
111
|
-
expect(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
expect(applyPagesQuery(PAGES, { search: 'about' }).map((p) => p.id)).toEqual(['1'])
|
|
112
|
+
// Tag slug "core" is on About + Services, but it is not a title match.
|
|
113
|
+
expect(applyPagesQuery(PAGES, { search: 'core' })).toEqual([])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does not match path, slug, author, tags, or SEO metadata', () => {
|
|
117
|
+
const marketing = page({
|
|
118
|
+
id: '5',
|
|
119
|
+
title: 'Digital Marketing',
|
|
120
|
+
path: '/services/seo-audit',
|
|
121
|
+
slug: 'seo-audit',
|
|
122
|
+
authorName: 'SEO Specialist',
|
|
123
|
+
tags: ['seo'],
|
|
124
|
+
seoTitle: 'Digital Marketing & SEO Services | Acme',
|
|
125
|
+
seoDescription: 'Full-service SEO, PPC, and content marketing.',
|
|
126
|
+
})
|
|
127
|
+
expect(applyPagesQuery([...PAGES, marketing], { search: 'seo' }).map((p) => p.id)).toEqual([
|
|
128
|
+
'3',
|
|
129
|
+
])
|
|
116
130
|
})
|
|
117
131
|
|
|
118
132
|
it('sorts by seo descending', () => {
|
|
@@ -32,8 +32,9 @@ describe('Login — logo resolution', () => {
|
|
|
32
32
|
render(<Login onLogin={onLogin} />)
|
|
33
33
|
// Bundled SVG exposes role="img" + aria-label="Prenta".
|
|
34
34
|
expect(screen.getByRole('img', { name: 'Prenta' })).toBeTruthy()
|
|
35
|
-
// No <img> logo element.
|
|
35
|
+
// No <img> logo element, and the wordmark already says Prenta — no extra headline.
|
|
36
36
|
expect(screen.queryByRole('img', { name: 'Prenta CMS' })).toBeNull()
|
|
37
|
+
expect(screen.queryByRole('heading', { name: 'Prenta CMS' })).toBeNull()
|
|
37
38
|
await waitFor(() => expect(cmsApiMock).toHaveBeenCalledWith('/public/brand'))
|
|
38
39
|
})
|
|
39
40
|
|
|
@@ -44,6 +44,13 @@ describe('LoginRoot', () => {
|
|
|
44
44
|
expect(screen.getByText('Sign in to your account')).toBeTruthy()
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
+
it('shows a logged-out confirmation when arriving with ?loggedOut=1', () => {
|
|
48
|
+
window.history.replaceState({}, '', '/securelogin?loggedOut=1')
|
|
49
|
+
render(<LoginRoot config={{}} session={null} onLogin={noopLogin} />)
|
|
50
|
+
expect(screen.getByRole('status').textContent).toMatch(/logged out successfully/i)
|
|
51
|
+
expect(screen.getByText('Sign in to your account')).toBeTruthy()
|
|
52
|
+
})
|
|
53
|
+
|
|
47
54
|
it('renders the setup wizard while setup is required (first-time install)', () => {
|
|
48
55
|
window.history.replaceState({}, '', '/securelogin/setup')
|
|
49
56
|
render(
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Default Prenta
|
|
3
|
-
* screen:
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Default Prenta brand lockup for the admin sidebar (top-left) and the login
|
|
3
|
+
* screen: the word "Prenta" plus a three-dot ellipsis (last dot is the brand
|
|
4
|
+
* red). The collapsed sidebar uses the matching "P…" mark.
|
|
5
|
+
*
|
|
6
|
+
* Letterforms and the first two dots render in `currentColor` so the lockup
|
|
7
|
+
* follows the surrounding theme in light and dark mode. The accent dot is the
|
|
8
|
+
* one baked-in brand color.
|
|
6
9
|
*
|
|
7
10
|
* Integrators override this default via:
|
|
8
11
|
* defineConfig({
|
|
@@ -20,42 +23,65 @@ export interface BrandLogoProps {
|
|
|
20
23
|
className?: string
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
/** Brand-red accent used for the final ellipsis dot. */
|
|
27
|
+
export const PRENTA_ACCENT = '#E30613'
|
|
28
|
+
|
|
29
|
+
const FONT = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
|
30
|
+
|
|
23
31
|
/**
|
|
24
|
-
*
|
|
25
|
-
* pipeline required — works under Next.js, Vite, Webpack, esbuild, etc.).
|
|
26
|
-
* The wordmark text inherits the admin font stack; `textLength` pins the
|
|
27
|
-
* rendered width so the lockup stays proportioned across font fallbacks.
|
|
32
|
+
* Compact "P…" mark for the collapsed sidebar rail and other square slots.
|
|
28
33
|
*/
|
|
29
|
-
export function
|
|
34
|
+
export function PrentaMark({ className }: BrandLogoProps): ReactElement {
|
|
30
35
|
return (
|
|
31
36
|
<svg
|
|
32
|
-
viewBox="0 0
|
|
37
|
+
viewBox="0 0 48 48"
|
|
33
38
|
xmlns="http://www.w3.org/2000/svg"
|
|
34
39
|
className={className}
|
|
35
40
|
role="img"
|
|
36
41
|
aria-label="Prenta"
|
|
37
|
-
style={{ shapeRendering: 'geometricPrecision' }}
|
|
38
42
|
>
|
|
39
|
-
{/* Mark: rounded square with a stencil "P" cutout (even-odd fill). */}
|
|
40
43
|
<path
|
|
41
44
|
fill="currentColor"
|
|
42
45
|
fillRule="evenodd"
|
|
43
|
-
d="
|
|
46
|
+
d="M8 4h10.5c8.2 0 13.5 4.4 13.5 12.2 0 7.7-5.3 12.1-13.5 12.1H16v15.7H8V4zm8 6.4v11.4h2.2c4.7 0 7.4-2.3 7.4-5.7 0-3.5-2.7-5.7-7.4-5.7H16z"
|
|
44
47
|
/>
|
|
45
|
-
|
|
48
|
+
<circle cx="26" cy="43.2" r="2.7" fill="currentColor" />
|
|
49
|
+
<circle cx="34.2" cy="43.2" r="2.7" fill="currentColor" />
|
|
50
|
+
<circle cx="42.4" cy="43.2" r="2.7" fill={PRENTA_ACCENT} />
|
|
51
|
+
</svg>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Horizontal "Prenta…" wordmark. Ships as JSX (no bundler asset pipeline —
|
|
57
|
+
* works under Next.js, Vite, Webpack, esbuild). `textLength` pins the
|
|
58
|
+
* rendered width so the lockup stays proportioned across font fallbacks.
|
|
59
|
+
*/
|
|
60
|
+
export function PrentaBrandLogo({ className }: BrandLogoProps): ReactElement {
|
|
61
|
+
return (
|
|
62
|
+
<svg
|
|
63
|
+
viewBox="0 0 168 32"
|
|
64
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
65
|
+
className={className}
|
|
66
|
+
role="img"
|
|
67
|
+
aria-label="Prenta"
|
|
68
|
+
>
|
|
46
69
|
<text
|
|
47
|
-
x="
|
|
48
|
-
y="
|
|
49
|
-
fontFamily=
|
|
50
|
-
fontSize="
|
|
51
|
-
fontWeight="
|
|
52
|
-
letterSpacing="-0.
|
|
70
|
+
x="0"
|
|
71
|
+
y="24"
|
|
72
|
+
fontFamily={FONT}
|
|
73
|
+
fontSize="22"
|
|
74
|
+
fontWeight="700"
|
|
75
|
+
letterSpacing="-0.6"
|
|
53
76
|
fill="currentColor"
|
|
54
|
-
textLength="
|
|
77
|
+
textLength="104"
|
|
55
78
|
lengthAdjust="spacingAndGlyphs"
|
|
56
79
|
>
|
|
57
80
|
Prenta
|
|
58
81
|
</text>
|
|
82
|
+
<circle cx="118" cy="20" r="3.1" fill="currentColor" />
|
|
83
|
+
<circle cx="130.5" cy="20" r="3.1" fill="currentColor" />
|
|
84
|
+
<circle cx="143" cy="20" r="3.1" fill={PRENTA_ACCENT} />
|
|
59
85
|
</svg>
|
|
60
86
|
)
|
|
61
87
|
}
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -34,34 +34,9 @@ import {
|
|
|
34
34
|
Trash2,
|
|
35
35
|
} from 'lucide-react'
|
|
36
36
|
import type { LucideIcon } from 'lucide-react'
|
|
37
|
-
import { PrentaBrandLogo } from '../assets/prenta-logo.js'
|
|
37
|
+
import { PrentaBrandLogo, PrentaMark } from '../assets/prenta-logo.js'
|
|
38
38
|
import { LogoPlaceholder } from '../components/LogoPlaceholder.js'
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
* Compact mark used in the collapsed sidebar — just the "C" symbol from the
|
|
42
|
-
* full lockup, drawn as its own simple SVG so it stays crisp at 32×32.
|
|
43
|
-
*/
|
|
44
|
-
function PrentaMark({ className }: { className?: string }) {
|
|
45
|
-
return (
|
|
46
|
-
<svg
|
|
47
|
-
viewBox="0 0 40 44"
|
|
48
|
-
fill="none"
|
|
49
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
50
|
-
className={className}
|
|
51
|
-
aria-hidden="true"
|
|
52
|
-
>
|
|
53
|
-
<polygon points="20,2 6,18 12,18 20,8 28,18 34,18" fill="#F05E65" />
|
|
54
|
-
<rect x="11" y="20" width="4" height="22" rx="1" fill="#F05E65" />
|
|
55
|
-
<rect x="18" y="20" width="4" height="22" rx="1" fill="#F05E65" />
|
|
56
|
-
<rect x="25" y="20" width="4" height="22" rx="1" fill="#F05E65" />
|
|
57
|
-
</svg>
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function PrentaWordmark({ className }: { className?: string }) {
|
|
62
|
-
return <PrentaBrandLogo className={className} />
|
|
63
|
-
}
|
|
64
|
-
|
|
65
40
|
const ICON_MAP: Record<string, LucideIcon> = {
|
|
66
41
|
file: File,
|
|
67
42
|
'file-text': FileText,
|
|
@@ -180,7 +155,7 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
180
155
|
)
|
|
181
156
|
}
|
|
182
157
|
|
|
183
|
-
return <
|
|
158
|
+
return <PrentaBrandLogo className="h-8 w-auto" />
|
|
184
159
|
}
|
|
185
160
|
|
|
186
161
|
/**
|
|
@@ -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/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'
|
|
@@ -52,7 +53,7 @@ export interface LoginProps {
|
|
|
52
53
|
captchaConfig?: CaptchaConfig
|
|
53
54
|
/**
|
|
54
55
|
* Custom branding for the login screen. Falls back to the bundled Prenta
|
|
55
|
-
*
|
|
56
|
+
* wordmark when omitted.
|
|
56
57
|
*/
|
|
57
58
|
branding?: LoginBrandingConfig
|
|
58
59
|
}
|
|
@@ -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) => {
|
|
@@ -375,9 +377,10 @@ export function Login({
|
|
|
375
377
|
const hasInitials = (value: string | null | undefined) => Boolean(value) && value !== 'A'
|
|
376
378
|
const isBranded =
|
|
377
379
|
Boolean(branding?.name) || hasInitials(adminBrand.initials) || hasInitials(publicInitials)
|
|
378
|
-
//
|
|
379
|
-
// headline
|
|
380
|
-
|
|
380
|
+
// A logo image or the bundled wordmark already carries the brand — a
|
|
381
|
+
// separate headline would duplicate it. Keep the name only for the
|
|
382
|
+
// branded-but-logo-less placeholder.
|
|
383
|
+
const showBrandName = !effectiveLogo && isBranded
|
|
381
384
|
|
|
382
385
|
return (
|
|
383
386
|
<div className="bg-muted flex min-h-screen items-center justify-center px-4">
|
|
@@ -489,6 +492,18 @@ export function Login({
|
|
|
489
492
|
onSubmit={handleSubmit}
|
|
490
493
|
className="border-border bg-card space-y-5 rounded-xl border p-6 shadow-sm"
|
|
491
494
|
>
|
|
495
|
+
{loggedOut && !error && (
|
|
496
|
+
<div
|
|
497
|
+
className="border-success/30 bg-success/10 flex items-start gap-3 rounded-lg border p-3"
|
|
498
|
+
role="status"
|
|
499
|
+
>
|
|
500
|
+
<CheckCircle2 className="text-success mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
|
|
501
|
+
<p className="text-success text-sm">
|
|
502
|
+
Logged out successfully. Sign in to continue.
|
|
503
|
+
</p>
|
|
504
|
+
</div>
|
|
505
|
+
)}
|
|
506
|
+
|
|
492
507
|
{error && (
|
|
493
508
|
<div className="border-destructive/30 bg-destructive/10 flex items-start gap-3 rounded-lg border p-3">
|
|
494
509
|
<AlertTriangle className="text-destructive mt-0.5 h-5 w-5 shrink-0" />
|