fix(front): clean localStorage on user change
- fix issue with selected drive and user logout/login via another user
(was raising a "404 not found")
- normalize all localStorage to "oxi-" prefix
- add a specific frontend/AGENTS.md for frontend part (stop increasing the global AGENTS.md)
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# AGENTS.md — Frontend
|
||||
|
||||
Complements the repo-root `/AGENTS.md`. Not shipped (adapter-static
|
||||
copies only `frontend/static/`).
|
||||
|
||||
## localStorage keys
|
||||
|
||||
Prefix `oxi-`, kebab-case separators. Example: `oxi-view-mode`.
|
||||
Enforced by `$lib/utils/localStoragePrefs::wipeAppKeys()` which sweeps
|
||||
every `oxi-*` key on user-account switches — any other prefix leaks the
|
||||
previous user's state into the new one.
|
||||
+11
-4
@@ -8,9 +8,16 @@
|
||||
%sveltekit.head%
|
||||
<!--
|
||||
Anti-FOUC theme init — runs synchronously before first paint.
|
||||
Ported from static/js/core/theme-init.js. Keeps the legacy
|
||||
`oxicloud_theme` localStorage key and `data-color-scheme` attribute
|
||||
so existing users keep their preference across the migration.
|
||||
Reads the `oxi-theme` localStorage key (part of the normalised
|
||||
`oxi-*` prefs namespace, see $lib/utils/localStoragePrefs) and
|
||||
reflects it on `<html data-color-scheme>`.
|
||||
|
||||
KEEP IN SYNC: the 'oxi-theme' string below MUST match
|
||||
`THEME_STORAGE_KEY` exported from
|
||||
$lib/stores/theme.svelte.ts. The inline script runs before
|
||||
any JS bundle loads, so it can't `import` the constant. A
|
||||
drift check in $lib/stores/theme.test.ts reads this file
|
||||
and fails CI if the two get out of sync.
|
||||
|
||||
Placed AFTER %sveltekit.head% so it follows the CSP <meta> SvelteKit
|
||||
injects there, hence it IS governed by that policy. svelte.config.js
|
||||
@@ -21,7 +28,7 @@
|
||||
<script id="theme-init">
|
||||
(function () {
|
||||
try {
|
||||
var s = localStorage.getItem('oxicloud_theme');
|
||||
var s = localStorage.getItem('oxi-theme');
|
||||
var h = document.documentElement;
|
||||
if (s === 'light' || s === 'dark') h.setAttribute('data-color-scheme', s);
|
||||
else h.removeAttribute('data-color-scheme');
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('initI18n — lazy English fallback', () => {
|
||||
let resolveEn: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('oxicloud-locale', 'es');
|
||||
localStorage.setItem('oxi-locale', 'es');
|
||||
resolveEn = () => {};
|
||||
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
@@ -71,7 +71,7 @@ export const LANGUAGES: readonly LanguageMeta[] = [
|
||||
{ code: 'pl', name: 'Polski', flag: '🇵🇱' }
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'oxicloud-locale';
|
||||
const STORAGE_KEY = 'oxi-locale';
|
||||
|
||||
/**
|
||||
* Reflect the active locale on `<html>`: sets `lang` and flips `dir` to `rtl`
|
||||
|
||||
@@ -72,7 +72,7 @@ export type Section =
|
||||
| 'photos'
|
||||
| 'music';
|
||||
|
||||
const VIEW_KEY = 'oxicloud_view_mode';
|
||||
const VIEW_KEY = 'oxi-view-mode';
|
||||
|
||||
function readViewMode(): ViewMode {
|
||||
if (typeof localStorage === 'undefined') return 'grid';
|
||||
|
||||
@@ -30,7 +30,7 @@ it('shows the owner as "Me" for the current user and a short id otherwise', () =
|
||||
it('persists the view mode and toggles selection', () => {
|
||||
files.setViewMode('list');
|
||||
expect(files.viewMode).toBe('list');
|
||||
expect(localStorage.getItem('oxicloud_view_mode')).toBe('list');
|
||||
expect(localStorage.getItem('oxi-view-mode')).toBe('list');
|
||||
files.setViewMode('grid');
|
||||
expect(files.viewMode).toBe('grid');
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { fetchMe, tryRefresh } from '$lib/api/endpoints/auth';
|
||||
import { drives } from '$lib/stores/drives.svelte';
|
||||
import type { User } from '$lib/api/types';
|
||||
import { ensureActiveUser } from '$lib/utils/localStoragePrefs';
|
||||
|
||||
class SessionStore {
|
||||
user = $state<User | null>(null);
|
||||
@@ -32,7 +33,8 @@ class SessionStore {
|
||||
if (!me && (await tryRefresh())) {
|
||||
me = await fetchMe();
|
||||
}
|
||||
this.user = me;
|
||||
if (me) this.setUser(me);
|
||||
else this.user = null;
|
||||
} catch {
|
||||
this.user = null;
|
||||
}
|
||||
@@ -40,6 +42,19 @@ class SessionStore {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the authenticated user AND run per-user localStorage cleanup
|
||||
* (see `$lib/utils/localStoragePrefs::ensureActiveUser`). Direct
|
||||
* `session.user = …` assignments skip the cleanup — always call
|
||||
* `setUser` on login-flow entry points (form login, OIDC exchange,
|
||||
* existing-session probe) so a switch-account flow inside the same
|
||||
* tab observes the wipe.
|
||||
*/
|
||||
setUser(user: User): void {
|
||||
this.user = user;
|
||||
ensureActiveUser(user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch the authenticated user from the server, bypassing the one-shot
|
||||
* `load()` cache. Call after operations that change server-side user state —
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
/**
|
||||
* Theme store — light / dark / auto.
|
||||
*
|
||||
* Mirrors the established behaviour: persists to the `oxicloud_theme` localStorage
|
||||
* key and reflects the choice on `<html data-color-scheme>`. `auto` removes the
|
||||
* attribute so the OS `prefers-color-scheme` takes over. The anti-FOUC inline
|
||||
* script in app.html applies the stored value before first paint; this store
|
||||
* owns runtime changes from the UI.
|
||||
* Persists to the `oxi-theme` localStorage key (part of the normalised
|
||||
* `oxi-*` prefs namespace — see `$lib/utils/localStoragePrefs`) and
|
||||
* reflects the choice on `<html data-color-scheme>`. `auto` removes the
|
||||
* attribute so the OS `prefers-color-scheme` takes over. The anti-FOUC
|
||||
* inline script in app.html applies the stored value before first paint;
|
||||
* this store owns runtime changes from the UI.
|
||||
*/
|
||||
export type Theme = 'light' | 'dark' | 'auto';
|
||||
|
||||
const STORAGE_KEY = 'oxicloud_theme';
|
||||
/**
|
||||
* localStorage key holding the active theme.
|
||||
*
|
||||
* Exported (not just module-private) because `src/app.html`'s anti-FOUC
|
||||
* inline script also reads it — that script runs before any JS bundle
|
||||
* loads, so it can't `import` here. The `app.html` copy is a hardcoded
|
||||
* string kept in sync by `theme.test.ts::app.html theme key matches
|
||||
* THEME_STORAGE_KEY`, which fails CI on any drift.
|
||||
*/
|
||||
export const THEME_STORAGE_KEY = 'oxi-theme';
|
||||
|
||||
function readInitial(): Theme {
|
||||
if (typeof localStorage === 'undefined') return 'auto';
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
const v = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return v === 'light' || v === 'dark' ? v : 'auto';
|
||||
}
|
||||
|
||||
@@ -29,8 +39,8 @@ function apply(theme: Theme): void {
|
||||
export function setTheme(theme: Theme): void {
|
||||
store.theme = theme;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
if (theme === 'auto') localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, theme);
|
||||
if (theme === 'auto') localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
else localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
apply(theme);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { theme, setTheme } from './theme.svelte';
|
||||
import { theme, setTheme, THEME_STORAGE_KEY } from './theme.svelte';
|
||||
|
||||
describe('theme store', () => {
|
||||
beforeEach(() => {
|
||||
@@ -9,21 +11,39 @@ describe('theme store', () => {
|
||||
it('sets light/dark, persists, and reflects on <html>', () => {
|
||||
setTheme('light');
|
||||
expect(theme.current).toBe('light');
|
||||
expect(localStorage.getItem('oxicloud_theme')).toBe('light');
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
|
||||
setTheme('dark');
|
||||
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
|
||||
expect(localStorage.getItem('oxicloud_theme')).toBe('dark');
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark');
|
||||
});
|
||||
it('auto clears storage and removes the attribute', () => {
|
||||
setTheme('dark');
|
||||
setTheme('auto');
|
||||
expect(theme.current).toBe('auto');
|
||||
expect(localStorage.getItem('oxicloud_theme')).toBeNull();
|
||||
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBeNull();
|
||||
expect(document.documentElement.hasAttribute('data-color-scheme')).toBe(false);
|
||||
});
|
||||
it('theme.set is an alias for setTheme', () => {
|
||||
theme.set('light');
|
||||
expect(theme.current).toBe('light');
|
||||
});
|
||||
|
||||
// Drift guard: `src/app.html` inlines an anti-FOUC theme reader that
|
||||
// reads the SAME localStorage key. Because that script runs before
|
||||
// any JS bundle loads, it can't `import { THEME_STORAGE_KEY }`;
|
||||
// the key is hardcoded there. This test reads the file verbatim
|
||||
// and refuses drift.
|
||||
it('app.html theme key matches THEME_STORAGE_KEY', () => {
|
||||
// Resolve against Vitest's cwd (the `frontend/` dir per its
|
||||
// invocation) — jsdom rewrites `import.meta.url` to `http://…`,
|
||||
// so file-URL conversion doesn't work in this environment.
|
||||
const appHtmlPath = resolve('src/app.html');
|
||||
const html = readFileSync(appHtmlPath, 'utf-8');
|
||||
expect(
|
||||
html.includes(`localStorage.getItem('${THEME_STORAGE_KEY}')`),
|
||||
`app.html must call localStorage.getItem('${THEME_STORAGE_KEY}') — ` +
|
||||
`update the inline script when THEME_STORAGE_KEY changes.`
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Local-storage prefs — key convention + user-scoped cleanup.
|
||||
*
|
||||
* # Key convention
|
||||
*
|
||||
* Every persistent client-side preference lives under the `oxi-` prefix.
|
||||
* Historical mix of `oxicloud_*`, `oxicloud-*`, and `oxi-*` normalised to
|
||||
* one form so `wipeAppKeys()` below can sweep the whole set with a single
|
||||
* `startsWith('oxi-')` predicate.
|
||||
*
|
||||
* # Nuke-on-mismatch at login
|
||||
*
|
||||
* `ensureActiveUser(userId)` compares the newly-authenticated user id
|
||||
* against the stored `oxi-active-user-id`. If they differ, EVERY `oxi-*`
|
||||
* key is removed (except the active-user marker itself). This runs on:
|
||||
* * first login after page load,
|
||||
* * "switch account" flows where the current tab silently changes user,
|
||||
* * session expiry then re-login as someone else.
|
||||
*
|
||||
* The wipe is intentionally broad — one naming convention beats maintaining
|
||||
* a per-key whitelist that decays as new preferences get added.
|
||||
*
|
||||
* # Not stored here
|
||||
*
|
||||
* Auth tokens and CSRF cookies do NOT use `oxi-*` keys — they live in
|
||||
* HTTP-only cookies set by the backend and are outside localStorage.
|
||||
* Nothing to wipe there.
|
||||
*/
|
||||
|
||||
/** Marker key: which user's prefs currently live in localStorage. */
|
||||
const ACTIVE_USER_KEY = 'oxi-active-user-id';
|
||||
|
||||
/** Every persistent client pref key must start with this. */
|
||||
const OXI_PREFIX = 'oxi-';
|
||||
|
||||
/**
|
||||
* Remove every `oxi-*` key from localStorage EXCEPT the active-user marker.
|
||||
* Idempotent; no-op when localStorage is unavailable (SSR, private mode).
|
||||
*/
|
||||
export function wipeAppKeys(): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
// Materialise the key list first — mutating localStorage while iterating
|
||||
// its live view skips half the entries.
|
||||
const keys = Object.keys(localStorage);
|
||||
for (const key of keys) {
|
||||
if (key === ACTIVE_USER_KEY) continue;
|
||||
if (key.startsWith(OXI_PREFIX)) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* private mode / quota — best-effort cleanup */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure any localStorage state belongs to `userId`. When the marker
|
||||
* doesn't match — first login of the page, re-login as someone else,
|
||||
* or a fossil from a previous release with no marker — the whole app
|
||||
* key namespace is nuked and the marker is set to the current user.
|
||||
*
|
||||
* Call once, right after the session store observes an authenticated
|
||||
* user. Cheap when no work is needed (single `getItem`).
|
||||
*/
|
||||
export function ensureActiveUser(userId: string): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = localStorage.getItem(ACTIVE_USER_KEY);
|
||||
} catch {
|
||||
/* private mode — treat as "no marker" so we do the cleanup pass */
|
||||
}
|
||||
if (stored === userId) return;
|
||||
wipeAppKeys();
|
||||
try {
|
||||
localStorage.setItem(ACTIVE_USER_KEY, userId);
|
||||
} catch {
|
||||
/* private mode — cleanup still ran, marker will re-attempt next login */
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@
|
||||
);
|
||||
return;
|
||||
}
|
||||
session.user = data.user;
|
||||
session.setUser(data.user);
|
||||
await goto(resolve(redirectTarget), { replaceState: true });
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in');
|
||||
@@ -203,7 +203,7 @@
|
||||
if (oidcCode) {
|
||||
const user = await exchangeOidcCode(oidcCode);
|
||||
if (user) {
|
||||
session.user = user;
|
||||
session.setUser(user);
|
||||
await goto(resolve(redirectTarget), { replaceState: true });
|
||||
return;
|
||||
}
|
||||
@@ -214,7 +214,7 @@
|
||||
try {
|
||||
const me = await fetchMe();
|
||||
if (me) {
|
||||
session.user = me;
|
||||
session.setUser(me);
|
||||
await goto(resolve(redirectTarget), { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { goto, pageState, session } = vi.hoisted(() => ({
|
||||
goto: vi.fn(),
|
||||
pageState: { url: new URL('http://localhost/login') } as { url: URL },
|
||||
session: { user: null } as { user: unknown }
|
||||
}));
|
||||
const { goto, pageState, session } = vi.hoisted(() => {
|
||||
// `setUser` mirrors the real SessionStore method: sets the user and
|
||||
// runs `ensureActiveUser` (localStorage cleanup on account switch).
|
||||
// Tests don't care about the cleanup; the mock just assigns.
|
||||
const store: { user: unknown; setUser: (u: unknown) => void } = {
|
||||
user: null,
|
||||
setUser(u) {
|
||||
store.user = u;
|
||||
}
|
||||
};
|
||||
return {
|
||||
goto: vi.fn(),
|
||||
pageState: { url: new URL('http://localhost/login') } as { url: URL },
|
||||
session: store
|
||||
};
|
||||
});
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
|
||||
type GroupMode = 'day' | 'month' | 'year';
|
||||
type LayoutMode = 'square' | 'justified';
|
||||
const GROUP_KEY = 'oxicloud-photos-group';
|
||||
const LAYOUT_KEY = 'oxicloud-photos-layout';
|
||||
const GROUP_KEY = 'oxi-photos-group';
|
||||
const LAYOUT_KEY = 'oxi-photos-layout';
|
||||
let groupMode = $state<GroupMode>('month');
|
||||
let layoutMode = $state<LayoutMode>('square');
|
||||
const selected = useSelection();
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
type Crumb = { id?: string; name: string };
|
||||
type ViewMode = 'grid' | 'list';
|
||||
|
||||
const VIEW_KEY = 'oxicloud_share_view';
|
||||
const VIEW_KEY = 'oxi-share-view';
|
||||
const token = $derived(page.params.token ?? '');
|
||||
|
||||
let view = $state<State>('loading');
|
||||
|
||||
Reference in New Issue
Block a user