fix: stop forcing a theme after the admin deletes or disables it

This commit is contained in:
Linus Rath
2026-10-14 01:34:40 +02:00
parent 468c1aac0c
commit 0dd04ed21d
5 changed files with 152 additions and 0 deletions
@@ -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<string, unknown>;
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn(),
getPolicy: vi.fn(() => policy),
setPolicy: vi.fn(async (p: Record<string, unknown>) => { 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']);
});
});
+2
View File
@@ -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 });
+62
View File
@@ -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<typeof import('@/lib/browser-navigation')>()),
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);
});
});
+15
View File
@@ -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<void> {
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) });
}
+23
View File
@@ -418,6 +418,29 @@ export const useThemeStore = create<ThemeState>()(
}
}
// 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),
}));