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,