feat(web): Auth-Session über @mana/shared-auth-sso
Some checks are pending
CI / validate (push) Waiting to run

devUser ist jetzt Wrapper um die shared SsoSession. App-spezifisch
bleiben stubId + patchProfile() (lokales Profil-Update). 201 LOC → 123 LOC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-19 01:31:02 +02:00
parent 633f051f2d
commit 55bf722761
3 changed files with 103 additions and 170 deletions

View file

@ -15,6 +15,7 @@
"clean": "rm -rf .svelte-kit build .turbo"
},
"dependencies": {
"@mana/shared-auth-sso": "0.1.0-alpha.3",
"@mana/shared-icons": "^1.0.0",
"@mana/shared-pwa": "0.1.0-alpha.3",
"@mana/shared-ui-2": "^0.1.0",

View file

@ -1,201 +1,121 @@
/**
* Auth-Session für wordeck-web.
* Auth-Session für wordeck-web Adapter um @mana/shared-auth-sso.
*
* Login-Flow: wordeck-web redirectet zu auth.mana.how (mana-auth-web),
* dort setzt mana-auth den SSO-Cookie, dann Redirect zurück zu
* /auth/callback, welcher via `tryRefresh()` + `loadUserFromToken()`
* den JWT holt und in localStorage speichert.
*
* Der Datei-Name `dev-stub.svelte.ts` ist Legacy alle Importer
* nutzen `devUser`-Symbol, Umbenennen würde ~50 Imports erfordern.
* Export-Name ist historisch `devUser` (zur Zeit der frühen Cards-
* Inkarnation noch reiner Dev-Stub). Heute ist es eine echte
* mana-auth-SSO-Session, ergänzt um:
* - `stubId` + `setStubId()` (manuelle Dev-User-ID, Cards-Muster)
* - `patchProfile()` (lokales Profil-Update z.B. nach Name-Edit)
*/
const TOKEN_KEY = 'cards.auth.accessToken';
const USER_KEY = 'cards.auth.user';
const STUB_KEY = 'cards.dev.userId';
export interface AuthUser {
id: string;
email: string;
name: string | null;
tier: string;
}
interface JwtClaims {
sub: string;
email?: string;
name?: string;
tier?: string;
exp?: number;
}
function decodeJwt(token: string): JwtClaims | null {
try {
const [, payload] = token.split('.');
if (!payload) return null;
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json) as JwtClaims;
} catch {
return null;
}
}
function isExpired(claims: JwtClaims): boolean {
if (!claims.exp) return false;
return claims.exp * 1000 < Date.now();
}
// SvelteKit-konformer ENV-Zugriff (siehe client.ts für Begründung).
import { browser } from '$app/environment';
import { env as publicEnv } from '$env/dynamic/public';
import { createSession } from '@mana/shared-auth-sso/session';
import { buildLoginRedirect as _build } from '@mana/shared-auth-sso';
function authBaseUrl(): string {
return publicEnv.PUBLIC_MANA_AUTH_URL ?? 'https://auth.mana.how';
const AUTH_URL = publicEnv.PUBLIC_MANA_AUTH_URL ?? 'https://auth.mana.how';
const AUTH_WEB_URL = publicEnv.PUBLIC_AUTH_WEB_URL ?? AUTH_URL;
const STUB_KEY = 'wordeck.auth.stubId';
const USER_KEY = 'wordeck.auth.user';
export function authBaseUrl(): string {
return AUTH_URL;
}
class Session {
token = $state<string | null>(null);
user = $state<AuthUser | null>(null);
stubId = $state<string | null>(null); // Phase-2-Übergangs-Fallback (?stub=<uuid>)
private refreshing: Promise<boolean> | null = null;
const _base = createSession({
appSlug: 'wordeck',
authUrl: AUTH_URL,
authWebUrl: AUTH_WEB_URL,
});
constructor() {
if (typeof window === 'undefined') return;
const stored = window.localStorage.getItem(TOKEN_KEY);
const userJson = window.localStorage.getItem(USER_KEY);
if (stored && userJson) {
const claims = decodeJwt(stored);
if (claims && !isExpired(claims)) {
this.token = stored;
this.user = JSON.parse(userJson) as AuthUser;
} else if (claims) {
// JWT abgelaufen, aber Cookie-Session lebt evtl. noch.
// User-Profil temporär behalten und im Hintergrund refreshen.
this.user = JSON.parse(userJson) as AuthUser;
void this.tryRefresh();
} else {
this.clearLocal();
}
}
// Dev-Stub-Fallback (für Tests + Anki-Importer im old-style flow)
this.stubId = window.sessionStorage.getItem(STUB_KEY);
}
class WordeckSession {
stubId = $state<string | null>(null);
private clearLocal() {
this.token = null;
this.user = null;
if (typeof window !== 'undefined') {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
constructor(private base: typeof _base) {
if (browser) {
this.stubId = window.localStorage.getItem(STUB_KEY);
}
}
/** Effektive User-ID: bevorzugt JWT, dann Dev-Stub, sonst null. */
get token() {
return this.base.token;
}
get user() {
return this.base.user;
}
get loading() {
return this.base.loading;
}
get isLoggedIn(): boolean {
return this.base.isLoggedIn || !!this.stubId;
}
get isAuthenticated(): boolean {
return this.base.isAuthenticated || !!this.stubId;
}
get id(): string | null {
return this.user?.id ?? this.stubId ?? null;
return this.base.user?.id ?? this.stubId;
}
get effectiveToken(): string | null {
return this.base.effectiveToken;
}
/**
* User-Profil aus dem aktuell gespeicherten JWT-Token laden.
* Wird vom /auth/callback nach erfolgreichem tryRefresh() aufgerufen.
*/
hasTier(need: string): boolean {
return this.base.hasTier(need);
}
tryRefresh(): Promise<boolean> {
return this.base.tryRefresh();
}
ensureFreshToken(): Promise<void> {
return this.base.ensureFreshToken();
}
loadUserFromToken(): void {
if (!this.token) return;
const claims = decodeJwt(this.token);
if (!claims) return;
this.user = {
id: claims.sub,
email: claims.email ?? '',
name: claims.name ?? null,
tier: claims.tier ?? 'public',
};
if (typeof window !== 'undefined') {
window.localStorage.setItem(USER_KEY, JSON.stringify(this.user));
}
this.base.loadUserFromToken();
}
/**
* Versucht den accessToken via mana-auth-/refresh zu erneuern.
* Nutzt die SSO-Session-Cookie (`credentials: 'include'`), die
* mana-auth beim Login auf `.mana.how` setzt. Returnt true bei
* Erfolg. Mehrfach-Aufrufe werden zu einer Promise gecoalesced,
* damit gleichzeitige API-Calls keinen Refresh-Storm verursachen.
*/
async tryRefresh(): Promise<boolean> {
if (this.refreshing) return this.refreshing;
this.refreshing = (async () => {
try {
const r = await fetch(`${authBaseUrl()}/api/v1/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!r.ok) return false;
const data = (await r.json()) as { accessToken?: string };
if (!data.accessToken) return false;
this.token = data.accessToken;
if (typeof window !== 'undefined') {
window.localStorage.setItem(TOKEN_KEY, data.accessToken);
}
return true;
} catch {
return false;
} finally {
this.refreshing = null;
}
})();
return this.refreshing;
async clear(): Promise<void> {
await this.base.clear();
this.stubId = null;
if (browser) window.localStorage.removeItem(STUB_KEY);
}
/**
* Stellt sicher, dass der token noch 60s gültig ist. Aufgerufen
* vom API-Client vor jedem Request. Wenn der token bald abläuft,
* versuchen wir einen stillen Refresh; klappt das nicht, lassen
* wir den Request mit dem alten Token durch die 401-Behandlung
* im Client greift dann.
*/
async ensureFreshToken(): Promise<void> {
if (!this.token) return;
const claims = decodeJwt(this.token);
if (!claims?.exp) return;
const remainingMs = claims.exp * 1000 - Date.now();
if (remainingMs < 60_000) {
await this.tryRefresh();
}
}
/** Setzt einen Dev-Stub (kein echtes Auth). Nur für Tests/Migration. */
set(userId: string) {
const trimmed = userId.trim();
if (!trimmed) return;
setStubId(raw: string | null): void {
const trimmed = raw?.trim() ?? null;
this.stubId = trimmed;
if (typeof window !== 'undefined') {
window.sessionStorage.setItem(STUB_KEY, trimmed);
}
if (!browser) return;
if (trimmed) window.localStorage.setItem(STUB_KEY, trimmed);
else window.localStorage.removeItem(STUB_KEY);
}
/** Aktualisiert den lokal gecachten Anzeigenamen. Schreibt in localStorage. */
/**
* Aktualisiert den lokal gecachten Anzeigenamen. Schreibt in
* localStorage zurück. Server-seitiges Update macht der Caller
* separat (z.B. PATCH /api/v1/me).
*/
patchProfile(patch: { name?: string }) {
if (!this.user) return;
if (patch.name !== undefined) this.user = { ...this.user, name: patch.name.trim() || null };
if (typeof window !== 'undefined') {
window.localStorage.setItem(USER_KEY, JSON.stringify(this.user));
if (patch.name !== undefined) {
// SsoSession.user ist ein $state-Object — neue Referenz triggert
// Reactivity in Konsumenten.
this.base.user = {
...this.user,
name: patch.name.trim() || null,
};
}
}
clear() {
// Auch SSO-Cookie auf .mana.how aufräumen — best-effort, schlägt
// bei abgelaufener Session ohne Drama fehl.
if (this.token && typeof window !== 'undefined') {
void fetch(`${authBaseUrl()}/api/v1/auth/logout`, {
method: 'POST',
credentials: 'include',
}).catch(() => undefined);
}
this.clearLocal();
this.stubId = null;
if (typeof window !== 'undefined') {
window.sessionStorage.removeItem(STUB_KEY);
if (browser && this.base.user) {
window.localStorage.setItem(USER_KEY, JSON.stringify(this.base.user));
}
}
}
export const devUser = new Session();
export const devUser = new WordeckSession(_base);
export function buildLoginRedirect(currentUrl?: URL): string {
return _build({
appSlug: 'wordeck',
authWebUrl: AUTH_WEB_URL,
from: currentUrl,
});
}
export type { AuthUser } from '@mana/shared-auth-sso';

12
pnpm-lock.yaml generated
View file

@ -66,6 +66,9 @@ importers:
apps/web:
dependencies:
'@mana/shared-auth-sso':
specifier: 0.1.0-alpha.3
version: 0.1.0-alpha.3(svelte@5.55.5)
'@mana/shared-icons':
specifier: ^1.0.0
version: 1.0.0(svelte@5.55.5)(vite@5.4.21(@types/node@24.12.3)(lightningcss@1.32.0)(terser@5.47.1))
@ -1247,6 +1250,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mana/shared-auth-sso@0.1.0-alpha.3':
resolution: {integrity: sha512-h3z7ICF8L+we52ZC+IeANWpWp79hqnedlyoH3zvztwsPe6MrjfuzcLL8LvKcKQLonlfWvCVXtwPfw2Y7KQRDSg==}
peerDependencies:
svelte: ^5.0.0
'@mana/shared-icons@1.0.0':
resolution: {integrity: sha512-71L1dLO6tias8floLv8s0MYzv4cA5IvwftxdFnTYOsKMTMkJ2xEiJ4VxoV5Rj7iAG0oCdV5cmjGyOEKWaLVGEA==}
peerDependencies:
@ -4382,6 +4390,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@mana/shared-auth-sso@0.1.0-alpha.3(svelte@5.55.5)':
dependencies:
svelte: 5.55.5
'@mana/shared-icons@1.0.0(svelte@5.55.5)(vite@5.4.21(@types/node@24.12.3)(lightningcss@1.32.0)(terser@5.47.1))':
dependencies:
phosphor-svelte: 3.1.0(svelte@5.55.5)(vite@5.4.21(@types/node@24.12.3)(lightningcss@1.32.0)(terser@5.47.1))