fix: apply admin defaults for brand-new users, normalize chatDirection, wire entry points

- +layout.svelte always merges adminDefaults with whatever user UI is
  available (server-returned settings.ui, or localStorage, or {}). The
  previous branch skipped defaults entirely when getUserSettings()
  returned null, which is exactly the new-user case the feature
  targets — admin defaults never reached users who hadn't customized
  anything.
- InterfaceDefaultsForm.validate_chat_direction now accepts any casing
  and normalizes to the shape the frontend store expects ('auto'
  lowercase, 'LTR' / 'RTL' uppercase). The interface-settings toggle
  cycles uppercase values, so admin saves with LTR/RTL were rejected
  with 422 by the old lowercase-only validator.
- admin Settings/Interface.svelte adds the missing entry points: a
  "Configure Defaults" button that toggles showInterfaceDefaultsModal
  and a destructive "Reset All Users Interface Settings" button that
  toggles showResetConfirmDialog. The flags and bound components were
  already in place; nothing was flipping them.
- getInterfaceDefaults / setInterfaceDefaults / resetAllUsersInterfaceSettings
  fall back through err.detail / err.message / stringification so
  network-level failures (fetch TypeError, non-JSON body) surface as
  thrown errors instead of resolving to null and letting callers treat
  failed requests as silent successes.
This commit is contained in:
DrMelone 2026-04-13 00:32:35 +02:00
parent 6712c83cf6
commit e7249118da
5 changed files with 70 additions and 14 deletions

View file

@ -676,9 +676,19 @@ class InterfaceDefaultsForm(BaseModel):
@field_validator("chatDirection")
@classmethod
def validate_chat_direction(cls, value):
if value is not None and value not in ("auto", "ltr", "rtl"):
raise ValueError("chatDirection must be 'auto', 'ltr', or 'rtl'")
return value
# The user-side store is typed 'LTR' | 'RTL' | 'auto' and the
# Interface settings toggle cycles uppercase values, so accept any
# casing and normalize to the shape the frontend expects. Rejecting
# the uppercase values outright (as the validator did before) made
# the modal fail with a 422 on every LTR/RTL save.
if value is None:
return value
normalized = value.lower()
if normalized == "auto":
return "auto"
if normalized in ("ltr", "rtl"):
return normalized.upper()
raise ValueError("chatDirection must be 'auto', 'LTR', or 'RTL'")
@field_validator("textScale")
@classmethod

View file

@ -664,7 +664,12 @@ export const getInterfaceDefaults = async (token: string) => {
})
.catch((err) => {
console.error(err);
error = err.detail;
// Fall back through detail / message / stringification so a
// network TypeError or non-JSON response still produces a truthy
// error — otherwise the function silently returned null and the
// caller couldn't tell a failed fetch from an empty-defaults
// response.
error = err?.detail ?? err?.message ?? (typeof err === 'string' ? err : 'Request failed');
return null;
});
@ -692,7 +697,7 @@ export const setInterfaceDefaults = async (token: string, defaults: object) => {
})
.catch((err) => {
console.error(err);
error = err.detail;
error = err?.detail ?? err?.message ?? (typeof err === 'string' ? err : 'Request failed');
return null;
});

View file

@ -567,7 +567,11 @@ export const resetAllUsersInterfaceSettings = async (token: string) => {
})
.catch((err) => {
console.error(err);
error = err.detail;
// Fall back through detail / message / stringification so a
// network TypeError or non-JSON response still produces a truthy
// error — otherwise the function silently returned null and the
// caller could mistake a failed reset for a silent success.
error = err?.detail ?? err?.message ?? (typeof err === 'string' ? err : 'Request failed');
return null;
});

View file

@ -114,6 +114,42 @@
}}
>
<div class=" overflow-y-scroll scrollbar-hidden h-full pr-1.5">
<div class="mb-3.5">
<div class=" mt-0.5 mb-2.5 text-base font-medium">
{$i18n.t('Interface Defaults')}
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
{$i18n.t(
"Configure default interface settings that apply to all users who haven't customized their own."
)}
</div>
<div class="flex flex-col sm:flex-row gap-2">
<button
type="button"
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-gray-800 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
on:click={() => {
showInterfaceDefaultsModal = true;
}}
>
{$i18n.t('Configure Defaults')}
</button>
<button
type="button"
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-red-300 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 transition"
on:click={() => {
showResetConfirmDialog = true;
}}
>
{$i18n.t('Reset All Users Interface Settings')}
</button>
</div>
</div>
<div class="mb-3.5">
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Tasks')}</div>

View file

@ -929,14 +929,15 @@
})
]);
// Admin defaults are base, user settings override.
const effectiveSettings = deepMerge(adminDefaults ?? {}, userSettings?.ui ?? {});
if (userSettings) {
settings.set(effectiveSettings);
} else {
settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
}
// Admin defaults are the base layer; whatever the user has
// actually customized overrides them. For brand-new users
// getUserSettings() returns null — the whole point of admin
// defaults is that THOSE users get them, so always merge
// against adminDefaults instead of skipping straight to
// localStorage when userSettings is falsy.
const localStorageSettings = JSON.parse(localStorage.getItem('settings') ?? '{}');
const userUI = userSettings?.ui ?? localStorageSettings ?? {};
settings.set(deepMerge(adminDefaults ?? {}, userUI));
setTextScale($settings?.textScale ?? 1);
// Set up the token expiry check