2026-06-17 17:06:30 -06:00
|
|
|
/**
|
|
|
|
|
* Session store — the authenticated user and derived flags.
|
|
|
|
|
*
|
2026-06-17 22:07:18 -06:00
|
|
|
* Replaces the user-related fields of the original `app` state object
|
2026-06-17 17:06:30 -06:00
|
|
|
* (isExternalUser, userHomeFolderId/Name). `isExternalUser` drives default
|
|
|
|
|
* routing: externals (magic-link / OIDC-only / OCM recipients) have no home
|
|
|
|
|
* folder and land on the shared-with-me view.
|
|
|
|
|
*/
|
2026-08-09 05:00:30 +02:00
|
|
|
import { bindDpopIfPossible, fetchMe, tryRefresh } from '$lib/api/endpoints/auth';
|
2026-08-09 14:04:42 +02:00
|
|
|
import { setLogoutInProgress } from '$lib/api/client';
|
2026-06-20 01:28:52 +02:00
|
|
|
import { drives } from '$lib/stores/drives.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
import type { User } from '$lib/api/types';
|
2026-07-07 20:47:51 +02:00
|
|
|
import { ensureActiveUser } from '$lib/utils/localStoragePrefs';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
class SessionStore {
|
|
|
|
|
user = $state<User | null>(null);
|
|
|
|
|
loaded = $state(false);
|
|
|
|
|
homeFolderId = $state<string | null>(null);
|
|
|
|
|
homeFolderName = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
isExternalUser = $derived(this.user?.is_external ?? false);
|
|
|
|
|
isAuthenticated = $derived(this.user !== null);
|
2026-08-04 21:05:08 +02:00
|
|
|
/**
|
|
|
|
|
* TRUE when the backend has set `force_password_change_at_next_login`
|
|
|
|
|
* on this account — an admin picked a temporary password and the
|
|
|
|
|
* user MUST change it before doing anything else. Drives the root
|
|
|
|
|
* layout's mandatory-mode redirect: any protected route other than
|
|
|
|
|
* `/profile` bounces back until the flag flips to false.
|
|
|
|
|
*
|
|
|
|
|
* Set to false by default so an older backend that predates the
|
|
|
|
|
* flag (or a malformed `/me` response) doesn't accidentally
|
|
|
|
|
* quarantine every user.
|
|
|
|
|
*/
|
|
|
|
|
mustChangePassword = $derived(this.user?.force_password_change === true);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve the session once. Probes /api/auth/me; on 401 it makes a single
|
|
|
|
|
* refresh attempt and re-probes. Never redirects — the layout guard decides
|
|
|
|
|
* what to do with an unauthenticated result. Idempotent: subsequent calls
|
|
|
|
|
* return the cached result (so client-side navigation doesn't re-probe).
|
|
|
|
|
*/
|
|
|
|
|
async load(): Promise<User | null> {
|
|
|
|
|
if (this.loaded) return this.user;
|
|
|
|
|
try {
|
|
|
|
|
let me = await fetchMe();
|
|
|
|
|
if (!me && (await tryRefresh())) {
|
|
|
|
|
me = await fetchMe();
|
|
|
|
|
}
|
2026-08-09 05:00:30 +02:00
|
|
|
if (me) {
|
|
|
|
|
this.setUser(me);
|
|
|
|
|
// Post-redirect DPoP bind — catches OIDC / magic-link
|
|
|
|
|
// flows whose server-side callback creates the session
|
|
|
|
|
// UNBOUND (no way for the redirect to carry the JKT in
|
2026-08-09 12:04:45 +02:00
|
|
|
// the callback body). Gate on `is_dpop_bound` so we
|
|
|
|
|
// don't call the endpoint on every SPA load: password
|
|
|
|
|
// login already binds at session-mint time, so `/me`
|
|
|
|
|
// reports `true` on the very first request and skip
|
|
|
|
|
// avoids the 409 `already_bound` reject that would
|
|
|
|
|
// otherwise clutter the audit stream. Fire-and-forget
|
|
|
|
|
// so a slow IndexedDB open doesn't stall app boot.
|
|
|
|
|
if (me.is_dpop_bound === false) void bindDpopIfPossible();
|
2026-08-09 05:00:30 +02:00
|
|
|
} else this.user = null;
|
2026-06-17 17:06:30 -06:00
|
|
|
} catch {
|
|
|
|
|
this.user = null;
|
|
|
|
|
}
|
|
|
|
|
this.loaded = true;
|
|
|
|
|
return this.user;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 20:47:51 +02:00
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-08-09 14:04:42 +02:00
|
|
|
// Any successful login clears the session-teardown gate. Without
|
|
|
|
|
// this, a logout → login within the same SPA session leaves the
|
|
|
|
|
// gate stuck at `true` — the login POST is exempted via
|
|
|
|
|
// `AUTH_PRIMITIVES`, but the /me + /drives + … fetches the app
|
|
|
|
|
// fires post-login would all abort with "Session terminated".
|
|
|
|
|
setLogoutInProgress(false);
|
2026-07-07 20:47:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-20 16:33:08 +02:00
|
|
|
/**
|
|
|
|
|
* Re-fetch the authenticated user from the server, bypassing the one-shot
|
|
|
|
|
* `load()` cache. Call after operations that change server-side user state —
|
|
|
|
|
* chiefly storage usage after uploads / deletes — so the UI reflects the new
|
|
|
|
|
* `storage_used_bytes` instead of the value cached at login. A transient
|
|
|
|
|
* failure leaves the current user untouched (never logs the UI out).
|
|
|
|
|
*/
|
|
|
|
|
async refresh(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const me = await fetchMe();
|
|
|
|
|
if (me) this.user = me;
|
|
|
|
|
} catch {
|
|
|
|
|
/* keep the existing user on a transient /api/auth/me failure */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
/**
|
2026-06-20 01:28:52 +02:00
|
|
|
* Resolve the caller's default personal drive's root folder — the landing
|
|
|
|
|
* point for `/files` and the `/` redirect. Externals (grant-only) have no
|
|
|
|
|
* personal drive, so this is skipped for them.
|
|
|
|
|
*
|
|
|
|
|
* Identifies the default via `default_for_user`, not folder name: users
|
|
|
|
|
* can rename "Personal" without breaking this lookup.
|
2026-06-17 17:06:30 -06:00
|
|
|
*/
|
|
|
|
|
async loadHomeFolder(): Promise<string | null> {
|
|
|
|
|
if (this.homeFolderId) return this.homeFolderId;
|
|
|
|
|
if (this.isExternalUser) return null;
|
2026-06-20 01:28:52 +02:00
|
|
|
await drives.load();
|
|
|
|
|
const def = drives.findDefault();
|
|
|
|
|
if (def) {
|
|
|
|
|
this.homeFolderId = def.root_folder_id;
|
|
|
|
|
this.homeFolderName = def.name;
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
return this.homeFolderId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reset(): void {
|
|
|
|
|
this.user = null;
|
|
|
|
|
this.homeFolderId = null;
|
|
|
|
|
this.homeFolderName = null;
|
2026-08-09 13:47:47 +02:00
|
|
|
// Mark the store as `loaded` so any subsequent `session.load()` —
|
|
|
|
|
// notably the login page's existing-session probe and the root
|
|
|
|
|
// layout's post-nav mount — short-circuits to `null` instead of
|
|
|
|
|
// re-probing `/api/auth/me`. After an explicit logout we know for
|
|
|
|
|
// a fact the session is gone; a probe would 401, the interceptor
|
|
|
|
|
// would retry via /refresh (also 401), and `sessionExpiredHandler`
|
|
|
|
|
// would divert to `/login?source=session_expired` — clobbering the
|
|
|
|
|
// nice "logged out" landing. On a hard nav (natural expiry path)
|
|
|
|
|
// module state is fresh and this flag is `false` again, so the
|
|
|
|
|
// probe still runs there.
|
|
|
|
|
this.loaded = true;
|
2026-06-17 17:06:30 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const session = new SessionStore();
|