Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/(main)/[locale]/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,7 @@ export default function LoginPage() {
<Input
id="jmap-endpoint"
type="url"
dir="ltr"
value={jmapEndpoint}
onChange={(e) => setJmapEndpoint(e.target.value)}
className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
Expand All @@ -1078,6 +1079,7 @@ export default function LoginPage() {
ref={inputRef}
id="username"
type="text"
dir="ltr"
value={formData.username}
onChange={handleUsernameChange}
onFocus={handleUsernameFocus}
Expand Down Expand Up @@ -1131,6 +1133,7 @@ export default function LoginPage() {
<Input
id="password"
type={showPassword ? "text" : "password"}
dir="ltr"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
Expand Down Expand Up @@ -1181,6 +1184,7 @@ export default function LoginPage() {
ref={totpInputRef}
id="totp"
type="text"
dir="ltr"
inputMode="numeric"
maxLength={6}
value={totpCode}
Expand Down
1 change: 1 addition & 0 deletions app/(main)/admin/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export default function AdminLoginPage() {
<input
id="password"
type="password"
dir="ltr"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring"
Expand Down
60 changes: 55 additions & 5 deletions stores/theme-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ interface ThemeState {
// Custom theme system
installedThemes: InstalledTheme[];
activeThemeId: string | null; // null = built-in default
// Whether the user has ever explicitly picked a theme (including "Default")
// via activateTheme(). Distinguishes "chose Default on purpose" from "never
// decided yet" - both look like activeThemeId === null - so the admin's
// policy default can be applied to the latter without stomping the former.
themeChoiceMade: boolean;

setTheme: (theme: Theme) => void;
toggleTheme: () => void;
Expand Down Expand Up @@ -83,6 +88,7 @@ export const useThemeStore = create<ThemeState>()(
hydrated: false,
installedThemes: [...BUILTIN_THEMES],
activeThemeId: null,
themeChoiceMade: false,

setTheme: (theme) => {
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
Expand All @@ -105,15 +111,15 @@ export const useThemeStore = create<ThemeState>()(
},

initializeTheme: () => {
const { theme, activeThemeId, installedThemes } = get();
const { theme, activeThemeId, installedThemes, themeChoiceMade } = get();
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
applyTheme(resolvedTheme);
set({ resolvedTheme, hydrated: true });

// Determine effective theme: forced theme > user choice > policy default > none
const forcedThemeId = getForcedThemeId(installedThemes);
let effectiveThemeId = forcedThemeId ?? activeThemeId;
if (!effectiveThemeId) {
if (!effectiveThemeId && !themeChoiceMade) {
const policyState = usePolicyStore.getState();
const tp = policyState.policy.themePolicy;
if (tp?.defaultThemeId) {
Expand Down Expand Up @@ -148,6 +154,49 @@ export const useThemeStore = create<ThemeState>()(
}
}

// The admin's policy default (themePolicy.defaultThemeId) almost
// always loses the race above: /api/admin/policy is only fetched
// after /api/config resolves (see hooks/use-config.ts), so a
// brand-new user (activeThemeId still null, themeChoiceMade still
// false) boots into the built-in look every time, and that "no
// theme" state then gets persisted, making it permanent instead of
// self-correcting on the next load. Re-check once policy actually
// arrives, but only if the user still hasn't made their own choice
// in the meantime.
const applyPolicyDefaultIfPending = () => {
if (get().themeChoiceMade) return;
const current = get().installedThemes;
if (getForcedThemeId(current)) return;
const defaultId = usePolicyStore.getState().policy.themePolicy?.defaultThemeId;
if (!defaultId || get().activeThemeId === defaultId) return;
const t = current.find(it => it.id === defaultId);
if (!t) return;
if (t.css) {
applyCustomThemeCSS(t, get().resolvedTheme);
set({ activeThemeId: defaultId });
} else {
pluginStorage.getThemeCSS(defaultId).then(css => {
if (!css || get().themeChoiceMade) return;
const hydrated = { ...t, css };
applyCustomThemeCSS(hydrated, get().resolvedTheme);
set(state => ({
activeThemeId: defaultId,
installedThemes: state.installedThemes.map(it => it.id === defaultId ? hydrated : it),
}));
});
}
};

if (usePolicyStore.getState().loaded) {
applyPolicyDefaultIfPending();
} else {
const unsubscribe = usePolicyStore.subscribe((state) => {
if (!state.loaded) return;
unsubscribe();
applyPolicyDefaultIfPending();
});
}

// Clean up previous listener if any
if (mediaQueryCleanup) {
mediaQueryCleanup();
Expand Down Expand Up @@ -297,7 +346,7 @@ export const useThemeStore = create<ThemeState>()(
if (id === null) {
removeThemeCSS();
removeThemeSkinCSS();
set({ activeThemeId: null });
set({ activeThemeId: null, themeChoiceMade: true });
return;
}

Expand All @@ -317,12 +366,12 @@ export const useThemeStore = create<ThemeState>()(
),
}));
});
set({ activeThemeId: id });
set({ activeThemeId: id, themeChoiceMade: true });
return;
}

applyCustomThemeCSS(theme, resolvedTheme);
set({ activeThemeId: id });
set({ activeThemeId: id, themeChoiceMade: true });
},

syncServerThemes: async () => {
Expand Down Expand Up @@ -440,6 +489,7 @@ export const useThemeStore = create<ThemeState>()(
partialize: (state) => ({
theme: state.theme,
activeThemeId: state.activeThemeId,
themeChoiceMade: state.themeChoiceMade,
// Store theme metadata but NOT full CSS / skin (those go in IndexedDB)
installedThemes: state.installedThemes.map(t => ({
...t,
Expand Down