mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
fix: stop forcing a theme after the admin deletes or disables it
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user