mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:21:09 +02:00
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:
parent
d3d9271426
commit
5635598a58
27 changed files with 773 additions and 489 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
71
apps/mana/apps/web/src/lib/auth/portal-redirect.ts
Normal file
71
apps/mana/apps/web/src/lib/auth/portal-redirect.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
271
apps/mana/apps/web/src/lib/auth/session.svelte.ts
Normal file
271
apps/mana/apps/web/src/lib/auth/session.svelte.ts
Normal 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();
|
||||||
|
}
|
||||||
248
apps/mana/apps/web/src/lib/auth/settings-client.ts
Normal file
248
apps/mana/apps/web/src/lib/auth/settings-client.ts
Normal 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 ?? [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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))"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue