feat(mana): migrate to central auth portal — no embedded login UI, clean cut

managarten redet jetzt nicht mehr direkt mit Better-Auth — Login,
Register, Passwort-Reset, 2FA-Verify, Magic-Link, Passkey-Login laufen
ALLE über `auth.mana.how` (mana-auth-web portal). managarten ist nur
noch Consumer einer existierenden Session.

## Architektur

- Unauthenticated: `redirectToPortal({ next })` macht hartes Redirect zu
  `auth.mana.how/login?app=mana&redirect=<callback>`. AuthGate
  (`(app)/+layout.svelte`) und `require-auth` triggern das.
- Nach Login: Portal setzt SSO-Cookie auf `.mana.how`. Browser landet
  auf `/auth/callback?next=<deep-link>`.
- Callback: `session.tryRefresh()` holt frischen JWT via Cookie,
  `loadUserFromToken()` setzt User, `goto(next)` renderet (app)-Layout
  mit unlocked Vault (Root-Layout-$effect feuert auf User-ID-Wechsel).

## Files

NEU:
- `lib/auth/portal-redirect.ts` — Helper für Portal-URL-Bau + hard redirect.
- `lib/auth/session.svelte.ts` — schlanke Session-Klasse: Token-Refresh
  via SSO-Cookie, ensureFresh, signOut. Storage: `mana.auth.accessToken`,
  `mana.auth.user`.
- `lib/auth/settings-client.ts` — Passkey-CRUD, 2FA-Setup, Sessions,
  Audit-Events. Pflegt keinen State, ruft direkt mana-auth API.

GELÖSCHT:
- `routes/(auth)/login|register|forgot-password|reset-password|+layout`
- `routes/auth/reset-password` (war Alias-Redirect)
- Komplette `(auth)` route group.

UMGESCHRIEBEN:
- `lib/stores/auth.svelte.ts` — re-exportiert `session` als `authStore`
  (keine 47-Methoden-Factory aus `@mana/shared-auth-ui` mehr).
- `routes/auth/callback/+page.svelte` — Token-Refresh + Deep-Link statt
  Legacy-Supabase-Stub.
- `lib/components/settings/sections/SecuritySection.svelte` — alle
  `authStore.registerPasskey/enableTwoFactor/...` Calls auf neuen
  `settings-client` umgelenkt. UI-Komponenten (PasskeyManager,
  TwoFactorSetup, …) aus `@mana/shared-auth-ui` bleiben — sind reine
  Render-Components.

ANGEPASST (Portal-Redirect statt `goto('/login')`):
- `(app)/+layout.svelte`, `RouteTierGate`, `email-verified`,
  `verification-failed`, `feedback/+layout`, `quotes/lists`,
  `quotes/favorites`, `citycorners/favorites`, `feedback/DetailView`,
  `feedback/ListView`, `profile/ListView`, `guest-prompt`,
  `require-auth.svelte.ts`.

ENV:
- `.env.development`: `MANA_AUTH_WEB_URL=http://localhost:3002`.
- `scripts/generate-env.mjs`: schreibt `PUBLIC_MANA_AUTH_URL` +
  `PUBLIC_AUTH_WEB_URL` ins `apps/mana/apps/web/.env`.

## Status

- `pnpm run check`: 0 errors, 0 warnings, 7672 files.
- `pnpm build` (8 GB heap): grün.
- E2E lokal + Production-Deploy stehen aus — Plan siehe
  `mana/docs/playbooks/MANAGARTEN_AUTH_PORTAL_MIGRATION.md`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-12 17:00:03 +02:00
parent d3d9271426
commit 5635598a58
27 changed files with 773 additions and 489 deletions

View file

@ -23,6 +23,10 @@ PUBLIC_GLITCHTIP_DSN=
# Mana Core Auth Service # Mana Core Auth Service
MANA_AUTH_URL=http://localhost:3001 MANA_AUTH_URL=http://localhost:3001
# Auth-Portal-UI (Login/Register/Reset, getrennt vom Auth-API-Service).
# In Prod identisch mit MANA_AUTH_URL (nginx splittet /api/* zu mana-auth,
# Rest zu mana-auth-web), lokal aber eigener Port (mana-auth-web :3002).
MANA_AUTH_WEB_URL=http://localhost:3002
# Mana Credits Service # Mana Credits Service
MANA_CREDITS_URL=http://localhost:3061 MANA_CREDITS_URL=http://localhost:3061
# Mana Media Service (CAS, thumbnails, Photos gallery) # Mana Media Service (CAS, thumbnails, Photos gallery)

View file

@ -0,0 +1,71 @@
/**
* Portal-Redirect-Helper.
*
* Statt einer eigenen Login-Seite leitet managarten unauthenticated User
* an `auth.mana.how/login` weiter. Nach erfolgreichem Login landet der
* User auf `/auth/callback?next=…`, wo der Token via SSO-Cookie geholt
* und der Encryption-Vault entsperrt wird.
*
* In Dev: `PUBLIC_AUTH_WEB_URL=http://localhost:3002`.
* In Prod: `PUBLIC_AUTH_WEB_URL=https://auth.mana.how`.
*
* Diese Funktion macht einen harten `window.location.href` keinen
* SvelteKit-`goto` weil das Portal eine eigene Origin ist.
*/
import { env as publicEnv } from '$env/dynamic/public';
const FALLBACK_PORTAL = 'https://auth.mana.how';
const APP_ID = 'mana';
function portalUrl(): string {
return publicEnv.PUBLIC_AUTH_WEB_URL ?? FALLBACK_PORTAL;
}
function appOrigin(): string {
if (typeof window === 'undefined') return 'https://mana.how';
return window.location.origin;
}
/** Baut die `/auth/callback?next=<path>`-URL für die aktuelle App. */
function callbackUrl(next: string | null | undefined): string {
const base = appOrigin();
const nextParam = next && next.startsWith('/') ? `?next=${encodeURIComponent(next)}` : '';
return `${base}/auth/callback${nextParam}`;
}
export interface RedirectToPortalOptions {
/**
* Pfad innerhalb der App, zu dem der User nach Login zurückgeführt
* werden soll. Default: der aktuelle `window.location.pathname`.
* Pfade ohne führenden `/` werden ignoriert.
*/
next?: string;
/** Welche Portal-Page direkt öffnen — login (default) oder register. */
target?: 'login' | 'register';
}
/** Hartes Redirect zum Auth-Portal. */
export function redirectToPortal(options: RedirectToPortalOptions = {}): void {
if (typeof window === 'undefined') return;
const target = options.target ?? 'login';
const next = options.next ?? window.location.pathname + window.location.search;
const url = new URL(`${portalUrl()}/${target}`);
url.searchParams.set('app', APP_ID);
url.searchParams.set('redirect', callbackUrl(next));
window.location.href = url.toString();
}
/**
* Liefert die Portal-URL als String (z.B. für `<a href>` in
* server-rendered Content). Vorsicht: Bei Klick erfolgt ein voller
* Navigation-Refresh (App-Bundle wird neu geladen).
*/
export function portalHref(options: RedirectToPortalOptions = {}): string {
const target = options.target ?? 'login';
const next = options.next ?? (typeof window !== 'undefined' ? window.location.pathname : '/');
const url = new URL(`${portalUrl()}/${target}`);
url.searchParams.set('app', APP_ID);
url.searchParams.set('redirect', callbackUrl(next));
return url.toString();
}

