feat: fix: enforce admin theme locks and repair theme ZIP bundles
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
|
||||
return {
|
||||
id: overrides.id ?? 'inbox',
|
||||
name: overrides.name ?? 'Inbox',
|
||||
sortOrder: 0,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
},
|
||||
isSubscribed: true,
|
||||
isShared: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEmail(id: string, threadId: string, mailboxId = 'inbox'): Email {
|
||||
return {
|
||||
id,
|
||||
threadId,
|
||||
mailboxIds: { [mailboxId]: true },
|
||||
keywords: {},
|
||||
size: 100,
|
||||
receivedAt: new Date().toISOString(),
|
||||
from: [{ name: 'Test', email: 'test@example.com' }],
|
||||
to: [{ name: 'User', email: 'user@example.com' }],
|
||||
subject: `Email ${id}`,
|
||||
preview: 'preview',
|
||||
hasAttachment: false,
|
||||
textBody: [],
|
||||
htmlBody: [],
|
||||
bodyValues: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('email-store archive thread behavior', () => {
|
||||
beforeEach(() => {
|
||||
const inbox = makeMailbox({ id: 'shared-inbox', role: 'inbox', isShared: true, accountId: 'shared-account', originalId: 'server-inbox' });
|
||||
const archive = makeMailbox({ id: 'archive-local', role: 'archive', accountId: 'shared-account', originalId: 'server-archive' });
|
||||
const threadEmailA = makeEmail('email-1', 'thread-1', 'shared-inbox');
|
||||
const threadEmailB = makeEmail('email-2', 'thread-1', 'shared-inbox');
|
||||
const otherEmail = makeEmail('email-3', 'thread-2', 'shared-inbox');
|
||||
|
||||
useEmailStore.setState({
|
||||
emails: [threadEmailA, threadEmailB, otherEmail],
|
||||
mailboxes: [inbox, archive],
|
||||
selectedMailbox: 'shared-inbox',
|
||||
selectedEmail: threadEmailA,
|
||||
selectedEmailIds: new Set(['email-1', 'email-2']),
|
||||
expandedThreadIds: new Set(['thread-1']),
|
||||
threadEmailsCache: new Map([['thread-1', [threadEmailA, threadEmailB]]]),
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('archives the full thread and removes cached thread state', async () => {
|
||||
const client = {
|
||||
getThread: vi.fn().mockResolvedValue({ id: 'thread-1', emailIds: ['email-1', 'email-2'] }),
|
||||
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IJMAPClient;
|
||||
|
||||
await useEmailStore.getState().moveThreadToMailbox(client, 'email-1', 'archive-local');
|
||||
|
||||
expect(client.getThread).toHaveBeenCalledWith('thread-1', 'shared-account');
|
||||
expect(client.batchMoveEmails).toHaveBeenCalledWith(['email-1', 'email-2'], 'server-archive', 'shared-account');
|
||||
|
||||
const state = useEmailStore.getState();
|
||||
expect(state.emails.map(email => email.id)).toEqual(['email-3']);
|
||||
expect(state.selectedEmail?.id).toBe('email-3');
|
||||
expect(Array.from(state.selectedEmailIds)).toEqual([]);
|
||||
expect(state.expandedThreadIds.has('thread-1')).toBe(false);
|
||||
expect(state.threadEmailsCache.has('thread-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
+79
-6
@@ -67,6 +67,7 @@ interface EmailStore {
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
||||
advancedSearch: (client: IJMAPClient) => Promise<void>;
|
||||
setSearchFilters: (filters: Partial<SearchFilters>) => void;
|
||||
@@ -111,16 +112,35 @@ interface EmailStore {
|
||||
}
|
||||
|
||||
// Helper: compute the next email to select when removing one from the list
|
||||
function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailId: string): Email | null {
|
||||
if (state.selectedEmail?.id !== removedEmailId) return state.selectedEmail;
|
||||
const idx = state.emails.findIndex(e => e.id === removedEmailId);
|
||||
function getNextSelectedEmailAfterRemoval(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailIds: Set<string>): Email | null {
|
||||
if (!state.selectedEmail || !removedEmailIds.has(state.selectedEmail.id)) {
|
||||
return state.selectedEmail;
|
||||
}
|
||||
|
||||
const idx = state.emails.findIndex(e => e.id === state.selectedEmail?.id);
|
||||
if (idx === -1) return null;
|
||||
// Prefer next email, fall back to previous
|
||||
if (idx < state.emails.length - 1) return state.emails[idx + 1];
|
||||
if (idx > 0) return state.emails[idx - 1];
|
||||
|
||||
for (let nextIndex = idx + 1; nextIndex < state.emails.length; nextIndex++) {
|
||||
const candidate = state.emails[nextIndex];
|
||||
if (!removedEmailIds.has(candidate.id)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (let prevIndex = idx - 1; prevIndex >= 0; prevIndex--) {
|
||||
const candidate = state.emails[prevIndex];
|
||||
if (!removedEmailIds.has(candidate.id)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailId: string): Email | null {
|
||||
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
|
||||
}
|
||||
|
||||
export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
emails: [],
|
||||
mailboxes: [],
|
||||
@@ -703,6 +723,59 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
moveThreadToMailbox: async (client, emailId, destinationMailboxId) => {
|
||||
try {
|
||||
const state = get();
|
||||
const email = state.emails.find(e => e.id === emailId)
|
||||
?? (state.selectedEmail?.id === emailId ? state.selectedEmail : null);
|
||||
|
||||
if (!email?.threadId) {
|
||||
await get().moveToMailbox(client, emailId, destinationMailboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMailbox = state.mailboxes.find(mb => mb.id === state.selectedMailbox);
|
||||
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||||
const destMailbox = state.mailboxes.find(mb => mb.id === destinationMailboxId);
|
||||
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
||||
|
||||
const thread = await client.getThread(email.threadId, accountId);
|
||||
const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId];
|
||||
|
||||
if (threadEmailIds.length <= 1) {
|
||||
await get().moveToMailbox(client, emailId, destinationMailboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
|
||||
|
||||
const removedEmailIds = new Set(threadEmailIds);
|
||||
set((currentState) => {
|
||||
const nextSelectedEmail = getNextSelectedEmailAfterRemoval(currentState, removedEmailIds);
|
||||
const nextSelectedEmailIds = new Set(
|
||||
Array.from(currentState.selectedEmailIds).filter(id => !removedEmailIds.has(id))
|
||||
);
|
||||
const nextExpandedThreadIds = new Set(currentState.expandedThreadIds);
|
||||
nextExpandedThreadIds.delete(email.threadId);
|
||||
const nextThreadEmailsCache = new Map(currentState.threadEmailsCache);
|
||||
nextThreadEmailsCache.delete(email.threadId);
|
||||
|
||||
return {
|
||||
emails: currentState.emails.filter(currentEmail => !removedEmailIds.has(currentEmail.id)),
|
||||
selectedEmail: nextSelectedEmail,
|
||||
selectedEmailIds: nextSelectedEmailIds,
|
||||
expandedThreadIds: nextExpandedThreadIds,
|
||||
threadEmailsCache: nextThreadEmailsCache,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to move email thread"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
searchEmails: async (client, query) => {
|
||||
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface PolicyState {
|
||||
getRestriction: (key: string) => SettingRestriction | undefined;
|
||||
getEffectiveDefault: (key: string) => unknown;
|
||||
getThemePolicy: () => ThemePolicy;
|
||||
getForcedThemeId: (availableThemeIds?: string[]) => string | null;
|
||||
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
||||
isPluginForceEnabled: (pluginId: string) => boolean;
|
||||
isThemeForceEnabled: (themeId: string) => boolean;
|
||||
@@ -61,6 +62,16 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
||||
return get().policy.themePolicy || { ...DEFAULT_THEME_POLICY };
|
||||
},
|
||||
|
||||
getForcedThemeId: (availableThemeIds) => {
|
||||
const forceEnabledThemes = get().policy.forceEnabledThemes || [];
|
||||
if (!availableThemeIds || availableThemeIds.length === 0) {
|
||||
return forceEnabledThemes[0] || null;
|
||||
}
|
||||
|
||||
const available = new Set(availableThemeIds);
|
||||
return forceEnabledThemes.find((themeId) => available.has(themeId)) || null;
|
||||
},
|
||||
|
||||
isThemeDisabled: (themeId, isBuiltIn) => {
|
||||
const tp = get().policy.themePolicy || DEFAULT_THEME_POLICY;
|
||||
if (isBuiltIn) {
|
||||
|
||||
+76
-17
@@ -9,6 +9,18 @@ import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
function getForcedThemeId(installedThemes: InstalledTheme[]): string | null {
|
||||
const policyForcedThemeId = usePolicyStore
|
||||
.getState()
|
||||
.getForcedThemeId(installedThemes.map((theme) => theme.id));
|
||||
|
||||
if (policyForcedThemeId) {
|
||||
return policyForcedThemeId;
|
||||
}
|
||||
|
||||
return installedThemes.find((theme) => theme.forceEnabled)?.id ?? null;
|
||||
}
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
@@ -90,18 +102,21 @@ export const useThemeStore = create<ThemeState>()(
|
||||
applyTheme(resolvedTheme);
|
||||
set({ resolvedTheme, hydrated: true });
|
||||
|
||||
// Determine effective theme: user choice > policy default > none
|
||||
let effectiveThemeId = activeThemeId;
|
||||
// Determine effective theme: forced theme > user choice > policy default > none
|
||||
const forcedThemeId = getForcedThemeId(installedThemes);
|
||||
let effectiveThemeId = forcedThemeId ?? activeThemeId;
|
||||
if (!effectiveThemeId) {
|
||||
const policyState = usePolicyStore.getState();
|
||||
const tp = policyState.policy.themePolicy;
|
||||
if (tp?.defaultThemeId) {
|
||||
effectiveThemeId = tp.defaultThemeId;
|
||||
// Persist so we don't re-check every time
|
||||
set({ activeThemeId: effectiveThemeId });
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveThemeId !== activeThemeId) {
|
||||
set({ activeThemeId: effectiveThemeId });
|
||||
}
|
||||
|
||||
// Apply active custom theme on boot
|
||||
if (effectiveThemeId) {
|
||||
const t = installedThemes.find(t => t.id === effectiveThemeId);
|
||||
@@ -215,6 +230,8 @@ export const useThemeStore = create<ThemeState>()(
|
||||
const { installedThemes, activeThemeId } = get();
|
||||
const theme = installedThemes.find(t => t.id === id);
|
||||
if (!theme || theme.builtIn) return;
|
||||
const forceEnabledByPolicy = usePolicyStore.getState().isThemeForceEnabled(id);
|
||||
if (theme.forceEnabled || forceEnabledByPolicy) return;
|
||||
|
||||
// Deactivate if active
|
||||
if (activeThemeId === id) {
|
||||
@@ -232,16 +249,44 @@ export const useThemeStore = create<ThemeState>()(
|
||||
},
|
||||
|
||||
activateTheme: (id: string | null) => {
|
||||
const { installedThemes, resolvedTheme } = get();
|
||||
const forcedThemeId = getForcedThemeId(installedThemes);
|
||||
|
||||
if (forcedThemeId && id !== forcedThemeId) {
|
||||
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
|
||||
if (forcedTheme) {
|
||||
applyCustomThemeCSS(forcedTheme, resolvedTheme);
|
||||
set({ activeThemeId: forcedThemeId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === null) {
|
||||
removeThemeCSS();
|
||||
set({ activeThemeId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const { installedThemes, resolvedTheme } = get();
|
||||
const theme = installedThemes.find(t => t.id === id);
|
||||
if (!theme) return;
|
||||
|
||||
if (!theme.css) {
|
||||
pluginStorage.getThemeCSS(id).then((css) => {
|
||||
if (!css) return;
|
||||
|
||||
const hydratedTheme = { ...theme, css };
|
||||
applyCustomThemeCSS(hydratedTheme, get().resolvedTheme);
|
||||
set((state) => ({
|
||||
activeThemeId: id,
|
||||
installedThemes: state.installedThemes.map((item) =>
|
||||
item.id === id ? { ...item, css } : item
|
||||
),
|
||||
}));
|
||||
});
|
||||
set({ activeThemeId: id });
|
||||
return;
|
||||
}
|
||||
|
||||
applyCustomThemeCSS(theme, resolvedTheme);
|
||||
set({ activeThemeId: id });
|
||||
},
|
||||
@@ -281,6 +326,8 @@ export const useThemeStore = create<ThemeState>()(
|
||||
variants: st.variants as ThemeVariant[],
|
||||
enabled: true,
|
||||
builtIn: false,
|
||||
managed: true,
|
||||
forceEnabled: st.forceEnabled,
|
||||
};
|
||||
|
||||
await pluginStorage.saveThemeCSS(st.id, sanitized.css);
|
||||
@@ -293,18 +340,17 @@ export const useThemeStore = create<ThemeState>()(
|
||||
};
|
||||
});
|
||||
|
||||
// If this is force-enabled and no theme is active, activate it
|
||||
if (st.forceEnabled && !get().activeThemeId) {
|
||||
applyCustomThemeCSS(theme, get().resolvedTheme);
|
||||
set({ activeThemeId: st.id });
|
||||
}
|
||||
} else if (!local.builtIn && local.version !== st.version) {
|
||||
// Version changed — re-download CSS
|
||||
const css = await downloadThemeCSS(st.id);
|
||||
if (!css) continue;
|
||||
} else if (!local.builtIn) {
|
||||
let css = local.css;
|
||||
|
||||
const sanitized = sanitizeThemeCSS(css);
|
||||
await pluginStorage.saveThemeCSS(st.id, sanitized.css);
|
||||
if (local.version !== st.version || !css) {
|
||||
const downloadedCss = await downloadThemeCSS(st.id);
|
||||
if (!downloadedCss) continue;
|
||||
|
||||
const sanitized = sanitizeThemeCSS(downloadedCss);
|
||||
css = sanitized.css;
|
||||
await pluginStorage.saveThemeCSS(st.id, sanitized.css);
|
||||
}
|
||||
|
||||
const updatedTheme = {
|
||||
...local,
|
||||
@@ -312,8 +358,10 @@ export const useThemeStore = create<ThemeState>()(
|
||||
version: st.version,
|
||||
author: st.author,
|
||||
description: st.description || '',
|
||||
css: sanitized.css,
|
||||
css,
|
||||
variants: st.variants as ThemeVariant[],
|
||||
managed: true,
|
||||
forceEnabled: st.forceEnabled,
|
||||
};
|
||||
|
||||
set(state => ({
|
||||
@@ -332,6 +380,15 @@ export const useThemeStore = create<ThemeState>()(
|
||||
set(state => ({
|
||||
installedThemes: dedupeInstalledThemes(state.installedThemes),
|
||||
}));
|
||||
|
||||
const forcedThemeId = getForcedThemeId(get().installedThemes);
|
||||
if (forcedThemeId && get().activeThemeId !== forcedThemeId) {
|
||||
const forcedTheme = get().installedThemes.find((theme) => theme.id === forcedThemeId);
|
||||
if (forcedTheme) {
|
||||
applyCustomThemeCSS(forcedTheme, get().resolvedTheme);
|
||||
set({ activeThemeId: forcedThemeId });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn('[theme-store] Server theme sync failed');
|
||||
}
|
||||
@@ -413,6 +470,8 @@ function dedupeInstalledThemes(themes: InstalledTheme[]): InstalledTheme[] {
|
||||
...theme,
|
||||
builtIn: existing.builtIn || theme.builtIn,
|
||||
enabled: existing.enabled || theme.enabled,
|
||||
managed: existing.managed || theme.managed,
|
||||
forceEnabled: theme.forceEnabled ?? existing.forceEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user