@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +14 -4
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/assets/prenta-logo.test.d.ts +2 -0
  6. package/dist/__tests__/assets/prenta-logo.test.d.ts.map +1 -0
  7. package/dist/__tests__/assets/prenta-logo.test.js +26 -0
  8. package/dist/__tests__/assets/prenta-logo.test.js.map +1 -0
  9. package/dist/__tests__/layout/sidebar-brand.render.test.js +13 -0
  10. package/dist/__tests__/layout/sidebar-brand.render.test.js.map +1 -1
  11. package/dist/__tests__/lib/logout-redirect.test.d.ts +2 -0
  12. package/dist/__tests__/lib/logout-redirect.test.d.ts.map +1 -0
  13. package/dist/__tests__/lib/logout-redirect.test.js +29 -0
  14. package/dist/__tests__/lib/logout-redirect.test.js.map +1 -0
  15. package/dist/__tests__/lib/pages-service.test.js +21 -6
  16. package/dist/__tests__/lib/pages-service.test.js.map +1 -1
  17. package/dist/__tests__/views/login-branding.render.test.js +2 -1
  18. package/dist/__tests__/views/login-branding.render.test.js.map +1 -1
  19. package/dist/__tests__/views/login-root.render.test.js +6 -0
  20. package/dist/__tests__/views/login-root.render.test.js.map +1 -1
  21. package/dist/assets/prenta-logo.d.ts +15 -7
  22. package/dist/assets/prenta-logo.d.ts.map +1 -1
  23. package/dist/assets/prenta-logo.js +12 -4
  24. package/dist/assets/prenta-logo.js.map +1 -1
  25. package/dist/layout/Sidebar.d.ts.map +1 -1
  26. package/dist/layout/Sidebar.js +2 -12
  27. package/dist/layout/Sidebar.js.map +1 -1
  28. package/dist/lib/logout-redirect.d.ts +19 -0
  29. package/dist/lib/logout-redirect.d.ts.map +1 -0
  30. package/dist/lib/logout-redirect.js +20 -0
  31. package/dist/lib/logout-redirect.js.map +1 -0
  32. package/dist/lib/pages-service.d.ts.map +1 -1
  33. package/dist/lib/pages-service.js +2 -16
  34. package/dist/lib/pages-service.js.map +1 -1
  35. package/dist/views/Login.d.ts +1 -1
  36. package/dist/views/Login.d.ts.map +1 -1
  37. package/dist/views/Login.js +8 -5
  38. package/dist/views/Login.js.map +1 -1
  39. package/package.json +4 -4
  40. package/src/AdminRoot.tsx +13 -4
  41. package/src/__tests__/assets/prenta-logo.test.tsx +26 -0
  42. package/src/__tests__/layout/sidebar-brand.render.test.tsx +15 -0
  43. package/src/__tests__/lib/logout-redirect.test.ts +35 -0
  44. package/src/__tests__/lib/pages-service.test.ts +22 -8
  45. package/src/__tests__/views/login-branding.render.test.tsx +2 -1
  46. package/src/__tests__/views/login-root.render.test.tsx +7 -0
  47. package/src/assets/prenta-logo.tsx +47 -21
  48. package/src/layout/Sidebar.tsx +2 -27
  49. package/src/lib/logout-redirect.ts +33 -0
  50. package/src/lib/pages-service.ts +1 -15
  51. 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 title, path, and tags', () => {
109
+ it('searches the page title only', () => {
110
110
  expect(applyPagesQuery(PAGES, { search: 'seo' }).map((p) => p.id)).toEqual(['3'])
111
- expect(
112
- applyPagesQuery(PAGES, { search: 'core' })
113
- .map((p) => p.id)
114
- .sort(),
115
- ).toEqual(['1', '2'])
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 wordmark for the admin sidebar (top-left) and the login
3
- * screen: a rounded-square mark with a stencil "P" cutout plus the product
4
- * name. Everything renders in `currentColor`, so the logo follows the
5
- * surrounding theme in both light and dark mode with no baked-in palette.
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
- * Inline SVG renderer for the Prenta wordmark. Ships as JSX (no bundler asset
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 PrentaBrandLogo({ className }: BrandLogoProps): ReactElement {
34
+ export function PrentaMark({ className }: BrandLogoProps): ReactElement {
30
35
  return (
31
36
  <svg
32
- viewBox="0 0 128 28"
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="M6 3 H16 A6 6 0 0 1 22 9 V19 A6 6 0 0 1 16 25 H6 A6 6 0 0 1 0 19 V9 A6 6 0 0 1 6 3 Z M7.5 8.5 H13 A4.5 4.5 0 0 1 13 17.5 H10 V20.5 H7.5 Z M10 11 H12.8 A2 2 0 0 1 12.8 15 H10 Z"
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
- {/* Wordmark */}
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="30"
48
- y="20.5"
49
- fontFamily="inherit"
50
- fontSize="19"
51
- fontWeight="600"
52
- letterSpacing="-0.3"
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="72"
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
  }
@@ -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 <PrentaWordmark className="h-9" />
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
+ }
@@ -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
 
@@ -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
- * Media wordmark + "Prenta CMS" headline when omitted.
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
- // When a logo image is shown it already carries the brand mark — a separate
379
- // headline duplicates it (see Settings Appearance uploads).
380
- const showBrandName = !effectiveLogo
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" />