Authentication & sessions
Login
src/routes/login/+page.svelte renders <LoginForm> from
src/lib/components/auth/LoginForm.svelte. The form uses
sveltekit-superforms with a Zod schema (username, password both
required) and POSTs to /api/v1/auth/login.
On success it stores the user in userProfileStore and invokes the
onsuccess callback, which the login page resolves through
resolvePostLoginTarget() — sanitized ?redirect_path= query param
first, then the sessionStorage-saved redirect, then /. On failure it
maps RFC7807 error responses to i18n-localized messages, with special
handling for rate-limited attempts (showing minutes remaining).
Session boot (auto-login)
Before showing the form, the login page runs a client-side boot check —
no /auth/me round-trip:
- Valid local session —
sessionStorage['user']exists and itsexpires_atis not within the 30 s expiry buffer → navigate straight to the post-login target while aLoadingWatermarkshows "Auto Logging" (cookie icon, flip animation). - Expired or missing local session → a single
triggerRefresh()attempt ("Refreshing Token" state, rotate-ccw-key icon). The HttpOnly refresh cookie is the authoritative channel, so this also recovers sessions in fresh tabs wheresessionStorageis empty. - Refresh fails → stale local state is cleared via
userProfileStore.clear()and the normal login form renders.
The shared helpers live in src/lib/auth/session-check.ts
(hasLocalSession, isTokenExpired, refreshAccessToken,
triggerRefresh) and are also used by apiFetch's 401 retry path —
triggerRefresh is single-flight, so concurrent callers share one
refresh request.
Identity vs session — one certified source
sessionStorage['user'] / userProfileStore mixes two domains that are
written by different paths:
| Writer | Allowed fields |
|---|---|
GET /api/v1/auth/me → userProfileStore.set(profile) | Identity — uuid, display_name, email, avatar_*, is_admin, is_verified, idp_*, permissions, audit fields. THE certified source (DB user_profiles). |
login / refresh responses (IDP JWT claims) → userProfileStore.setSession(data.user) | Session only — username, idp_code, expires_at, roles, organization. setSession whitelists these keys: claim-level display_name/email are dropped — never deduced identity. |
setSession also carries an account-switch guard: a payload idp_code
different from the stored one drops the current identity fields, which
/auth/me then repopulates reactively — no stale identity of another
account can linger. Never write sessionStorage['user'] directly or feed
claims to set().
Post-login redirect
src/lib/auth/redirect-cache.ts exposes the redirect helpers:
| Helper | Purpose |
|---|---|
saveRedirectUrl(url) | Persists the current app path in sessionStorage (API routes skipped) |
getAndClearRedirectUrl() | Reads and clears the saved redirect |
sanitizeRedirectPath(raw) | Accepts only same-origin /... paths — rejects //host, /api/…, backslashes, control chars |
resolvePostLoginTarget(qsParam, lastRoute) | redirect_path QS → saved redirect → last meaningful route → / |
goToLoginPage() | Hard-navigates to /login preserving the current path both in sessionStorage and as a sanitized ?redirect_path= (survives reloads and new tabs) |
SessionExpiredDialog's "Go to login" button uses goToLoginPage(), so
the original destination is restored after re-authentication even from
the full login page.
Last-route restore
pb:shell:lastRoute (localStorage) tracks the last meaningful page via
afterNavigate — /, /login* and /api/* are never saved, so the
post-login resolution's third fallback is deterministic rather than
degenerating to /.
Module switching & route-aware translations
Selecting a module in the sidebar switcher calls
shellNav.selectModule(id) and navigates to the module home — the
first nav entry of the current root slice (home[root] from the
/api/v1/modules payload; nav[0].href is the single source of truth). /system/settings itself redirects to /system/settings/profile
via a +page.ts universal load() — redirect() inside a component
<script> only works on SSR; on client-side navigation it surfaces as
an unhandled "Redirect" error and a blank page.
The route change is what drives lazy translation loading:
useModuleTranslations() (mounted in the (app) layout) resolves the
module from page.url.pathname and fetches its dictionary — so module
dictionaries load on navigation, not on selection.
Logout
POST /api/v1/auth/logout (PUBLIC) clears both HttpOnly auth cookies —
access_token (path /) and refresh_token
(path /api/v1/auth/refresh). JavaScript cannot clear HttpOnly
cookies, so the server round-trip is required.
AppSidebar's handleLogout calls the endpoint (best-effort), then
clears local state — userProfileStore.clear(),
getAndClearRedirectUrl(), shellNav.clearLastRoute() — and navigates
to /login without redirect_path. After a real logout the login
boot's refresh attempt fails and the form renders: the session is
officially over.
Session expiration
When an API call returns 401, the request is enqueued in the
session-expired store and the SessionExpiredDialog opens so the user can
re-authenticate without losing context.
src/lib/auth/session-expired-store.svelte.ts exposes:
| Member | Purpose |
|---|---|
enqueue(input, init) | Enqueues a failed request and opens the dialog; returns a Promise<Response> that resolves after re-login |
drainPending() | Returns and clears the queued requests (called after successful re-login) |
setFailed() | Marks the last login attempt as failed (shows the error alert + "Go to login" button) |
close() | Closes the dialog and resets state |
State is exposed read-only via state (isOpen, pendingRequests,
hasFailedAttempt). After a successful re-login in the dialog,
handleLoginSuccess() calls close() and drainPending(), and the
enqueued requests are retried with a _sessionRetry flag to avoid
re-triggering the dialog.
Password policy
The backend exposes the active password policy at
GET /api/v1/system/password-policy. The usePasswordPolicy() composable
fetches it and exposes both the rules and a derived validation regex.
Policies
| Policy | Rules enforced |
|---|---|
alpha_numeric | Length 8–64, alphanumeric only |
letter_and_number | Length, ≥1 letter, ≥1 number |
letter_number_special | Length, letter, number, special char |
mixed_case_special | Length, lowercase, uppercase, number, special char |
Allowed special characters: *-_.#@!|?^:
The regex patterns are kept in sync with the backend exactly:
Code
Composable API
Code
Password checklist
PasswordChecklist.svelte renders a live checklist of the active rules
(length, letter, lowercase, uppercase, number, special) with
check/circle icons, updating as the user types. It takes password,
rules: PasswordChecklistRule[], and optional specialChars.
Change password dialog
ChangePasswordDialog.svelte (in the entity-list-table dialogs) loads the
policy on open, validates the new password against policy.regex, requires
confirmation match, and POSTs to
/api/v1/entities/user_profile/{uuid}/change-password. It embeds
<PasswordChecklist> for inline feedback.
Password UI components
The src/lib/components/ui/password/ family wraps password input with
strength checking via @zxcvbn-ts/core:
Password.Root— context provider (usePassword({ hidden, minScore }))Password.Input— the input itself (usePasswordInput({ value, ref }))Password.ToggleVisibility— show/hide togglePassword.Copy— copy-to-clipboardPassword.Strength— zxcvbn strength meterPasswordInputField— convenience wrapper combining Root + Input + Toggle
zxcvbn is lazy-loaded and strength checking is debounced with request
cancellation. When the score is below minScore, the input's custom
validity is set.
Active roles
useActiveRoles() fetches roles from /api/v1/system/roles/active and
exposes state.roles (ActiveRole[] with idp_role, label_key,
permissions, is_admin) plus a derived roleNames getter for use in
forms.
MCP OAuth consent
src/lib/mcp-oauth.ts provides OAuth client helpers for the MCP consent
screen at /mcp/consent:
| Function | Purpose |
|---|---|
parseConsentParams(searchParams) | Parses OAuth params from the URL query string |
parseScopes(scopeString) | Splits the scope string into { name, description } entries |
buildApproveUrl(params, beBaseUrl) | Builds the backend authorize URL with consent_approved=true |
buildDenyUrl(params) | Builds the deny redirect URL per RFC 6749 §4.1.2.1 |
The consent page checks /api/v1/auth/me; if the user is not logged in it
shows <LoginForm> and reloads on success. Otherwise it shows the client
name and requested scopes, then redirects to the approve or deny URL.
Form guards
Two composables consolidate logic shared across the settings form pages:
useFormGuard
Derives hasChanges and canSave from a SuperForm instance. Because
Superform's auto-subscription does not work inside .svelte.ts files, it
takes getter functions for $tainted and $errors plus the form's
isTainted function:
Code
useUnsavedChangesGuard
Registers navigation guards for unsaved changes:
Code
handleBeforeUnload— for<svelte:window onbeforeunload>, prevents browser close/refreshhandleCancel— for the footer Cancel button; confirms then closes the window or navigates back- Internally registers a SvelteKit™
beforeNavigateguard that prompts on in-app navigation