diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index fbddc90e2..1038e90ed 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1056,6 +1056,7 @@ export default function LoginPage() { 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" @@ -1078,6 +1079,7 @@ export default function LoginPage() { ref={inputRef} id="username" type="text" + dir="ltr" value={formData.username} onChange={handleUsernameChange} onFocus={handleUsernameFocus} @@ -1131,6 +1133,7 @@ export default function LoginPage() { 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" @@ -1181,6 +1184,7 @@ export default function LoginPage() { ref={totpInputRef} id="totp" type="text" + dir="ltr" inputMode="numeric" maxLength={6} value={totpCode} diff --git a/app/(main)/admin/login/page.tsx b/app/(main)/admin/login/page.tsx index 018777d46..da4bd8bac 100644 --- a/app/(main)/admin/login/page.tsx +++ b/app/(main)/admin/login/page.tsx @@ -66,6 +66,7 @@ export default function AdminLoginPage() { 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" diff --git a/stores/theme-store.ts b/stores/theme-store.ts index 1313ca606..a49aed589 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -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; @@ -83,6 +88,7 @@ export const useThemeStore = create()( hydrated: false, installedThemes: [...BUILTIN_THEMES], activeThemeId: null, + themeChoiceMade: false, setTheme: (theme) => { const resolvedTheme = theme === 'system' ? getSystemTheme() : theme; @@ -105,7 +111,7 @@ export const useThemeStore = create()( }, initializeTheme: () => { - const { theme, activeThemeId, installedThemes } = get(); + const { theme, activeThemeId, installedThemes, themeChoiceMade } = get(); const resolvedTheme = theme === 'system' ? getSystemTheme() : theme; applyTheme(resolvedTheme); set({ resolvedTheme, hydrated: true }); @@ -113,7 +119,7 @@ export const useThemeStore = create()( // 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) { @@ -148,6 +154,49 @@ export const useThemeStore = create()( } } + // 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(); @@ -297,7 +346,7 @@ export const useThemeStore = create()( if (id === null) { removeThemeCSS(); removeThemeSkinCSS(); - set({ activeThemeId: null }); + set({ activeThemeId: null, themeChoiceMade: true }); return; } @@ -317,12 +366,12 @@ export const useThemeStore = create()( ), })); }); - set({ activeThemeId: id }); + set({ activeThemeId: id, themeChoiceMade: true }); return; } applyCustomThemeCSS(theme, resolvedTheme); - set({ activeThemeId: id }); + set({ activeThemeId: id, themeChoiceMade: true }); }, syncServerThemes: async () => { @@ -440,6 +489,7 @@ export const useThemeStore = create()( 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,