View file

@ -56,9 +56,9 @@
* recovery UX. Mixing the two would muddy the message. * recovery UX. Mixing the two would muddy the message.
*/ */
import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { redirectToPortal } from '$lib/auth/portal-redirect';
/** What requireAuth() needs to render the modal. */ /** What requireAuth() needs to render the modal. */
export interface RequireAuthOptions { export interface RequireAuthOptions {
@ -129,10 +129,12 @@ export const authGateState = new AuthGateState();
* - If guest shows a modal and resolves to `true` if the user * - If guest shows a modal and resolves to `true` if the user
* logs in (and returns to the page), `false` if they cancel. * logs in (and returns to the page), `false` if they cancel.
* *
* The modal navigates to `/login?next=<current path>` so the user * The modal navigates to `auth.mana.how/login?app=mana&redirect=<callback>`
* lands back on the same view after logging in. The Promise then * so the user lands back on `/auth/callback?next=<current path>` after
* resolves on the *next* time `authStore.isAuthenticated` flips to * the Portal-Login, where Token-Refresh + Vault-Unlock laufen, bevor
* `true` the caller does NOT have to re-trigger their action. * `goto(next)` ins App-Innere weiterleitet. Die Promise resolved beim
* nächsten Mal, wenn `authStore.isAuthenticated` auf `true` flippt der
* Aufrufer muss seine Action NICHT manuell re-triggern.
*/ */
export async function requireAuth(options: RequireAuthOptions): Promise<boolean> { export async function requireAuth(options: RequireAuthOptions): Promise<boolean> {
if (authStore.isAuthenticated) return true; if (authStore.isAuthenticated) return true;
@ -140,19 +142,16 @@ export async function requireAuth(options: RequireAuthOptions): Promise<boolean>
} }
/** /**
* Called by AuthRequiredModal when the user clicks "Anmelden". Saves * Called by AuthRequiredModal when the user clicks "Anmelden". Hartes
* the current path so /login can redirect back, then navigates. * Redirect zum Auth-Portal der aktuelle Pfad geht als `next`-Hint mit,
* damit der User nach Login + Vault-Unlock wieder hier landet.
* *
* The modal closes immediately on click. We deliberately do NOT wait * `authGateState.resolve(false)` läuft VOR dem `window.location.href`-
* for the post-login redirect to come back here once the user * Wechsel, weil der Modal-Caller den Promise sonst nie auflöst der
* navigates to /login, the original action's call site has lost its * SvelteKit-Run-Loop wird durch das harte Redirect abgebrochen.
* stack frame anyway. Instead, the user re-clicks the button after
* landing back on the page; the second click sees `isAuthenticated`
* is now true and proceeds without a modal.
*/ */
export async function navigateToLogin(): Promise<void> { export async function navigateToLogin(): Promise<void> {
const here = page.url?.pathname ?? '/'; const here = page.url?.pathname ?? '/';
const next = here === '/login' ? '/' : here;
await goto(`/login?next=${encodeURIComponent(next)}`);
authGateState.resolve(false); authGateState.resolve(false);
redirectToPortal({ next: here });
} }

View file

@ -0,0 +1,271 @@
/**
* Mana Session schlanker Auth-Client für die App-Seite.
*
* **Was diese Klasse macht:** Token-Lifecycle managen (Refresh via
* SSO-Cookie, lokale Persistierung im localStorage, proaktive
* Auffrischung vor Ablauf, Logout). Sie macht KEINE Login/Register-
* Flows die laufen exklusiv über das zentrale Auth-Portal
* (`auth.mana.how`).
*
* **Wie der Flow läuft:**
* 1. Unauthenticated User landet auf `/` `+page.svelte` redirected
* zum Portal mit `?app=mana&redirect=<callback>`.
* 2. User loggt sich auf dem Portal ein. Better-Auth setzt
* `mana_session` Cookie auf `.mana.how`.
* 3. Browser wird zurück zur App geleitet (`/auth/callback`).
* 4. `auth/callback/+page.svelte` ruft `session.tryRefresh()`.
* Der POST geht mit `credentials: 'include'` an
* `${MANA_AUTH_URL}/api/v1/auth/refresh` der Browser sendet das
* SSO-Cookie automatisch mit. mana-auth liest das Cookie,
* validiert die Session, mintet einen frischen JWT und gibt ihn
* als JSON zurück.
* 5. Token landet im `localStorage` (`mana.auth.accessToken`), das
* User-Profil (aus den JWT-Claims) in `mana.auth.user`.
* 6. Vor jedem API-Call ruft der Caller `session.ensureFresh()` auf
* wenn weniger als 60s Restlaufzeit, wird automatisch refreshed.
*
* **Was diese Klasse NICHT macht:** Settings-Methoden (Passkey-CRUD,
* 2FA-Setup, Sessions-Liste, Audit-Log). Die leben in
* `lib/auth/settings-client.ts` und nutzen den Token, den die
* Session vorhält.
*/
import { env as publicEnv } from '$env/dynamic/public';
const TOKEN_KEY = 'mana.auth.accessToken';
const USER_KEY = 'mana.auth.user';
const FALLBACK_AUTH_URL = 'https://auth.mana.how';
function authBaseUrl(): string {
return publicEnv.PUBLIC_MANA_AUTH_URL ?? FALLBACK_AUTH_URL;
}
export interface SessionUser {
id: string;
email: string;
name: string | null;
role: string;
tier: string;
twoFactorEnabled?: boolean;
}
interface JwtClaims {
sub: string;
email?: string;
name?: string;
role?: string;
tier?: string;
twoFactorEnabled?: boolean;
exp?: number;
}
function decodeJwt(token: string): JwtClaims | null {
try {
const [, payload] = token.split('.');
if (!payload) return null;
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const json =
typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('utf8');
return JSON.parse(json) as JwtClaims;
} catch {
return null;
}
}
function isExpired(claims: JwtClaims | null): boolean {
if (!claims?.exp) return false;
return claims.exp * 1000 <= Date.now();
}
function userFromClaims(claims: JwtClaims): SessionUser {
return {
id: claims.sub,
email: claims.email ?? '',
name: claims.name ?? null,
role: claims.role ?? 'user',
tier: claims.tier ?? 'public',
twoFactorEnabled: claims.twoFactorEnabled,
};
}
class Session {
token = $state<string | null>(null);
user = $state<SessionUser | null>(null);
initialized = $state(false);
loading = $state(false);
private refreshing: Promise<boolean> | null = null;
get isAuthenticated(): boolean {
return !!this.user && !!this.token;
}
/**
* Einmaliger Boot-Pass: lädt persistierte Tokens, validiert sie,
* versucht stillschweigend eine SSO-Refresh wenn der lokale Token
* abgelaufen ist (Cookie könnte noch leben).
*/
async initialize(): Promise<void> {
if (this.initialized) return;
this.initialized = true;
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;
try {
this.user = JSON.parse(userJson) as SessionUser;
} catch {
this.user = userFromClaims(claims);
}
return;
}
if (claims) {
// JWT abgelaufen — versuche stillschweigend zu refreshen
// (SSO-Cookie kann noch leben).
try {
this.user = JSON.parse(userJson) as SessionUser;
} catch {
this.user = userFromClaims(claims);
}
const ok = await this.tryRefresh();
if (!ok) this.clearLocal();
return;
}
}
// Kein Token lokal — letzter Versuch: vielleicht haben wir
// trotzdem ein SSO-Cookie (z.B. anderer Tab hat eingeloggt).
await this.tryRefresh();
}
/**
* Holt einen frischen JWT vom Server. Benötigt das SSO-Cookie
* (wird vom Browser automatisch mitgesendet).
*
* Returns `true` wenn der Refresh erfolgreich war, sonst `false`.
* Bei `false` ist die Session geleert worden.
*/
async tryRefresh(): Promise<boolean> {
if (this.refreshing) return this.refreshing;
this.refreshing = (async () => {
try {
const res = await fetch(`${authBaseUrl()}/api/v1/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const data = (await res.json()) as { accessToken?: string };
if (!data.accessToken) return false;
this.token = data.accessToken;
const claims = decodeJwt(data.accessToken);
if (claims) {
this.user = userFromClaims(claims);
}
this.persist();
return true;
} catch {
return false;
} finally {
this.refreshing = null;
}
})();
return this.refreshing;
}
/**
* Lädt das User-Profil aus dem aktuell gehaltenen Token. Aufrufen
* direkt nach `tryRefresh()` im Callback-Handler, damit `user` synchron
* gesetzt ist bevor das Layout rendert.
*/
loadUserFromToken(): void {
if (!this.token) return;
const claims = decodeJwt(this.token);
if (!claims) return;
this.user = userFromClaims(claims);
this.persist();
}
/** Liefert den aktuellen Token. Refresht silently bei nahem Ablauf. */
async getValidToken(): Promise<string | null> {
await this.ensureFresh();
return this.token;
}
/** Synchroner Getter — kein Refresh. Nutzt das, was lokal liegt. */
getAccessToken(): string | null {
return this.token;
}
/** Wenn der Token <60s Restlaufzeit hat: refreshen. */
async ensureFresh(): 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();
}
}
/**
* Logout. Ruft den Portal-Endpoint zum Cookie-Invalidieren auf
* (best-effort), löscht lokale Tokens.
*
* Der Caller ist verantwortlich, danach den Redirect zum Portal
* zu machen (`redirectToPortal()`) die Session selbst macht keine
* Navigation.
*/
async signOut(): Promise<void> {
const had = !!this.token;
this.token = null;
this.user = null;
this.clearLocal();
if (had && typeof window !== 'undefined') {
try {
await fetch(`${authBaseUrl()}/api/v1/auth/logout`, {
method: 'POST',
credentials: 'include',
});
} catch {
// Best-effort — auch ohne Server-Logout ist der lokale
// Token weg und der Caller leitet eh zum Portal weiter.
}
}
}
/** Komplett-Reset ohne Server-Call (z.B. bei API-401). */
clear(): void {
this.token = null;
this.user = null;
this.clearLocal();
}
private persist(): void {
if (typeof window === 'undefined') return;
if (this.token) window.localStorage.setItem(TOKEN_KEY, this.token);
else window.localStorage.removeItem(TOKEN_KEY);
if (this.user) window.localStorage.setItem(USER_KEY, JSON.stringify(this.user));
else window.localStorage.removeItem(USER_KEY);
}
private clearLocal(): void {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
}
}
/** Singleton — die ganze App nutzt diese Instanz. */
export const session = new Session();
/** Internal helper für andere Auth-Module (settings-client, vault-unlock). */
export function _authBaseUrl(): string {
return authBaseUrl();
}

View file

@ -0,0 +1,248 @@
/**
* Settings-Client Account-Settings-Operationen (Passkey-CRUD,
* 2FA-Setup, Sessions-Liste, Audit-Log).
*
* Diese Methoden brauchen einen authentisierten User. Sie ziehen
* den Token aus dem `session`-Singleton (`session.svelte.ts`).
*
* Alle Calls gehen gegen `mana-auth` (BASE_URL aus
* `PUBLIC_MANA_AUTH_URL`). Cookies werden mitgesendet (Better-Auth
* verwaltet zusätzlich zur JWT-Auth den Session-State auch im
* Cookie für CSRF / Re-Auth-Flows).
*/
import { _authBaseUrl, session } from './session.svelte';
async function authHeaders(): Promise<HeadersInit> {
const token = await session.getValidToken();
if (!token) throw new Error('Not authenticated');
return {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
}
async function expectOk(res: Response, op: string): Promise<unknown> {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
typeof body === 'object' && body && 'message' in body
? String((body as { message: unknown }).message)
: `${op} failed (HTTP ${res.status})`
);
}
const text = await res.text();
return text ? JSON.parse(text) : null;
}
// ─── Passkeys ────────────────────────────────────────────────
/** Shape, der von der UI (`PasskeyManager` aus `@mana/shared-auth-ui`) erwartet wird. */
export interface PasskeyEntry {
id: string;
credentialId: string;
deviceType: string;
backedUp: boolean;
friendlyName: string | null;
lastUsedAt: string | null;
createdAt: string;
}
export interface PasskeyCapability {
browser: boolean;
conditionalUI: boolean;
server: boolean;
available: boolean;
rpId: string | null;
}
export const passkeys = {
async list(): Promise<PasskeyEntry[]> {
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys`, {
credentials: 'include',
headers: await authHeaders(),
});
const data = (await expectOk(res, 'passkeys.list')) as { passkeys?: PasskeyEntry[] };
return data?.passkeys ?? [];
},
async capability(): Promise<PasskeyCapability> {
const base = _authBaseUrl();
const res = await fetch(`${base}/api/v1/auth/passkeys/capability`);
if (!res.ok) {
return { browser: false, conditionalUI: false, server: false, available: false, rpId: null };
}
const data = (await res.json()) as { enabled?: boolean; rpId?: string | null };
const browser = typeof window !== 'undefined' && 'PublicKeyCredential' in window;
let conditionalUI = false;
if (browser) {
try {
const PKC = (window as unknown as { PublicKeyCredential: typeof PublicKeyCredential })
.PublicKeyCredential;
if (typeof PKC.isConditionalMediationAvailable === 'function') {
conditionalUI = await PKC.isConditionalMediationAvailable();
}
} catch {
/* ignore */
}
}
const server = !!data.enabled;
return {
browser,
conditionalUI,
server,
available: browser && server,
rpId: data.rpId ?? null,
};
},
async register(friendlyName?: string): Promise<void> {
const { startRegistration } = await import('@simplewebauthn/browser');
const headers = await authHeaders();
const optionsRes = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/register/options`, {
method: 'POST',
credentials: 'include',
headers,
});
const webauthnOptions = (await expectOk(optionsRes, 'passkeys.register.options')) as Parameters<
typeof startRegistration
>[0]['optionsJSON'];
const credential = await startRegistration({ optionsJSON: webauthnOptions });
const verifyRes = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/register/verify`, {
method: 'POST',
credentials: 'include',
headers,
body: JSON.stringify({ response: credential, name: friendlyName }),
});
await expectOk(verifyRes, 'passkeys.register.verify');
},
async delete(passkeyId: string): Promise<void> {
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/${passkeyId}`, {
method: 'DELETE',
credentials: 'include',
headers: await authHeaders(),
});
await expectOk(res, 'passkeys.delete');
},
async rename(passkeyId: string, friendlyName: string): Promise<void> {
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/passkeys/${passkeyId}`, {
method: 'PATCH',
credentials: 'include',
headers: await authHeaders(),
body: JSON.stringify({ name: friendlyName }),
});
await expectOk(res, 'passkeys.rename');
},
};
// ─── 2FA (TOTP + Backup Codes) ───────────────────────────────
export interface TwoFactorEnableResponse {
secret: string;
uri: string;
backupCodes: string[];
}
export interface BackupCodesResponse {
backupCodes: string[];
}
export const twoFactor = {
async enable(password: string): Promise<TwoFactorEnableResponse> {
const res = await fetch(`${_authBaseUrl()}/api/auth/two-factor/enable`, {
method: 'POST',
credentials: 'include',
headers: await authHeaders(),
body: JSON.stringify({ password }),
});
const data = (await expectOk(res, 'twoFactor.enable')) as TwoFactorEnableResponse;
// Refresh Token so der JWT den neuen `twoFactorEnabled`-Claim trägt.
await session.tryRefresh();
return data;
},
async disable(password: string): Promise<void> {
const res = await fetch(`${_authBaseUrl()}/api/auth/two-factor/disable`, {
method: 'POST',
credentials: 'include',
headers: await authHeaders(),
body: JSON.stringify({ password }),
});
await expectOk(res, 'twoFactor.disable');
await session.tryRefresh();
},
async generateBackupCodes(password: string): Promise<BackupCodesResponse> {
const res = await fetch(`${_authBaseUrl()}/api/auth/two-factor/generate-backup-codes`, {
method: 'POST',
credentials: 'include',
headers: await authHeaders(),
body: JSON.stringify({ password }),
});
return (await expectOk(res, 'twoFactor.generateBackupCodes')) as BackupCodesResponse;
},
};
// ─── Sessions ────────────────────────────────────────────────
/** Shape, der von der UI (`SessionManager` aus `@mana/shared-auth-ui`) erwartet wird. */
export interface SessionEntry {
id: string;
ipAddress: string | null;
userAgent: string | null;
deviceId: string | null;
deviceName: string | null;
lastActivityAt: string | null;
createdAt: string;
expiresAt: string;
}
export const sessions = {
async list(): Promise<SessionEntry[]> {
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/sessions`, {
credentials: 'include',
headers: await authHeaders(),
});
const data = (await expectOk(res, 'sessions.list')) as { sessions?: SessionEntry[] };
return data?.sessions ?? [];
},
async revoke(sessionId: string): Promise<void> {
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include',
headers: await authHeaders(),
});
await expectOk(res, 'sessions.revoke');
},
};
// ─── Audit / Security Events ─────────────────────────────────
/** Shape, der von der UI (`AuditLog` aus `@mana/shared-auth-ui`) erwartet wird. */
export interface SecurityEvent {
id: string;
eventType: string;
ipAddress: string | null;
userAgent: string | null;
metadata: unknown;
createdAt: string;
}
export const audit = {
async getSecurityEvents(limit = 50): Promise<SecurityEvent[]> {
const res = await fetch(`${_authBaseUrl()}/api/v1/auth/security-events?limit=${limit}`, {
credentials: 'include',
headers: await authHeaders(),
});
const data = (await expectOk(res, 'audit.getSecurityEvents')) as {
events?: SecurityEvent[];
};
return data?.events ?? [];
},
};

View file

@ -7,6 +7,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n'; import { locale } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { redirectToPortal } from '$lib/auth/portal-redirect';
interface Props { interface Props {
appName: string; appName: string;
@ -71,7 +72,7 @@
class="w-full cursor-pointer rounded-lg border px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90" class="w-full cursor-pointer rounded-lg border px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
style:border-color="hsl(var(--color-border, 0 0% 90%))" style:border-color="hsl(var(--color-border, 0 0% 90%))"
style:color="hsl(var(--color-foreground, 0 0% 9%))" style:color="hsl(var(--color-foreground, 0 0% 9%))"
onclick={() => goto('/login')} onclick={() => redirectToPortal()}
> >
{isDE ? 'Anmelden' : 'Sign in'} {isDE ? 'Anmelden' : 'Sign in'}
</button> </button>

View file

@ -4,25 +4,66 @@
import { ShieldCheck } from '@mana/shared-icons'; import { ShieldCheck } from '@mana/shared-icons';
import { PasskeyManager, TwoFactorSetup, AuditLog, SessionManager } from '@mana/shared-auth-ui'; import { PasskeyManager, TwoFactorSetup, AuditLog, SessionManager } from '@mana/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import {
passkeys as passkeysClient,
sessions as sessionsClient,
twoFactor as twoFactorClient,
audit as auditClient,
type PasskeyEntry,
type SessionEntry,
type SecurityEvent,
} from '$lib/auth/settings-client';
import SettingsPanel from '../SettingsPanel.svelte'; import SettingsPanel from '../SettingsPanel.svelte';
import SettingsSectionHeader from '../SettingsSectionHeader.svelte'; import SettingsSectionHeader from '../SettingsSectionHeader.svelte';
import VaultSection from './VaultSection.svelte'; import VaultSection from './VaultSection.svelte';
let passkeys = $state<any[]>([]); let passkeys = $state<PasskeyEntry[]>([]);
let sessions = $state<any[]>([]); let passkeyAvailable = $state(false);
let sessions = $state<SessionEntry[]>([]);
let sessionsLoading = $state(false); let sessionsLoading = $state(false);
let securityEvents = $state<any[]>([]); let securityEvents = $state<SecurityEvent[]>([]);
let securityEventsLoading = $state(false); let securityEventsLoading = $state(false);
// Adapter: die UI-Komponenten erwarten `{ success, error? }`-Returns,
// unsere Settings-Client-Methoden werfen. Hier einmal zentral übersetzen.
function asResult<T>(p: Promise<T>): Promise<{ success: boolean; error?: string }> {
return p
.then(() => ({ success: true as const }))
.catch((e: unknown) => ({
success: false as const,
error: e instanceof Error ? e.message : String(e),
}));
}
async function handleEnable(password: string) {
try {
const r = await twoFactorClient.enable(password);
return { success: true as const, totpURI: r.uri, backupCodes: r.backupCodes };
} catch (e) {
return { success: false as const, error: e instanceof Error ? e.message : String(e) };
}
}
async function handleGenerateBackupCodes(password: string) {
try {
const r = await twoFactorClient.generateBackupCodes(password);
return { success: true as const, backupCodes: r.backupCodes };
} catch (e) {
return { success: false as const, error: e instanceof Error ? e.message : String(e) };
}
}
onMount(async () => { onMount(async () => {
if (!authStore.isAuthenticated) return; if (!authStore.isAuthenticated) return;
try { try {
passkeys = await authStore.listPasskeys(); const cap = await passkeysClient.capability();
passkeyAvailable = cap.available;
passkeys = await passkeysClient.list();
sessionsLoading = true; sessionsLoading = true;
sessions = await authStore.listSessions(); sessions = await sessionsClient.list();
sessionsLoading = false; sessionsLoading = false;
securityEventsLoading = true; securityEventsLoading = true;
securityEvents = await authStore.getSecurityEvents(); securityEvents = await auditClient.getSecurityEvents();
securityEventsLoading = false; securityEventsLoading = false;
} catch (e) { } catch (e) {
console.error('SecuritySection load failed:', e); console.error('SecuritySection load failed:', e);
@ -44,12 +85,12 @@
<SettingsPanel id="passkeys"> <SettingsPanel id="passkeys">
<PasskeyManager <PasskeyManager
{passkeys} {passkeys}
passkeyAvailable={authStore.isPasskeyAvailable()} {passkeyAvailable}
onRegister={(name) => authStore.registerPasskey(name)} onRegister={(name) => asResult(passkeysClient.register(name))}
onDelete={(id) => authStore.deletePasskey(id)} onDelete={(id) => asResult(passkeysClient.delete(id))}
onRename={(id, name) => authStore.renamePasskey(id, name)} onRename={(id, name) => asResult(passkeysClient.rename(id, name))}
onRefresh={async () => { onRefresh={async () => {
passkeys = await authStore.listPasskeys(); passkeys = await passkeysClient.list();
}} }}
primaryColor="hsl(var(--color-primary))" primaryColor="hsl(var(--color-primary))"
/> />
@ -59,10 +100,10 @@
<SessionManager <SessionManager
{sessions} {sessions}
loading={sessionsLoading} loading={sessionsLoading}
onRevoke={(id) => authStore.revokeSession(id)} onRevoke={(id) => asResult(sessionsClient.revoke(id))}
onRefresh={async () => { onRefresh={async () => {
sessionsLoading = true; sessionsLoading = true;
sessions = await authStore.listSessions(); sessions = await sessionsClient.list();
sessionsLoading = false; sessionsLoading = false;
}} }}
primaryColor="hsl(var(--color-primary))" primaryColor="hsl(var(--color-primary))"
@ -72,9 +113,9 @@
<SettingsPanel id="two-factor"> <SettingsPanel id="two-factor">
<TwoFactorSetup <TwoFactorSetup
enabled={!!authStore.user?.twoFactorEnabled} enabled={!!authStore.user?.twoFactorEnabled}
onEnable={(password) => authStore.enableTwoFactor(password)} onEnable={handleEnable}
onDisable={(password) => authStore.disableTwoFactor(password)} onDisable={(password) => asResult(twoFactorClient.disable(password))}
onGenerateBackupCodes={(password) => authStore.generateBackupCodes(password)} onGenerateBackupCodes={handleGenerateBackupCodes}
primaryColor="hsl(var(--color-primary))" primaryColor="hsl(var(--color-primary))"
/> />
</SettingsPanel> </SettingsPanel>
@ -89,7 +130,7 @@
loading={securityEventsLoading} loading={securityEventsLoading}
onRefresh={async () => { onRefresh={async () => {
securityEventsLoading = true; securityEventsLoading = true;
securityEvents = await authStore.getSecurityEvents(); securityEvents = await auditClient.getSecurityEvents();
securityEventsLoading = false; securityEventsLoading = false;
}} }}
primaryColor="hsl(var(--color-primary))" primaryColor="hsl(var(--color-primary))"

View file

@ -9,6 +9,7 @@
import ItemCard from '../components/ItemCard.svelte'; import ItemCard from '../components/ItemCard.svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { feedbackService } from '$lib/api/feedback'; import { feedbackService } from '$lib/api/feedback';
import { portalHref } from '$lib/auth/portal-redirect';
interface Props { interface Props {
id: string; id: string;
@ -118,7 +119,7 @@
</div> </div>
{:else} {:else}
<p class="login-hint"> <p class="login-hint">
<a href="/login" class="link">Login</a>, um zu antworten. <a href={portalHref()} class="link">Login</a>, um zu antworten.
</p> </p>
{/if} {/if}
</section> </section>

View file

@ -10,6 +10,7 @@
import { useCommunityFeed, toggleReactionOnItem } from '../queries.svelte'; import { useCommunityFeed, toggleReactionOnItem } from '../queries.svelte';
import ItemCard from '../components/ItemCard.svelte'; import ItemCard from '../components/ItemCard.svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { portalHref } from '$lib/auth/portal-redirect';
interface Props { interface Props {
/** Optional initial moduleContext filter — passed by the /** Optional initial moduleContext filter — passed by the
@ -82,7 +83,7 @@
Noch keine Stimmen — sei der erste, der was reinwirft. Noch keine Stimmen — sei der erste, der was reinwirft.
{#if !authStore.user} {#if !authStore.user}
<br /> <br />
<a href="/login" class="link">Login</a>, um mitzumachen. <a href={portalHref()} class="link">Login</a>, um mitzumachen.
{/if} {/if}
</div> </div>
{:else} {:else}

View file

@ -7,6 +7,7 @@
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast.svelte'; import { toast } from '$lib/stores/toast.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { redirectToPortal } from '$lib/auth/portal-redirect';
import { import {
EditProfileModal, EditProfileModal,
ChangePasswordModal, ChangePasswordModal,
@ -69,7 +70,7 @@
async function handleAccountDeleted() { async function handleAccountDeleted() {
toast.info($_('profile.hub.toast_account_deleting')); toast.info($_('profile.hub.toast_account_deleting'));
await authStore.signOut(); await authStore.signOut();
goto('/login'); redirectToPortal({ next: '/' });
} }
</script> </script>
@ -232,7 +233,7 @@
class="account-btn" class="account-btn"
onclick={async () => { onclick={async () => {
await authStore.signOut(); await authStore.signOut();
goto('/login'); redirectToPortal({ next: '/' });
}} }}
> >
{$_('profile.logout')} {$_('profile.logout')}

View file

@ -1,7 +1,28 @@
/** /**
* Auth Store uses centralized Mana auth factory. * Auth Store re-export der neuen Session-Klasse.
*
* Bis 2026-05-12 war das ein Wrapper über `createManaAuthStore()` aus
* `@mana/shared-auth-ui`, der 47 Methoden mitbrachte (Login, Register,
* Reset, Passkey-CRUD, 2FA-Setup, Sessions, Audit). Mit dem Wechsel
* auf den zentralen Auth-Portal sind die Login-/Register-/Reset-Flows
* komplett rausgeflogen sie leben jetzt auf `auth.mana.how`.
*
* Was bleibt:
* - `authStore.user`, `authStore.isAuthenticated`, `authStore.initialized`,
* `authStore.loading` der reaktive Session-State.
* - `authStore.initialize()` Boot-Pass.
* - `authStore.signOut()` Logout (kein Redirect; den macht der Caller).
* - `authStore.getValidToken()`, `authStore.getAccessToken()` Token-Access.
*
* Was wegfällt (Settings `$lib/auth/settings-client.ts`):
* - Passkey-CRUD `import { passkeys } from '$lib/auth/settings-client'`
* - 2FA-Setup `import { twoFactor } from '$lib/auth/settings-client'`
* - Sessions / Audit `import { sessions, audit } from '$lib/auth/settings-client'`
*
* Was ganz wegfällt (passiert jetzt im Portal):
* - `signIn`, `signUp`, `resetPassword`, `resetPasswordWithToken`,
* `resendVerificationEmail`, `verifyTwoFactor`, `verifyBackupCode`,
* `sendMagicLink`, `signInWithPasskey`.
*/ */
import { createManaAuthStore } from '@mana/shared-auth-ui'; export { session as authStore } from '$lib/auth/session.svelte';
export const authStore = createManaAuthStore();

View file

@ -25,12 +25,15 @@
*/ */
import type { BottomNotification } from '@mana/shared-ui'; import type { BottomNotification } from '@mana/shared-ui';
import { portalHref } from '$lib/auth/portal-redirect';
let prompts = $state<BottomNotification[]>([]); let prompts = $state<BottomNotification[]>([]);
/** Default action target the login page already exposes both login /** Default action target the central auth portal (auth.mana.how) handles
* and register flows so we point at one URL and let the user pick. */ * both login and register flows in one UI. */
const DEFAULT_LOGIN_HREF = '/login'; function defaultLoginHref(): string {
return portalHref();
}
/** Navigates the browser. Kept as a small wrapper so unit tests can /** Navigates the browser. Kept as a small wrapper so unit tests can
* swap it out without pulling SvelteKit's `goto`. */ * swap it out without pulling SvelteKit's `goto`. */
@ -79,7 +82,7 @@ export const guestPrompt = {
action: { action: {
label: actionLabel, label: actionLabel,
onClick: () => { onClick: () => {
navigate(DEFAULT_LOGIN_HREF); navigate(defaultLoginHref());
guestPrompt.dismiss(id); guestPrompt.dismiss(id);
}, },
}, },

View file

@ -7,6 +7,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { redirectToPortal, portalHref } from '$lib/auth/portal-redirect';
import type { Component, Snippet } from 'svelte'; import type { Component, Snippet } from 'svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte'; import ToastContainer from '$lib/components/ToastContainer.svelte';
import FeedbackQuickModal from '$lib/components/feedback/FeedbackQuickModal.svelte'; import FeedbackQuickModal from '$lib/components/feedback/FeedbackQuickModal.svelte';
@ -497,7 +498,7 @@
guestMode?.destroy(); guestMode?.destroy();
setErrorTrackingUser(null); setErrorTrackingUser(null);
await authStore.signOut(); await authStore.signOut();
goto('/login'); redirectToPortal({ next: '/' });
} }
// ── Guest Mode ────────────────────────────────────────── // ── Guest Mode ──────────────────────────────────────────
@ -739,7 +740,7 @@
markAsGuest(); markAsGuest();
guestMode = createGuestMode('mana', { guestMode = createGuestMode('mana', {
nudgeDelayMinutes: 3, nudgeDelayMinutes: 3,
onRegister: () => goto('/register'), onRegister: () => redirectToPortal({ target: 'register' }),
}); });
} }
} }
@ -1062,7 +1063,7 @@
{languageItems} {languageItems}
{currentLanguageLabel} {currentLanguageLabel}
showLogout={authStore.isAuthenticated} showLogout={authStore.isAuthenticated}
loginHref="/login" loginHref={portalHref()}
primaryColor="hsl(var(--color-primary))" primaryColor="hsl(var(--color-primary))"
showAppSwitcher={false} showAppSwitcher={false}
showAiTierSelector={true} showAiTierSelector={true}
@ -1152,8 +1153,8 @@
appId="mana" appId="mana"
visible={guestMode.showWelcome} visible={guestMode.showWelcome}
onClose={() => guestMode?.dismissWelcome()} onClose={() => guestMode?.dismissWelcome()}
onLogin={() => goto('/login')} onLogin={() => redirectToPortal()}
onRegister={() => goto('/register')} onRegister={() => redirectToPortal({ target: 'register' })}
locale={($locale || 'de') === 'de' ? 'de' : 'en'} locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/> />
{/if} {/if}

View file

@ -2,6 +2,7 @@
import { Heart } from '@mana/shared-icons'; import { Heart } from '@mana/shared-icons';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { portalHref } from '$lib/auth/portal-redirect';
import { RoutePage } from '$lib/components/shell'; import { RoutePage } from '$lib/components/shell';
import { import {
favoritesStore, favoritesStore,
@ -38,7 +39,7 @@
<div class="rounded-xl border border-border bg-background-card p-8 text-center"> <div class="rounded-xl border border-border bg-background-card p-8 text-center">
<p class="mb-4 text-foreground-secondary">{$_('favorites.loginRequired')}</p> <p class="mb-4 text-foreground-secondary">{$_('favorites.loginRequired')}</p>
<a <a
href="/login" href={portalHref()}
class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90" class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
> >
{$_('settings.login')} {$_('settings.login')}

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { redirectToPortal } from '$lib/auth/portal-redirect';
import { favoritesStore } from '$lib/modules/quotes/stores/favorites.svelte'; import { favoritesStore } from '$lib/modules/quotes/stores/favorites.svelte';
import { type Favorite } from '$lib/modules/quotes/queries'; import { type Favorite } from '$lib/modules/quotes/queries';
import { getQuoteById, getQuoteText, type Quote } from '@quotes/content'; import { getQuoteById, getQuoteText, type Quote } from '@quotes/content';
@ -97,7 +97,7 @@
<User size={20} class="mx-auto text-foreground-muted mb-4" /> <User size={20} class="mx-auto text-foreground-muted mb-4" />
<p class="text-foreground-secondary mb-4">{$_('favorites.loginPrompt')}</p> <p class="text-foreground-secondary mb-4">{$_('favorites.loginPrompt')}</p>
<button <button
onclick={() => goto('/login')} onclick={() => redirectToPortal()}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors" class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
> >
{$_('auth.login')} {$_('auth.login')}

View file

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { redirectToPortal } from '$lib/auth/portal-redirect';
import { toast } from '$lib/stores/toast.svelte'; import { toast } from '$lib/stores/toast.svelte';
import { listsStore } from '$lib/modules/quotes/stores/lists.svelte'; import { listsStore } from '$lib/modules/quotes/stores/lists.svelte';
import { type QuoteList } from '$lib/modules/quotes/queries'; import { type QuoteList } from '$lib/modules/quotes/queries';
@ -89,7 +89,7 @@
</div> </div>
<p class="text-foreground-secondary mb-4">{$_('lists.loginPrompt')}</p> <p class="text-foreground-secondary mb-4">{$_('lists.loginPrompt')}</p>
<button <button
onclick={() => goto('/login')} onclick={() => redirectToPortal()}
class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors" class="px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary-hover transition-colors"
> >
{$_('auth.login')} {$_('auth.login')}

View file

@ -1,34 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
import { Sun, Moon } from '@mana/shared-icons';
let { children }: { children: Snippet } = $props();
let isDark = $derived(theme.isDark);
// If user is already authenticated when visiting auth pages, redirect to home
onMount(async () => {
await authStore.initialize();
if (authStore.isAuthenticated) {
goto('/');
}
});
</script>
<!-- Theme toggle for auth pages -->
<button
type="button"
onclick={() => theme.toggleMode()}
class="fixed top-4 right-4 z-50 rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
aria-label="Toggle dark mode"
>
{#if isDark}
<Sun size={20} />
{:else}
<Moon size={20} />
{/if}
</button>
{@render children()}

View file

@ -1,28 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@mana/shared-auth-ui';
import { ManaLogo } from '@mana/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/auth.svelte';
async function handleForgotPassword(email: string) {
// resetPassword is the wrapper's "send-the-email" entry point.
// resetPasswordWithToken is the actually-perform-the-reset step.
return authStore.resetPassword(email);
}
</script>
<ForgotPasswordPage
appName="Mana"
logo={ManaLogo}
primaryColor="hsl(var(--color-primary))"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#f3f4f6"
darkBackground="#121212"
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -1,62 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { LoginPage } from '@mana/shared-auth-ui';
import { ManaLogo } from '@mana/shared-branding';
import { getLoginTranslations } from '@mana/shared-i18n';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
import { APP_VERSION, BUILD_TIME } from '$lib/version';
const isDark = $derived(theme.isDark);
// Get translations based on current locale
const translations = $derived(getLoginTranslations($locale || 'de'));
// Read verification status from query params (set after email verification)
const verified = $derived($page.url.searchParams.get('verified') === 'true');
const initialEmail = $derived($page.url.searchParams.get('email') || '');
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<LoginPage
appName="Mana"
logo={ManaLogo}
primaryColor="hsl(var(--color-primary))"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.passkeyAvailable === true}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)}
onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)}
onSendMagicLink={(email) => authStore.sendMagicLink(email)}
{goto}
successRedirect="/"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f3f4f6"
darkBackground="#121212"
{translations}
{verified}
{initialEmail}
{isDark}
version={APP_VERSION}
buildTime={BUILD_TIME}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -1,55 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@mana/shared-auth-ui';
import { getRegisterTranslations } from '@mana/shared-i18n';
import { ManaLogo } from '@mana/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { trackGuestConversion } from '$lib/stores/funnel-tracking';
import '$lib/i18n';
// Get translations based on current locale
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleSignUp(email: string, password: string) {
const result = await authStore.signUp(email, password);
if (result.success) {
// Tracking must never block the success redirect.
queueMicrotask(() => {
try {
trackGuestConversion();
} catch {
/* swallow tracking errors */
}
});
}
return result;
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
<title>{translations.title} | Mana</title>
</svelte:head>
<RegisterPage
appName="Mana"
logo={ManaLogo}
primaryColor="hsl(var(--color-primary))"
onSignUp={handleSignUp}
onResendVerification={handleResendVerification}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#f3f4f6"
darkBackground="#121212"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</RegisterPage>

View file

@ -1,168 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button, Input, Card } from '@mana/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
let loading = $state(false);
let hasToken = $state(false);
let token = $state<string | null>(null);
let password = $state('');
let confirmPassword = $state('');
let error = $state<string | null>(null);
let success = $state(false);
onMount(() => {
// Get token from URL query parameter
token = $page.url.searchParams.get('token');
hasToken = !!token;
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
if (!token) {
error = 'Reset token is missing';
return;
}
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 12) {
error = 'Password must be at least 12 characters';
return;
}
loading = true;
try {
const result = await authStore.resetPasswordWithToken(token, password);
if (!result.success) {
error = result.error || 'Failed to reset password';
} else {
success = true;
// Redirect to login after 3 seconds
setTimeout(() => {
goto('/login');
}, 3000);
}
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
} finally {
loading = false;
}
}
</script>
<div>
<div class="text-center">
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
<p class="text-gray-600 dark:text-gray-400">
{#if success}
Password reset successfully
{:else if hasToken}
Enter your new password
{:else}
Invalid or missing reset token
{/if}
</p>
</div>
{#if success}
<Card class="mt-8">
<div class="text-center">
<div class="mb-4 text-6xl"></div>
<p class="mb-4 text-gray-600 dark:text-gray-400">
Your password has been reset successfully. You will be redirected to the login page
shortly.
</p>
<a
href="/login"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
>
Go to login
</a>
</div>
</Card>
{:else if hasToken}
<Card class="mt-8">
<form onsubmit={handleSubmit}>
{#if error}
<div
class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{error}
</div>
{/if}
<div class="space-y-4">
<div>
<label
for="password"
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
>
New Password
</label>
<Input
type="password"
name="password"
id="password"
autocomplete="new-password"
placeholder="Enter new password"
required
bind:value={password}
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 12 characters
</p>
</div>
<div>
<label
for="confirmPassword"
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
>
Confirm Password
</label>
<Input
type="password"
name="confirmPassword"
id="confirmPassword"
autocomplete="new-password"
placeholder="Confirm new password"
required
bind:value={confirmPassword}
/>
</div>
<div>
<Button type="submit" {loading} class="w-full">
{loading ? 'Updating password...' : 'Update password'}
</Button>
</div>
</div>
</form>
</Card>
{:else}
<Card class="mt-8">
<div class="text-center">
<div class="mb-4 text-6xl">⚠️</div>
<p class="mb-4 text-gray-600 dark:text-gray-400">
This password reset link is invalid or has expired.
</p>
<a
href="/forgot-password"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400"
>
Request a new reset link
</a>
</div>
</Card>
{/if}
</div>

View file

@ -1,88 +1,71 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/state';
import { session } from '$lib/auth/session.svelte';
import { redirectToPortal } from '$lib/auth/portal-redirect';
let error = $state<string | null>(null); let error = $state<string | null>(null);
let processing = $state(true);
onMount(async () => { onMount(async () => {
const urlParams = $page.url.searchParams; // Token via SSO-Cookie holen — der Browser sendet `mana_session`
const code = urlParams.get('code'); // automatisch mit, weil das Cookie auf `.mana.how` lebt.
const errorParam = urlParams.get('error'); const ok = await session.tryRefresh();
const errorDescription = urlParams.get('error_description');
const type = urlParams.get('type'); // 'signup' or 'recovery' or 'invite' etc.
const appName = urlParams.get('appName'); // App name for branding
const next = urlParams.get('next');
// Handle OAuth errors if (!ok) {
if (errorParam) { // Cookie weg oder Session expired → zurück aufs Portal mit
error = errorDescription || errorParam; // dem aktuellen Callback als Redirect-URL, damit der User
processing = false; // nach erneutem Login wieder hier landet.
setTimeout(() => { redirectToPortal({ next: page.url.searchParams.get('next') ?? '/' });
goto(`/login?error=${encodeURIComponent(error || 'Authentication failed')}`);
}, 2000);
return; return;
} }
// Determine redirect destination session.loadUserFromToken();
let redirectUrl = next || '/';
// For email verification/signup, redirect to welcome page // Vault-Unlock läuft automatisch im Root-Layout-$effect, sobald
// `session.user.id` gesetzt ist (siehe routes/+layout.svelte).
// Wir warten hier NICHT explizit — der User soll seine App so
// schnell wie möglich sehen. Encrypted-Reads vor dem Unlock
// rendern leere Liste; die liveQuery re-evaluiert sobald
// `vaultClient.unlock()` durchgelaufen ist.
const next = page.url.searchParams.get('next');
const type = page.url.searchParams.get('type');
// Email-Verification-Klick oder Signup-Welcome-Flow → Welcome-Page.
if (type === 'signup' || type === 'email_verification') { if (type === 'signup' || type === 'email_verification') {
redirectUrl = `/welcome${appName ? `?appName=${encodeURIComponent(appName)}` : ''}`; goto('/welcome');
return;
} }
// Handle OAuth callback with code // Sonst: Deep-Link wiederherstellen oder Default.
if (code) { goto(next && next.startsWith('/') ? next : '/');
try {
// Exchange code for session
// This is handled by Supabase automatically via the auth callback
// Just wait a moment and redirect
setTimeout(() => {
goto(redirectUrl);
}, 1000);
} catch (err) {
console.error('Auth callback error:', err);
error = 'Failed to complete authentication';
processing = false;
setTimeout(() => {
goto(`/login?error=${encodeURIComponent(error || '')}`);
}, 2000);
}
} else if (type) {
// Handle email confirmation callback (from Supabase email links)
// The session should already be set by Supabase
// Just redirect to the appropriate page
setTimeout(() => {
goto(redirectUrl);
}, 1000);
} else {
// No code or type, redirect to login
goto('/login');
}
}); });
</script> </script>
<svelte:head> <svelte:head>
<title>Authenticating... - Mana</title> <title>Anmeldung wird abgeschlossen Mana</title>
</svelte:head> </svelte:head>
<div class="flex min-h-screen items-center justify-center"> <div class="flex min-h-screen items-center justify-center">
<div class="w-full max-w-md rounded-2xl bg-white p-8 text-center shadow-lg dark:bg-gray-800"> <div class="w-full max-w-md rounded-2xl bg-white p-8 text-center shadow-lg dark:bg-gray-800">
{#if processing} {#if error}
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
<h2 class="mb-2 text-2xl font-bold">Authenticating...</h2>
<p class="text-gray-600 dark:text-gray-400">Please wait while we complete your sign-in.</p>
{:else if error}
<div class="mb-4 text-6xl">⚠️</div> <div class="mb-4 text-6xl">⚠️</div>
<h2 class="mb-2 text-2xl font-bold text-red-600 dark:text-red-400">Authentication Error</h2> <h2 class="mb-2 text-2xl font-bold text-red-600 dark:text-red-400">
Authentifizierungsfehler
</h2>
<p class="mb-4 text-gray-600 dark:text-gray-400"> <p class="mb-4 text-gray-600 dark:text-gray-400">
{error} {error}
</p> </p>
<p class="text-sm text-gray-500 dark:text-gray-400">Redirecting you back to login...</p> <p class="text-sm text-gray-500 dark:text-gray-400">
Du wirst zurück zur Anmeldung geleitet…
</p>
{:else}
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
<h2 class="mb-2 text-2xl font-bold">Anmeldung wird abgeschlossen…</h2>
<p class="text-gray-600 dark:text-gray-400">Einen Moment bitte.</p>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -1,23 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
// Redirect to the main reset-password page, preserving the token query parameter
onMount(() => {
const token = $page.url.searchParams.get('token');
const redirectUrl = token ? `/reset-password?token=${token}` : '/reset-password';
goto(redirectUrl, { replaceState: true });
});
</script>
<div
class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 dark:bg-gray-900 sm:px-6 lg:px-8"
>
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"
></div>
<p class="text-gray-600 dark:text-gray-400">Redirecting...</p>
</div>
</div>

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { Check } from '@mana/shared-icons'; import { Check } from '@mana/shared-icons';
import { redirectToPortal } from '$lib/auth/portal-redirect';
</script> </script>
<svelte:head> <svelte:head>
@ -26,7 +26,7 @@
</p> </p>
<button <button
onclick={() => goto('/login')} onclick={() => redirectToPortal({ next: '/' })}
class="w-full rounded-lg bg-indigo-600 px-6 py-3 font-medium text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-800" class="w-full rounded-lg bg-indigo-600 px-6 py-3 font-medium text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-800"
> >
Zur Anmeldung Zur Anmeldung

View file

@ -6,6 +6,7 @@
<script lang="ts"> <script lang="ts">
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { HeartHalf } from '@mana/shared-icons'; import { HeartHalf } from '@mana/shared-icons';
import { portalHref } from '$lib/auth/portal-redirect';
let { children } = $props(); let { children } = $props();
</script> </script>
@ -25,7 +26,7 @@
{#if authStore.user} {#if authStore.user}
<a class="cta" href="/?app=feedback">In Mana öffnen</a> <a class="cta" href="/?app=feedback">In Mana öffnen</a>
{:else} {:else}
<a class="cta" href="/login">Login</a> <a class="cta" href={portalHref({ next: '/?app=feedback' })}>Login</a>
{/if} {/if}
</nav> </nav>
</header> </header>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { X } from '@mana/shared-icons'; import { X } from '@mana/shared-icons';
import { redirectToPortal } from '$lib/auth/portal-redirect';
// Get error from URL query params // Get error from URL query params
const error = $derived($page.url.searchParams.get('error') || 'unknown_error'); const error = $derived($page.url.searchParams.get('error') || 'unknown_error');
@ -44,14 +44,14 @@
<div class="space-y-3"> <div class="space-y-3">
<button <button
onclick={() => goto('/login')} onclick={() => redirectToPortal({ next: '/' })}
class="w-full rounded-lg bg-indigo-600 px-6 py-3 font-medium text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-800" class="w-full rounded-lg bg-indigo-600 px-6 py-3 font-medium text-white transition-colors hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-neutral-800"
> >
Zur Anmeldung Zur Anmeldung
</button> </button>
<button <button
onclick={() => goto('/register')} onclick={() => redirectToPortal({ next: '/', target: 'register' })}
class="w-full rounded-lg border border-gray-300 bg-white px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:border-neutral-600 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 dark:focus:ring-offset-neutral-800" class="w-full rounded-lg border border-gray-300 bg-white px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:border-neutral-600 dark:bg-neutral-700 dark:text-gray-200 dark:hover:bg-neutral-600 dark:focus:ring-offset-neutral-800"
> >
Neues Konto erstellen Neues Konto erstellen

View file

@ -234,6 +234,12 @@ const APP_CONFIGS = [
PUBLIC_SUPABASE_URL: (env) => env.MANA_SUPABASE_URL, PUBLIC_SUPABASE_URL: (env) => env.MANA_SUPABASE_URL,
PUBLIC_SUPABASE_ANON_KEY: (env) => env.MANA_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_ANON_KEY: (env) => env.MANA_SUPABASE_ANON_KEY,
MIDDLEWARE_URL: (env) => env.MANA_AUTH_URL, MIDDLEWARE_URL: (env) => env.MANA_AUTH_URL,
// Auth-Portal: Login, Register, Reset, 2FA-Verify laufen
// exklusiv über mana-auth-web. Lokal Port 3002, in Prod
// auth.mana.how (gleicher Host wie mana-auth API — nginx
// routet /api/* dorthin).
PUBLIC_MANA_AUTH_URL: (env) => env.MANA_AUTH_URL,
PUBLIC_AUTH_WEB_URL: (env) => env.MANA_AUTH_WEB_URL || 'http://localhost:3002',
PUBLIC_UMAMI_WEBSITE_ID: (env) => env.UMAMI_WEBSITE_ID_MANA || '', PUBLIC_UMAMI_WEBSITE_ID: (env) => env.UMAMI_WEBSITE_ID_MANA || '',
PUBLIC_GLITCHTIP_DSN: (env) => env.PUBLIC_GLITCHTIP_DSN || '', PUBLIC_GLITCHTIP_DSN: (env) => env.PUBLIC_GLITCHTIP_DSN || '',
// Speech-to-Text proxy (server-side only, never exposed to the client) // Speech-to-Text proxy (server-side only, never exposed to the client)