@actuate-media/cms-admin 0.36.1 → 0.37.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/dist/__tests__/layout/sidebar-brand.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-brand.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-brand.render.test.js +44 -0
- package/dist/__tests__/layout/sidebar-brand.render.test.js.map +1 -0
- package/dist/__tests__/views/appearance-settings.render.test.js +4 -4
- package/dist/__tests__/views/login-branding.render.test.d.ts +2 -0
- package/dist/__tests__/views/login-branding.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/login-branding.render.test.js +80 -0
- package/dist/__tests__/views/login-branding.render.test.js.map +1 -0
- package/dist/__tests__/views/login-mfa.render.test.js +6 -0
- package/dist/__tests__/views/login-mfa.render.test.js.map +1 -1
- package/dist/__tests__/views/updates-tab.render.test.d.ts +2 -0
- package/dist/__tests__/views/updates-tab.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/updates-tab.render.test.js +132 -0
- package/dist/__tests__/views/updates-tab.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/LogoPlaceholder.d.ts +17 -0
- package/dist/components/LogoPlaceholder.d.ts.map +1 -0
- package/dist/components/LogoPlaceholder.js +13 -0
- package/dist/components/LogoPlaceholder.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/Card.d.ts +2 -2
- package/dist/components/ui/Input.d.ts +1 -1
- package/dist/components/ui/Select.d.ts +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +13 -10
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/Login.d.ts.map +1 -1
- package/dist/views/Login.js +66 -8
- package/dist/views/Login.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +4 -112
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/AdminThemeCard.js +1 -1
- package/dist/views/settings/BrandPreviewCard.js +1 -1
- package/dist/views/settings/BrandingCard.d.ts.map +1 -1
- package/dist/views/settings/BrandingCard.js +34 -19
- package/dist/views/settings/BrandingCard.js.map +1 -1
- package/dist/views/settings/TypographyMotionCard.js +1 -1
- package/dist/views/settings/UpdatesTab.d.ts +6 -0
- package/dist/views/settings/UpdatesTab.d.ts.map +1 -0
- package/dist/views/settings/UpdatesTab.js +80 -0
- package/dist/views/settings/UpdatesTab.js.map +1 -0
- package/dist/views/settings/components.d.ts +20 -0
- package/dist/views/settings/components.d.ts.map +1 -1
- package/dist/views/settings/components.js +11 -0
- package/dist/views/settings/components.js.map +1 -1
- package/dist/views/settings/useUpdates.d.ts +67 -0
- package/dist/views/settings/useUpdates.d.ts.map +1 -0
- package/dist/views/settings/useUpdates.js +127 -0
- package/dist/views/settings/useUpdates.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/layout/sidebar-brand.render.test.tsx +58 -0
- package/src/__tests__/views/appearance-settings.render.test.tsx +4 -4
- package/src/__tests__/views/login-branding.render.test.tsx +96 -0
- package/src/__tests__/views/login-mfa.render.test.tsx +7 -0
- package/src/__tests__/views/updates-tab.render.test.tsx +161 -0
- package/src/components/LogoPlaceholder.tsx +55 -0
- package/src/layout/Sidebar.tsx +14 -24
- package/src/views/Login.tsx +70 -9
- package/src/views/Settings.tsx +2 -438
- package/src/views/settings/AdminThemeCard.tsx +1 -1
- package/src/views/settings/BrandPreviewCard.tsx +1 -1
- package/src/views/settings/BrandingCard.tsx +179 -180
- package/src/views/settings/TypographyMotionCard.tsx +1 -1
- package/src/views/settings/UpdatesTab.tsx +545 -0
- package/src/views/settings/components.tsx +51 -0
- package/src/views/settings/useUpdates.ts +183 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"tailwindcss": "^4.0.0",
|
|
89
89
|
"typescript": "^5.7.0",
|
|
90
90
|
"vitest": "^4.1.0",
|
|
91
|
-
"@actuate-media/cms-core": "0.
|
|
91
|
+
"@actuate-media/cms-core": "0.47.0",
|
|
92
92
|
"@actuate-media/component-blocks": "0.2.0"
|
|
93
93
|
},
|
|
94
94
|
"scripts": {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
// The sidebar fetches forms unread counts on mount; stub it so these brand
|
|
6
|
+
// tests stay offline and deterministic.
|
|
7
|
+
vi.mock('../../lib/forms-service.js', () => ({
|
|
8
|
+
fetchFormsSidebarCounts: vi.fn(async () => []),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
// Drive the brand lockup directly: a branded install (initials derived from a
|
|
12
|
+
// site title) with no uploaded logo. happy-dom's localStorage stub is
|
|
13
|
+
// unreliable here, so we mock the theme hook rather than hydrating it.
|
|
14
|
+
vi.mock('../../components/ThemeProvider.js', () => ({
|
|
15
|
+
useTheme: () => ({
|
|
16
|
+
adminBrand: { primaryUrl: null, markUrl: null, darkUrl: null, initials: 'AC' },
|
|
17
|
+
resolvedTheme: 'light',
|
|
18
|
+
density: 'default',
|
|
19
|
+
}),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
const { Sidebar } = await import('../../layout/Sidebar.js')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Brand lockup fallback behaviour. When an install is branded (a site name is
|
|
26
|
+
* set, so initials exist) but no logo has been uploaded, the sidebar must show
|
|
27
|
+
* the neutral logo placeholder — NOT a filled initials badge, which reads as a
|
|
28
|
+
* user avatar (the real account avatar already lives top-right).
|
|
29
|
+
*/
|
|
30
|
+
function renderSidebar(collapsed: boolean, config?: unknown) {
|
|
31
|
+
return render(
|
|
32
|
+
<Sidebar
|
|
33
|
+
collapsed={collapsed}
|
|
34
|
+
onToggleCollapse={() => {}}
|
|
35
|
+
currentPath="/"
|
|
36
|
+
onNavigate={() => {}}
|
|
37
|
+
config={config}
|
|
38
|
+
/>,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('Sidebar brand placeholder', () => {
|
|
43
|
+
it('renders the logo placeholder (not an initials avatar) when branded with no logo — expanded', () => {
|
|
44
|
+
renderSidebar(false, { admin: { branding: { name: 'Acme Co' } } })
|
|
45
|
+
|
|
46
|
+
// Placeholder slot is present and the brand name shows beside it.
|
|
47
|
+
expect(screen.getByText('Acme Co')).toBeTruthy()
|
|
48
|
+
// The old avatar-style initials badge must not render.
|
|
49
|
+
expect(screen.queryByText('AC')).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('renders the logo placeholder slot when branded with no logo — collapsed', () => {
|
|
53
|
+
renderSidebar(true)
|
|
54
|
+
|
|
55
|
+
expect(screen.getByRole('img', { name: 'Logo placeholder' })).toBeTruthy()
|
|
56
|
+
expect(screen.queryByText('AC')).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -51,9 +51,9 @@ describe('AppearanceTab', () => {
|
|
|
51
51
|
render(<AppearanceTab role="ADMIN" />)
|
|
52
52
|
|
|
53
53
|
await screen.findByText('Branding')
|
|
54
|
-
expect(screen.getByText('Admin
|
|
55
|
-
expect(screen.getByText('Typography &
|
|
56
|
-
expect(screen.getByText('Brand
|
|
54
|
+
expect(screen.getByText('Admin theme')).toBeTruthy()
|
|
55
|
+
expect(screen.getByText('Typography & motion')).toBeTruthy()
|
|
56
|
+
expect(screen.getByText('Brand preview')).toBeTruthy()
|
|
57
57
|
expect(
|
|
58
58
|
(screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
|
|
59
59
|
).toBe(true)
|
|
@@ -99,7 +99,7 @@ describe('AppearanceTab', () => {
|
|
|
99
99
|
render(<AppearanceTab role="ADMIN" />)
|
|
100
100
|
await screen.findByText('Branding')
|
|
101
101
|
|
|
102
|
-
const input = screen.getByLabelText('Primary
|
|
102
|
+
const input = screen.getByLabelText('Primary logo') as HTMLInputElement
|
|
103
103
|
const file = new File(
|
|
104
104
|
['<svg viewBox="0 0 400 100" xmlns="http://www.w3.org/2000/svg"></svg>'],
|
|
105
105
|
'logo.svg',
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The login screen resolves its logo to match the admin sidebar:
|
|
7
|
+
* 1. the brand uploaded in Settings → Appearance (cached admin brand, then
|
|
8
|
+
* the unauthenticated `/public/brand` endpoint for fresh browsers),
|
|
9
|
+
* 2. the static `config.admin.branding.logo`,
|
|
10
|
+
* 3. the bundled Actuate wordmark,
|
|
11
|
+
* and `branding.logo === null` hides the logo entirely (whitelabel opt-out).
|
|
12
|
+
*
|
|
13
|
+
* `cmsApi` is mocked because the component fetches `/public/brand` on mount.
|
|
14
|
+
*/
|
|
15
|
+
const cmsApiMock = vi.fn()
|
|
16
|
+
vi.mock('../../lib/api.js', () => ({
|
|
17
|
+
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import { Login } from '../../views/Login.js'
|
|
21
|
+
|
|
22
|
+
const onLogin = vi.fn(async () => ({ success: true }))
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
cmsApiMock.mockReset()
|
|
26
|
+
// Default: no public brand configured.
|
|
27
|
+
cmsApiMock.mockResolvedValue({ data: {}, status: 200 })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('Login — logo resolution', () => {
|
|
31
|
+
it('renders the bundled Actuate logo when nothing is branded', async () => {
|
|
32
|
+
render(<Login onLogin={onLogin} />)
|
|
33
|
+
// Bundled SVG exposes role="img" + aria-label="Actuate Media".
|
|
34
|
+
expect(screen.getByRole('img', { name: 'Actuate Media' })).toBeTruthy()
|
|
35
|
+
// No <img> logo element.
|
|
36
|
+
expect(screen.queryByRole('img', { name: 'Actuate CMS' })).toBeNull()
|
|
37
|
+
await waitFor(() => expect(cmsApiMock).toHaveBeenCalledWith('/public/brand'))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('shows the neutral logo placeholder when branded but no logo is set', async () => {
|
|
41
|
+
render(<Login onLogin={onLogin} branding={{ name: 'Acme Co' }} />)
|
|
42
|
+
// Placeholder slot, not the bundled Actuate wordmark.
|
|
43
|
+
expect(screen.getByRole('img', { name: 'Logo placeholder' })).toBeTruthy()
|
|
44
|
+
expect(screen.queryByRole('img', { name: 'Actuate Media' })).toBeNull()
|
|
45
|
+
// The brand name still renders as the headline.
|
|
46
|
+
expect(screen.getByRole('heading', { name: 'Acme Co' })).toBeTruthy()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('shows the placeholder for a fresh browser when /public/brand reports a branded site (initials) but no logo', async () => {
|
|
50
|
+
// No config name, no cached admin brand — only the public brand initials
|
|
51
|
+
// signal a branded install. The logo-less fallback must be the placeholder,
|
|
52
|
+
// not the Actuate wordmark.
|
|
53
|
+
cmsApiMock.mockResolvedValue({
|
|
54
|
+
data: { public: { primary: null, initials: 'AC' } },
|
|
55
|
+
status: 200,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
render(<Login onLogin={onLogin} />)
|
|
59
|
+
|
|
60
|
+
await waitFor(() => {
|
|
61
|
+
expect(screen.getByRole('img', { name: 'Logo placeholder' })).toBeTruthy()
|
|
62
|
+
})
|
|
63
|
+
expect(screen.queryByRole('img', { name: 'Actuate Media' })).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('uses the static config logo when no appearance brand exists', async () => {
|
|
67
|
+
render(
|
|
68
|
+
<Login onLogin={onLogin} branding={{ logo: 'https://cdn.example.com/config-logo.png' }} />,
|
|
69
|
+
)
|
|
70
|
+
const img = (await screen.findByAltText('Actuate CMS')) as HTMLImageElement
|
|
71
|
+
expect(img.getAttribute('src')).toBe('https://cdn.example.com/config-logo.png')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('prefers the uploaded appearance logo from /public/brand over the config logo', async () => {
|
|
75
|
+
cmsApiMock.mockResolvedValue({
|
|
76
|
+
data: { public: { primary: { url: 'https://cdn.example.com/appearance-logo.svg' } } },
|
|
77
|
+
status: 200,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
render(
|
|
81
|
+
<Login onLogin={onLogin} branding={{ logo: 'https://cdn.example.com/config-logo.png' }} />,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
const img = screen.getByAltText('Actuate CMS') as HTMLImageElement
|
|
86
|
+
expect(img.getAttribute('src')).toBe('https://cdn.example.com/appearance-logo.svg')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('hides the logo entirely when branding.logo is null (whitelabel) and skips the fetch', async () => {
|
|
91
|
+
render(<Login onLogin={onLogin} branding={{ logo: null }} />)
|
|
92
|
+
expect(screen.queryByRole('img', { name: 'Actuate Media' })).toBeNull()
|
|
93
|
+
expect(screen.queryByAltText('Actuate CMS')).toBeNull()
|
|
94
|
+
expect(cmsApiMock).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest'
|
|
3
3
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
4
|
|
|
5
|
+
// The login screen fetches `/public/brand` on mount to mirror the sidebar
|
|
6
|
+
// logo. These MFA tests don't care about branding — stub the network so the
|
|
7
|
+
// real `cmsApi` (which reads localStorage) doesn't run in this environment.
|
|
8
|
+
vi.mock('../../lib/api.js', () => ({
|
|
9
|
+
cmsApi: vi.fn(async () => ({ data: {}, status: 200 })),
|
|
10
|
+
}))
|
|
11
|
+
|
|
5
12
|
import { Login } from '../../views/Login.js'
|
|
6
13
|
|
|
7
14
|
/**
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
// The Updates tab reads `/updates/check`, saves GitHub config via
|
|
6
|
+
// PUT `/updates/config`, and opens an upgrade PR via POST `/updates/apply`.
|
|
7
|
+
let checkData: any
|
|
8
|
+
|
|
9
|
+
const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?: unknown }) => {
|
|
10
|
+
const method = options?.method ?? 'GET'
|
|
11
|
+
if (endpoint === '/updates/check' && method === 'GET') return { data: checkData, status: 200 }
|
|
12
|
+
if (endpoint === '/updates/config' && method === 'PUT')
|
|
13
|
+
return { data: { success: true }, status: 200 }
|
|
14
|
+
if (endpoint === '/updates/apply' && method === 'POST')
|
|
15
|
+
return { data: { prUrl: 'https://github.com/x/y/pull/1', prNumber: 1 }, status: 200 }
|
|
16
|
+
return { data: {}, status: 200 }
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any) }))
|
|
20
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
21
|
+
|
|
22
|
+
const { UpdatesTab } = await import('../../views/settings/UpdatesTab.js')
|
|
23
|
+
|
|
24
|
+
function upToDate(overrides: Record<string, unknown> = {}) {
|
|
25
|
+
return {
|
|
26
|
+
current: '0.46.0',
|
|
27
|
+
latest: '0.46.0',
|
|
28
|
+
updateAvailable: false,
|
|
29
|
+
canManage: true,
|
|
30
|
+
hasGithubToken: false,
|
|
31
|
+
githubRepo: '',
|
|
32
|
+
source: 'update-server',
|
|
33
|
+
...overrides,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function updateAvailable(overrides: Record<string, unknown> = {}) {
|
|
38
|
+
return {
|
|
39
|
+
current: '0.45.0',
|
|
40
|
+
latest: '0.46.0',
|
|
41
|
+
updateAvailable: true,
|
|
42
|
+
severity: 'minor',
|
|
43
|
+
releaseDate: '2026-01-02',
|
|
44
|
+
changelog: [
|
|
45
|
+
{
|
|
46
|
+
version: '0.46.0',
|
|
47
|
+
date: '2026-01-02',
|
|
48
|
+
summary: 'Add Brand Voice training',
|
|
49
|
+
type: 'feature',
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
hasMigrations: true,
|
|
53
|
+
breakingChanges: ['Removed the legacy provider API'],
|
|
54
|
+
pluginCompatibility: [
|
|
55
|
+
{ name: '@actuate-media/plugin-seo', compatible: true, minVersion: '0.46.0' },
|
|
56
|
+
],
|
|
57
|
+
updateCommand: 'npm update @actuate-media/cms-core',
|
|
58
|
+
canManage: true,
|
|
59
|
+
hasGithubToken: false,
|
|
60
|
+
githubRepo: '',
|
|
61
|
+
source: 'update-server',
|
|
62
|
+
...overrides,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
cmsApi.mockClear()
|
|
68
|
+
checkData = upToDate()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('UpdatesTab', () => {
|
|
72
|
+
it('shows the current version and an up-to-date banner', async () => {
|
|
73
|
+
render(<UpdatesTab role="ADMIN" />)
|
|
74
|
+
await screen.findByText("You're up to date!")
|
|
75
|
+
expect(screen.getAllByText('0.46.0').length).toBeGreaterThan(0)
|
|
76
|
+
// Admins see the GitHub integration card.
|
|
77
|
+
expect(screen.getByText('GitHub Integration')).toBeTruthy()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('surfaces severity, real changelog, migrations and breaking changes', async () => {
|
|
81
|
+
checkData = updateAvailable()
|
|
82
|
+
render(<UpdatesTab role="ADMIN" />)
|
|
83
|
+
|
|
84
|
+
await screen.findByText('Update available')
|
|
85
|
+
expect(screen.getByText('Minor')).toBeTruthy()
|
|
86
|
+
expect(screen.getByText('Add Brand Voice training')).toBeTruthy()
|
|
87
|
+
expect(screen.getByText('Database migrations required')).toBeTruthy()
|
|
88
|
+
expect(screen.getByText('Breaking changes in this release')).toBeTruthy()
|
|
89
|
+
expect(screen.getByText('Removed the legacy provider API')).toBeTruthy()
|
|
90
|
+
// Plugin compatibility surfaced.
|
|
91
|
+
expect(screen.getByText('plugin-seo')).toBeTruthy()
|
|
92
|
+
expect(screen.getByRole('button', { name: 'Create Update PR' })).toBeTruthy()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('flags a security release prominently', async () => {
|
|
96
|
+
checkData = updateAvailable({ severity: 'security' })
|
|
97
|
+
render(<UpdatesTab role="ADMIN" />)
|
|
98
|
+
await screen.findByText('Security update available')
|
|
99
|
+
expect(screen.getByText('Security')).toBeTruthy()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('is permission-aware: non-admins cannot manage updates', async () => {
|
|
103
|
+
checkData = updateAvailable({ canManage: false })
|
|
104
|
+
render(<UpdatesTab role="EDITOR" />)
|
|
105
|
+
|
|
106
|
+
await screen.findByText('Update available')
|
|
107
|
+
expect(screen.queryByText('GitHub Integration')).toBeNull()
|
|
108
|
+
expect(screen.queryByRole('button', { name: 'Create Update PR' })).toBeNull()
|
|
109
|
+
expect(screen.getByText(/Ask an administrator/i)).toBeTruthy()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('enables Save when the repo changes and persists via PUT /updates/config', async () => {
|
|
113
|
+
render(<UpdatesTab role="ADMIN" />)
|
|
114
|
+
await screen.findByText('GitHub Integration')
|
|
115
|
+
|
|
116
|
+
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
|
|
117
|
+
expect(save.disabled).toBe(true)
|
|
118
|
+
|
|
119
|
+
const repo = screen.getByLabelText('Repository') as HTMLInputElement
|
|
120
|
+
fireEvent.change(repo, { target: { value: 'actuate-media/site' } })
|
|
121
|
+
expect(save.disabled).toBe(false)
|
|
122
|
+
|
|
123
|
+
fireEvent.click(save)
|
|
124
|
+
await waitFor(() =>
|
|
125
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
126
|
+
'/updates/config',
|
|
127
|
+
expect.objectContaining({
|
|
128
|
+
method: 'PUT',
|
|
129
|
+
body: expect.stringContaining('actuate-media/site'),
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('blocks Save on an invalid repository format', async () => {
|
|
136
|
+
render(<UpdatesTab role="ADMIN" />)
|
|
137
|
+
await screen.findByText('GitHub Integration')
|
|
138
|
+
|
|
139
|
+
const repo = screen.getByLabelText('Repository') as HTMLInputElement
|
|
140
|
+
fireEvent.change(repo, { target: { value: 'not a repo' } })
|
|
141
|
+
|
|
142
|
+
expect(screen.getByText(/owner\/repo format/i)).toBeTruthy()
|
|
143
|
+
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
|
|
144
|
+
expect(save.disabled).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('creates an update PR via POST /updates/apply', async () => {
|
|
148
|
+
checkData = updateAvailable()
|
|
149
|
+
render(<UpdatesTab role="ADMIN" />)
|
|
150
|
+
await screen.findByText('Update available')
|
|
151
|
+
|
|
152
|
+
fireEvent.click(screen.getByRole('button', { name: 'Create Update PR' }))
|
|
153
|
+
await waitFor(() =>
|
|
154
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
155
|
+
'/updates/apply',
|
|
156
|
+
expect.objectContaining({ method: 'POST', body: expect.stringContaining('0.46.0') }),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
await screen.findByText('Pull Request Created')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ImagePlus } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Neutral "logo goes here" placeholder shown when no brand logo has been
|
|
7
|
+
* uploaded or configured. Deliberately reads as an empty slot — a dashed
|
|
8
|
+
* outline with a muted image icon — rather than a filled initials badge, which
|
|
9
|
+
* looks like a user avatar (the real account avatar already lives top-right).
|
|
10
|
+
*
|
|
11
|
+
* It is automatically superseded the moment a logo is set in Settings →
|
|
12
|
+
* Appearance, since every logo branch takes precedence over this fallback.
|
|
13
|
+
*/
|
|
14
|
+
export interface LogoPlaceholderProps {
|
|
15
|
+
/** Color treatment so the slot blends with its surface. */
|
|
16
|
+
tone: 'sidebar' | 'login'
|
|
17
|
+
/** Brand/site name rendered beside the slot (expanded sidebar only). */
|
|
18
|
+
label?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function LogoPlaceholder({ tone, label }: LogoPlaceholderProps) {
|
|
22
|
+
if (tone === 'login') {
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
role="img"
|
|
26
|
+
aria-label="Logo placeholder"
|
|
27
|
+
title="Add a logo in Settings → Appearance"
|
|
28
|
+
className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-lg border border-dashed border-gray-300 text-gray-400"
|
|
29
|
+
>
|
|
30
|
+
<ImagePlus className="h-6 w-6" aria-hidden="true" />
|
|
31
|
+
</span>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const slot = (
|
|
36
|
+
<span
|
|
37
|
+
className="border-sidebar-border text-sidebar-foreground/50 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-dashed"
|
|
38
|
+
role={label ? undefined : 'img'}
|
|
39
|
+
aria-hidden={label ? true : undefined}
|
|
40
|
+
aria-label={label ? undefined : 'Logo placeholder'}
|
|
41
|
+
title="Add a logo in Settings → Appearance"
|
|
42
|
+
>
|
|
43
|
+
<ImagePlus size={16} aria-hidden="true" />
|
|
44
|
+
</span>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if (!label) return slot
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex min-w-0 items-center gap-2.5">
|
|
51
|
+
{slot}
|
|
52
|
+
<span className="text-sidebar-foreground truncate text-sm font-medium">{label}</span>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from 'lucide-react'
|
|
35
35
|
import type { LucideIcon } from 'lucide-react'
|
|
36
36
|
import { ActuateBrandLogo } from '../assets/actuate-logo.js'
|
|
37
|
+
import { LogoPlaceholder } from '../components/LogoPlaceholder.js'
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Compact mark used in the collapsed sidebar — just the "C" symbol from the
|
|
@@ -76,28 +77,17 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|
|
76
77
|
Layers: Layers,
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
/** Square site-initials badge — the final fallback before the Actuate mark. */
|
|
80
|
-
function InitialsBadge({ initials, size }: { initials: string; size: 'sm' | 'lg' }) {
|
|
81
|
-
return (
|
|
82
|
-
<span
|
|
83
|
-
className={`bg-sidebar-primary text-sidebar-primary-foreground inline-flex shrink-0 items-center justify-center rounded-md font-medium ${
|
|
84
|
-
size === 'lg' ? 'h-8 w-8 text-sm' : 'h-8 w-8 text-sm'
|
|
85
|
-
}`}
|
|
86
|
-
aria-hidden="true"
|
|
87
|
-
>
|
|
88
|
-
{initials}
|
|
89
|
-
</span>
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
80
|
/**
|
|
94
81
|
* Sidebar brand lockup.
|
|
95
82
|
*
|
|
96
83
|
* Resolution order follows the Appearance spec, sourcing from the live
|
|
97
84
|
* appearance context (DB-backed brand, cached in localStorage) and falling
|
|
98
|
-
* back to legacy `config.admin.branding`, then
|
|
99
|
-
*
|
|
100
|
-
*
|
|
85
|
+
* back to legacy `config.admin.branding`, then a neutral logo placeholder for
|
|
86
|
+
* branded-but-logo-less installs, then the bundled Actuate mark/wordmark for
|
|
87
|
+
* un-branded installs. The placeholder replaces the old initials badge, which
|
|
88
|
+
* read as a user avatar (the real account avatar already sits top-right).
|
|
89
|
+
* Dark-mode uses the dark logo when one is configured. Logos are never
|
|
90
|
+
* stretched/cropped (object-contain).
|
|
101
91
|
*/
|
|
102
92
|
function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean }) {
|
|
103
93
|
const { adminBrand, resolvedTheme } = useTheme()
|
|
@@ -117,7 +107,7 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
117
107
|
if (mark) return <img src={mark} alt={alt} className="h-8 w-8 object-contain" />
|
|
118
108
|
if (primary) return <img src={primary} alt={alt} className="h-8 w-8 object-contain" />
|
|
119
109
|
if (configLogo) return <img src={configLogo} alt={alt} className="h-8 w-8 object-contain" />
|
|
120
|
-
if (hasInitials) return <
|
|
110
|
+
if (hasInitials) return <LogoPlaceholder tone="sidebar" />
|
|
121
111
|
return <ActuateMark className="h-8 w-8" />
|
|
122
112
|
}
|
|
123
113
|
|
|
@@ -143,14 +133,10 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
143
133
|
)
|
|
144
134
|
}
|
|
145
135
|
|
|
146
|
-
if (mark
|
|
136
|
+
if (mark) {
|
|
147
137
|
return (
|
|
148
138
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
149
|
-
{mark
|
|
150
|
-
<img src={mark} alt={alt} className="h-8 w-8 shrink-0 object-contain" />
|
|
151
|
-
) : (
|
|
152
|
-
<InitialsBadge initials={initials} size="lg" />
|
|
153
|
-
)}
|
|
139
|
+
<img src={mark} alt={alt} className="h-8 w-8 shrink-0 object-contain" />
|
|
154
140
|
<span className="text-sidebar-foreground truncate text-sm font-medium">
|
|
155
141
|
{brandName ?? alt}
|
|
156
142
|
</span>
|
|
@@ -158,6 +144,10 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
158
144
|
)
|
|
159
145
|
}
|
|
160
146
|
|
|
147
|
+
if (hasInitials) {
|
|
148
|
+
return <LogoPlaceholder tone="sidebar" label={brandName ?? alt} />
|
|
149
|
+
}
|
|
150
|
+
|
|
161
151
|
if (brandName) {
|
|
162
152
|
return (
|
|
163
153
|
<div className="flex min-w-0 items-center gap-2.5">
|
package/src/views/Login.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState, type FormEvent } from 'react'
|
|
3
|
+
import { useState, useEffect, useRef, type FormEvent } from 'react'
|
|
4
4
|
import { Eye, EyeOff, AlertTriangle, Loader2 } from 'lucide-react'
|
|
5
5
|
import { ActuateBrandLogo } from '../assets/actuate-logo.js'
|
|
6
|
+
import { LogoPlaceholder } from '../components/LogoPlaceholder.js'
|
|
7
|
+
import { useTheme } from '../components/ThemeProvider.js'
|
|
8
|
+
import { cmsApi } from '../lib/api.js'
|
|
6
9
|
|
|
7
10
|
export interface CaptchaConfig {
|
|
8
11
|
provider: 'recaptcha' | 'turnstile' | 'none'
|
|
@@ -227,8 +230,45 @@ export function Login({
|
|
|
227
230
|
const [totpError, setTotpError] = useState('')
|
|
228
231
|
const [verifying, setVerifying] = useState(false)
|
|
229
232
|
|
|
233
|
+
// Brand logo, sourced to match the admin sidebar (see resolution note below).
|
|
234
|
+
// `adminBrand` is the ThemeProvider cache the sidebar paints from (hydrated
|
|
235
|
+
// from localStorage on mount); `publicLogo` is fetched only when that cache
|
|
236
|
+
// is empty (a fresh browser that hasn't loaded the admin yet).
|
|
237
|
+
const { adminBrand } = useTheme()
|
|
238
|
+
const [publicLogo, setPublicLogo] = useState<string | null>(null)
|
|
239
|
+
// Site initials resolved from `/public/brand`. Used only to detect whether
|
|
240
|
+
// the install is "branded" on a fresh browser (no cache, no config name), so
|
|
241
|
+
// the logo-less fallback can pick the placeholder over the Actuate wordmark.
|
|
242
|
+
const [publicInitials, setPublicInitials] = useState<string | null>(null)
|
|
243
|
+
const publicBrandFetched = useRef(false)
|
|
244
|
+
|
|
230
245
|
useCaptchaScript(captchaConfig?.provider, captchaConfig?.siteKey)
|
|
231
246
|
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
// The cached admin brand (exactly what the sidebar last rendered) wins and
|
|
249
|
+
// needs no network. Only reach for the unauthenticated `/public/brand`
|
|
250
|
+
// endpoint on a fresh browser with no cache, and never when the integrator
|
|
251
|
+
// opted out of a logo entirely (`branding.logo === null`).
|
|
252
|
+
if (adminBrand.primaryUrl || branding?.logo === null) return
|
|
253
|
+
if (publicBrandFetched.current) return
|
|
254
|
+
publicBrandFetched.current = true
|
|
255
|
+
|
|
256
|
+
let cancelled = false
|
|
257
|
+
void (async () => {
|
|
258
|
+
const res = await cmsApi<{
|
|
259
|
+
public?: { primary?: { url?: string } | null; initials?: string } | null
|
|
260
|
+
}>('/public/brand')
|
|
261
|
+
if (cancelled) return
|
|
262
|
+
const url = res.data?.public?.primary?.url
|
|
263
|
+
if (url) setPublicLogo(url)
|
|
264
|
+
const initials = res.data?.public?.initials
|
|
265
|
+
if (initials) setPublicInitials(initials)
|
|
266
|
+
})()
|
|
267
|
+
return () => {
|
|
268
|
+
cancelled = true
|
|
269
|
+
}
|
|
270
|
+
}, [adminBrand.primaryUrl, branding?.logo])
|
|
271
|
+
|
|
232
272
|
const canSubmit = email.trim() && password && !submitting
|
|
233
273
|
|
|
234
274
|
const handleSubmit = async (e: FormEvent) => {
|
|
@@ -308,27 +348,48 @@ export function Login({
|
|
|
308
348
|
const enabledProviders =
|
|
309
349
|
oauthProviders?.filter((p) => ['google', 'github', 'microsoft'].includes(p)) ?? []
|
|
310
350
|
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
351
|
+
// Logo resolution mirrors the admin sidebar so the login screen shows the
|
|
352
|
+
// same brand mark a user sees once signed in. Precedence (best available
|
|
353
|
+
// pre-auth):
|
|
354
|
+
// 1. the logo uploaded in Settings → Appearance — read from the
|
|
355
|
+
// ThemeProvider cache the sidebar paints from, then (fresh browsers with
|
|
356
|
+
// no cache) the unauthenticated `/public/brand` endpoint;
|
|
357
|
+
// 2. the static `config.admin.branding.logo` URL;
|
|
358
|
+
// 3. a neutral logo placeholder when the install is branded (a site/brand
|
|
359
|
+
// name is set) but no logo has been uploaded yet — matching the sidebar;
|
|
360
|
+
// 4. the bundled Actuate wordmark for a fully un-branded install.
|
|
361
|
+
// `branding.logo === null` still hides the logo entirely (whitelabel opt-out).
|
|
362
|
+
const configLogo = branding?.logo
|
|
363
|
+
const hideLogo = configLogo === null
|
|
364
|
+
const appearanceLogo = adminBrand.primaryUrl ?? publicLogo
|
|
365
|
+
const effectiveLogo = appearanceLogo ?? (typeof configLogo === 'string' ? configLogo : null)
|
|
316
366
|
const brandName = branding?.name ?? 'Actuate CMS'
|
|
317
367
|
const brandTagline = branding?.tagline ?? 'Sign in to your account'
|
|
318
|
-
const showLogo =
|
|
368
|
+
const showLogo = !hideLogo
|
|
369
|
+
// "Branded" = the operator has set a brand identity. Mirrors the sidebar's
|
|
370
|
+
// `hasInitials` signal (initials are derived from the site title) plus an
|
|
371
|
+
// explicit `branding.name` and, for fresh browsers, the initials returned by
|
|
372
|
+
// `/public/brand`. Drives the placeholder vs. Actuate-wordmark fallback so a
|
|
373
|
+
// configured client install never shows Actuate branding. `'A'` is the
|
|
374
|
+
// unset-title default, so it counts as un-branded.
|
|
375
|
+
const hasInitials = (value: string | null | undefined) => Boolean(value) && value !== 'A'
|
|
376
|
+
const isBranded =
|
|
377
|
+
Boolean(branding?.name) || hasInitials(adminBrand.initials) || hasInitials(publicInitials)
|
|
319
378
|
|
|
320
379
|
return (
|
|
321
380
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
|
322
381
|
<div className="w-full max-w-md">
|
|
323
382
|
<div className="mb-8 text-center">
|
|
324
383
|
{showLogo &&
|
|
325
|
-
(
|
|
384
|
+
(effectiveLogo ? (
|
|
326
385
|
<img
|
|
327
|
-
src={
|
|
386
|
+
src={effectiveLogo}
|
|
328
387
|
alt={brandName}
|
|
329
388
|
className="mx-auto mb-4 max-h-16 w-auto object-contain"
|
|
330
389
|
draggable={false}
|
|
331
390
|
/>
|
|
391
|
+
) : isBranded ? (
|
|
392
|
+
<LogoPlaceholder tone="login" />
|
|
332
393
|
) : (
|
|
333
394
|
<ActuateBrandLogo className="mx-auto mb-4 h-14 w-auto" />
|
|
334
395
|
))}
|