diff --git a/app/api/admin/themes/__tests__/route.test.ts b/app/api/admin/themes/__tests__/route.test.ts new file mode 100644 index 000000000..6f11c7a7d --- /dev/null +++ b/app/api/admin/themes/__tests__/route.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) })); + +let policy: Record; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn(), + getPolicy: vi.fn(() => policy), + setPolicy: vi.fn(async (p: Record) => { policy = p; }), + }, +})); +vi.mock('@/lib/admin/plugin-registry', () => ({ + deleteTheme: vi.fn(async () => true), + saveTheme: vi.fn(), + getTheme: vi.fn(async () => null), + getThemeRegistry: vi.fn(async () => ({ themes: [] })), +})); + +import { DELETE } from '../route'; + +function del(id: string) { + return DELETE(new NextRequest('http://localhost/api/admin/themes', { + method: 'DELETE', + body: JSON.stringify({ id }), + headers: { 'content-type': 'application/json' }, + })); +} + +beforeEach(() => { + policy = { forceEnabledThemes: ['corp', 'other'], forceEnabledPlugins: [] }; +}); + +describe('DELETE /api/admin/themes', () => { + it('stops forcing a theme once it is deleted', async () => { + const res = await del('corp'); + + expect(res.status).toBe(200); + expect(policy.forceEnabledThemes).toEqual(['other']); + }); +}); diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index f600d09b6..a7869c0c1 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -8,6 +8,7 @@ import { deleteTheme as removeTheme, type ServerTheme, } from '@/lib/admin/plugin-registry'; +import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup'; import JSZip from 'jszip'; import { MAX_THEME_SIZE } from '@/lib/plugin-types'; @@ -205,6 +206,7 @@ export async function DELETE(request: NextRequest) { if (!removed) { return NextResponse.json({ error: 'Theme not found' }, { status: 404 }); } + await forgetExtensionInPolicy('theme', id); await auditLog('theme.delete', { id }, ip); return NextResponse.json({ success: true }); diff --git a/lib/__tests__/theme-store-sync.test.ts b/lib/__tests__/theme-store-sync.test.ts new file mode 100644 index 000000000..824fc059f --- /dev/null +++ b/lib/__tests__/theme-store-sync.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const serverThemes: { themes: unknown[] } = { themes: [] }; +vi.mock('@/lib/browser-navigation', async (orig) => ({ + ...(await orig()), + apiFetch: vi.fn(async () => new Response(JSON.stringify({ plugins: [], ...serverThemes }))), +})); +vi.mock('@/lib/plugin-storage', () => ({ + pluginStorage: { + saveThemeCSS: vi.fn(async () => {}), + getThemeCSS: vi.fn(async () => null), + deleteThemeCSS: vi.fn(async () => {}), + deleteThemeSkin: vi.fn(async () => {}), + deletePreview: vi.fn(async () => {}), + getThemeSkin: vi.fn(async () => null), + }, +})); + +import { useThemeStore } from '@/stores/theme-store'; +import { pluginStorage } from '@/lib/plugin-storage'; +import type { InstalledTheme } from '@/lib/plugin-types'; + +const CORP: InstalledTheme = { + id: 'corp', name: 'Corp', version: '1.0.0', author: 'IT', description: '', + css: ':root{--color-primary:red}', variants: ['light', 'dark'], + enabled: true, builtIn: false, managed: true, forceEnabled: true, +}; + +beforeEach(() => { + serverThemes.themes = []; + const builtIns = useThemeStore.getState().installedThemes.filter((t) => t.builtIn); + useThemeStore.setState({ installedThemes: [...builtIns, CORP], activeThemeId: 'corp' }); +}); + +describe('server theme sync', () => { + it('drops a managed theme the admin deleted, and stops forcing it', async () => { + await useThemeStore.getState().syncServerThemes(); + + const state = useThemeStore.getState(); + expect(state.installedThemes.some((t) => t.id === 'corp')).toBe(false); + expect(state.activeThemeId).toBeNull(); + expect(pluginStorage.deleteThemeCSS).toHaveBeenCalledWith('corp'); + }); + + it('keeps a theme the user installed themselves', async () => { + const mine: InstalledTheme = { ...CORP, id: 'mine', managed: false, forceEnabled: false }; + useThemeStore.setState((s) => ({ installedThemes: [...s.installedThemes, mine] })); + + await useThemeStore.getState().syncServerThemes(); + + expect(useThemeStore.getState().installedThemes.some((t) => t.id === 'mine')).toBe(true); + }); + + it('leaves everything alone when the server list could not be read', async () => { + const { apiFetch } = await import('@/lib/browser-navigation'); + vi.mocked(apiFetch).mockResolvedValueOnce(new Response('nope', { status: 500 })); + + await useThemeStore.getState().syncServerThemes(); + + expect(useThemeStore.getState().installedThemes.some((t) => t.id === 'corp')).toBe(true); + }); +}); diff --git a/lib/admin/extension-cleanup.ts b/lib/admin/extension-cleanup.ts new file mode 100644 index 000000000..052e4a2f8 --- /dev/null +++ b/lib/admin/extension-cleanup.ts @@ -0,0 +1,15 @@ +import { configManager } from './config-manager'; + +/** + * Drop a deleted plugin or theme from the policy lists that refer to it by + * id. Left behind, a later upload that reuses the id would start out forced + * on for everyone without the admin asking for it. + */ +export async function forgetExtensionInPolicy(kind: 'plugin' | 'theme', id: string): Promise { + await configManager.ensureLoaded(); + const policy = configManager.getPolicy(); + const key = kind === 'plugin' ? 'forceEnabledPlugins' : 'forceEnabledThemes'; + const list = policy[key] ?? []; + if (!list.includes(id)) return; + await configManager.setPolicy({ ...policy, [key]: list.filter((x) => x !== id) }); +} diff --git a/stores/theme-store.ts b/stores/theme-store.ts index e9ad9da78..3427dc5ea 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -418,6 +418,29 @@ export const useThemeStore = create()( } } + // A managed theme the server no longer lists was deleted or + // disabled by the admin. Without this it stayed installed, and + // its local forceEnabled flag kept forcing it on forever. + const serverIds = new Set(serverThemes.map(t => t.id)); + const stale = get().installedThemes.filter(t => t.managed && !t.builtIn && !serverIds.has(t.id)); + if (stale.length > 0) { + const staleIds = new Set(stale.map(t => t.id)); + const activeId = get().activeThemeId; + if (activeId && staleIds.has(activeId)) { + removeThemeCSS(); + removeThemeSkinCSS(); + set({ activeThemeId: null }); + } + for (const id of staleIds) { + void pluginStorage.deleteThemeCSS(id); + void pluginStorage.deleteThemeSkin(id); + void pluginStorage.deletePreview(id); + } + set(state => ({ + installedThemes: state.installedThemes.filter(t => !staleIds.has(t.id)), + })); + } + set(state => ({ installedThemes: dedupeInstalledThemes(state.installedThemes), }));