From cc50c0c2ab9d1f50ccbf1ef6bb597c2173cbd0f2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 27 Mar 2026 11:23:09 +0100 Subject: [PATCH] feat(auth): add password strength indicator and magic links Password strength (zxcvbn-ts): - PasswordStrength component with 4-segment color bar and German feedback - Lazy-loaded with 150ms debounce to avoid SSR/bundle issues - Integrated into RegisterPage and ChangePassword components Magic Links (passwordless email): - Better Auth magicLink plugin (10-minute expiry) - sendMagicLinkEmail() in email service (German template) - Passthrough route for /magic-link/* endpoints - sendMagicLink() in shared-auth client - "Login-Link per E-Mail senden" button on all 20 login pages - All 21 auth stores have sendMagicLink() method Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 7 +- .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 4 + .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/auth/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + .../apps/web/src/lib/stores/auth.svelte.ts | 6 ++ .../web/src/routes/(auth)/login/+page.svelte | 1 + packages/shared-auth-ui/package.json | 3 + .../src/components/ChangePassword.svelte | 3 + .../src/components/PasswordStrength.svelte | 102 ++++++++++++++++++ packages/shared-auth-ui/src/index.ts | 1 + .../shared-auth-ui/src/pages/LoginPage.svelte | 72 +++++++++++++ .../src/pages/RegisterPage.svelte | 3 + packages/shared-auth/src/core/authService.ts | 27 +++++ .../better-auth-passthrough.controller.ts | 21 ++++ .../src/auth/better-auth.config.ts | 18 ++++ .../mana-core-auth/src/email/email.service.ts | 43 ++++++++ 49 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 packages/shared-auth-ui/src/components/PasswordStrength.svelte diff --git a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts index 5e9b97ec8..f0a89f0db 100644 --- a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts @@ -145,6 +145,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte b/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte index 3036c6425..563249623 100644 --- a/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(auth)/login/+page.svelte @@ -60,6 +60,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts index 6c9df46ea..89d660a85 100644 --- a/apps/chat/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/chat/apps/web/src/lib/stores/auth.svelte.ts @@ -145,6 +145,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte b/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte index 4b062c72c..43d349584 100644 --- a/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/chat/apps/web/src/routes/(auth)/login/+page.svelte @@ -67,6 +67,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts b/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts index c9af89ab0..b9bdc3e6c 100644 --- a/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/citycorners/apps/web/src/lib/stores/auth.svelte.ts @@ -127,6 +127,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte b/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte index 0fc36c24f..f622be0bd 100644 --- a/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(auth)/login/+page.svelte @@ -51,6 +51,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts index 7b426d977..8d32bd44d 100644 --- a/apps/clock/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/auth.svelte.ts @@ -145,6 +145,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte b/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte index 6cab19920..83a406630 100644 --- a/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/clock/apps/web/src/routes/(auth)/login/+page.svelte @@ -58,6 +58,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts index 7a501c33a..18ba633aa 100644 --- a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts @@ -145,6 +145,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte b/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte index 6ad155be5..4c0d7160a 100644 --- a/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte @@ -57,6 +57,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/context/apps/web/src/lib/stores/auth.svelte.ts b/apps/context/apps/web/src/lib/stores/auth.svelte.ts index 1e649a7ee..757a83f63 100644 --- a/apps/context/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/context/apps/web/src/lib/stores/auth.svelte.ts @@ -128,6 +128,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/context/apps/web/src/routes/(auth)/login/+page.svelte b/apps/context/apps/web/src/routes/(auth)/login/+page.svelte index 2512d8b7e..6f115c6d8 100644 --- a/apps/context/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/context/apps/web/src/routes/(auth)/login/+page.svelte @@ -52,6 +52,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts index 91af0fb40..a373da08e 100644 --- a/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manacore/apps/web/src/lib/stores/auth.svelte.ts @@ -101,7 +101,12 @@ export const authStore = { } }, - /** + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + /** * Check if passkeys are available in this browser */ diff --git a/apps/manacore/apps/web/src/routes/(auth)/login/+page.svelte b/apps/manacore/apps/web/src/routes/(auth)/login/+page.svelte index 0179ede18..c4966c658 100644 --- a/apps/manacore/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(auth)/login/+page.svelte @@ -36,6 +36,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect="/dashboard" registerPath="/register" diff --git a/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts b/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts index 9d3e128bf..b8c494bed 100644 --- a/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/manadeck/apps/web/src/lib/stores/auth.svelte.ts @@ -111,6 +111,10 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { return authService.isPasskeyAvailable(); }, diff --git a/apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte b/apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte index 75107aac3..f49aa6c89 100644 --- a/apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/manadeck/apps/web/src/routes/(auth)/login/+page.svelte @@ -36,6 +36,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect="/decks" registerPath="/register" diff --git a/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts b/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts index b0e4d9bac..2bbf63e47 100644 --- a/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts @@ -129,6 +129,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte b/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte index ea1db5bb5..ce5f68ee0 100644 --- a/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte @@ -54,6 +54,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts b/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts index 9673272e7..e2a698a26 100644 --- a/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/nutriphi/apps/web/src/lib/stores/auth.svelte.ts @@ -143,6 +143,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte b/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte index fa9f4c134..2a42837cc 100644 --- a/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/nutriphi/apps/web/src/routes/(auth)/login/+page.svelte @@ -56,6 +56,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/photos/apps/web/src/lib/stores/auth.svelte.ts b/apps/photos/apps/web/src/lib/stores/auth.svelte.ts index 89e78bb3d..bc66ee750 100644 --- a/apps/photos/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/photos/apps/web/src/lib/stores/auth.svelte.ts @@ -131,6 +131,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/photos/apps/web/src/routes/(auth)/login/+page.svelte b/apps/photos/apps/web/src/routes/(auth)/login/+page.svelte index 30424adfb..7156defb5 100644 --- a/apps/photos/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/photos/apps/web/src/routes/(auth)/login/+page.svelte @@ -47,6 +47,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/picture/apps/web/src/lib/stores/auth.svelte.ts b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts index 5a9251f3e..420a83318 100644 --- a/apps/picture/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/picture/apps/web/src/lib/stores/auth.svelte.ts @@ -143,6 +143,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/picture/apps/web/src/routes/auth/login/+page.svelte b/apps/picture/apps/web/src/routes/auth/login/+page.svelte index 1196eb8c5..f75d7983c 100644 --- a/apps/picture/apps/web/src/routes/auth/login/+page.svelte +++ b/apps/picture/apps/web/src/routes/auth/login/+page.svelte @@ -40,6 +40,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect="/app/gallery" registerPath="/auth/signup" diff --git a/apps/planta/apps/web/src/lib/stores/auth.svelte.ts b/apps/planta/apps/web/src/lib/stores/auth.svelte.ts index 332e99f1e..f2c583196 100644 --- a/apps/planta/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/planta/apps/web/src/lib/stores/auth.svelte.ts @@ -137,6 +137,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/planta/apps/web/src/routes/(auth)/login/+page.svelte b/apps/planta/apps/web/src/routes/(auth)/login/+page.svelte index 08ce6942f..48ac0a238 100644 --- a/apps/planta/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/planta/apps/web/src/routes/(auth)/login/+page.svelte @@ -56,6 +56,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/playground/apps/web/src/lib/stores/auth.svelte.ts b/apps/playground/apps/web/src/lib/stores/auth.svelte.ts index 5a5091efa..748256941 100644 --- a/apps/playground/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/playground/apps/web/src/lib/stores/auth.svelte.ts @@ -106,6 +106,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte b/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte index 40285f3e9..1dbd5f445 100644 --- a/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte @@ -38,6 +38,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/presi/apps/web/src/lib/stores/auth.svelte.ts b/apps/presi/apps/web/src/lib/stores/auth.svelte.ts index 2c9ac9440..d31408d6d 100644 --- a/apps/presi/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/presi/apps/web/src/lib/stores/auth.svelte.ts @@ -142,6 +142,12 @@ export const auth = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/presi/apps/web/src/routes/(auth)/login/+page.svelte b/apps/presi/apps/web/src/routes/(auth)/login/+page.svelte index 4b01ddb33..9bfc978c9 100644 --- a/apps/presi/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/presi/apps/web/src/routes/(auth)/login/+page.svelte @@ -44,6 +44,7 @@ onSignInWithPasskey={() => auth.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => auth.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => auth.verifyBackupCode(code)} + onSendMagicLink={(email) => auth.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/questions/apps/web/src/lib/stores/auth.svelte.ts b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts index a18420491..e3ba21c37 100644 --- a/apps/questions/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/questions/apps/web/src/lib/stores/auth.svelte.ts @@ -135,6 +135,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte b/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte index 6b176a04c..db61d5420 100644 --- a/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/questions/apps/web/src/routes/(auth)/login/+page.svelte @@ -62,6 +62,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts index a3de22340..70a7a46a9 100644 --- a/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts @@ -139,6 +139,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/skilltree/apps/web/src/routes/(auth)/login/+page.svelte b/apps/skilltree/apps/web/src/routes/(auth)/login/+page.svelte index 19938caf5..6b274ef67 100644 --- a/apps/skilltree/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/skilltree/apps/web/src/routes/(auth)/login/+page.svelte @@ -62,6 +62,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/storage/apps/web/src/lib/stores/auth.svelte.ts b/apps/storage/apps/web/src/lib/stores/auth.svelte.ts index 5a7a7e86b..bcf5c0670 100644 --- a/apps/storage/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/storage/apps/web/src/lib/stores/auth.svelte.ts @@ -141,6 +141,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/todo/apps/web/src/lib/stores/auth.svelte.ts b/apps/todo/apps/web/src/lib/stores/auth.svelte.ts index 74b095c14..3befa4db2 100644 --- a/apps/todo/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/auth.svelte.ts @@ -156,6 +156,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/todo/apps/web/src/routes/(auth)/login/+page.svelte b/apps/todo/apps/web/src/routes/(auth)/login/+page.svelte index c0475ccc2..35436c00c 100644 --- a/apps/todo/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/todo/apps/web/src/routes/(auth)/login/+page.svelte @@ -59,6 +59,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts b/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts index a8727a53a..3765044a5 100644 --- a/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts @@ -145,6 +145,12 @@ export const authStore = { return result; }, + async sendMagicLink(email: string) { + const authService = getAuthService(); + if (!authService) return { success: false, error: 'Auth not available on server' }; + return authService.sendMagicLink(email); + }, + isPasskeyAvailable(): boolean { const authService = getAuthService(); if (!authService) return false; diff --git a/apps/zitare/apps/web/src/routes/(auth)/login/+page.svelte b/apps/zitare/apps/web/src/routes/(auth)/login/+page.svelte index 5cbf49669..39c605b92 100644 --- a/apps/zitare/apps/web/src/routes/(auth)/login/+page.svelte +++ b/apps/zitare/apps/web/src/routes/(auth)/login/+page.svelte @@ -57,6 +57,7 @@ onSignInWithPasskey={() => authStore.signInWithPasskey()} onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)} onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)} + onSendMagicLink={(email) => authStore.sendMagicLink(email)} {goto} successRedirect={redirectTo} registerPath="/register" diff --git a/packages/shared-auth-ui/package.json b/packages/shared-auth-ui/package.json index 34772fe76..64a889131 100644 --- a/packages/shared-auth-ui/package.json +++ b/packages/shared-auth-ui/package.json @@ -32,6 +32,9 @@ "typescript": "^5.7.3" }, "dependencies": { + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", + "@zxcvbn-ts/language-de": "^3.0.2", "qrcode": "^1.5.4" } } diff --git a/packages/shared-auth-ui/src/components/ChangePassword.svelte b/packages/shared-auth-ui/src/components/ChangePassword.svelte index b60f4f45f..e216fd8d5 100644 --- a/packages/shared-auth-ui/src/components/ChangePassword.svelte +++ b/packages/shared-auth-ui/src/components/ChangePassword.svelte @@ -1,5 +1,6 @@ + +{#if password} +
+
+ {#each [0, 1, 2, 3] as i} +
+ {/each} +
+ {labels[score]} + {#if feedback} +

{feedback}

+ {/if} +
+{/if} + + diff --git a/packages/shared-auth-ui/src/index.ts b/packages/shared-auth-ui/src/index.ts index ae5115c51..dfa364a45 100644 --- a/packages/shared-auth-ui/src/index.ts +++ b/packages/shared-auth-ui/src/index.ts @@ -12,6 +12,7 @@ export { default as PasskeyManager } from './components/PasskeyManager.svelte'; export { default as TwoFactorSetup } from './components/TwoFactorSetup.svelte'; export { default as SecurityOnboarding } from './components/SecurityOnboarding.svelte'; export { default as ChangePassword } from './components/ChangePassword.svelte'; +export { default as PasswordStrength } from './components/PasswordStrength.svelte'; export { default as AuditLog } from './components/AuditLog.svelte'; // Utilities diff --git a/packages/shared-auth-ui/src/pages/LoginPage.svelte b/packages/shared-auth-ui/src/pages/LoginPage.svelte index ee057c564..a5cc9caa5 100644 --- a/packages/shared-auth-ui/src/pages/LoginPage.svelte +++ b/packages/shared-auth-ui/src/pages/LoginPage.svelte @@ -88,6 +88,7 @@ passkeyAvailable?: boolean; onVerifyTwoFactor?: (code: string, trustDevice?: boolean) => Promise; onVerifyBackupCode?: (code: string) => Promise; + onSendMagicLink?: (email: string) => Promise; } let { @@ -114,6 +115,7 @@ passkeyAvailable = false, onVerifyTwoFactor, onVerifyBackupCode, + onSendMagicLink, }: Props = $props(); const t = $derived({ ...defaultTranslations, ...translations }); @@ -146,6 +148,8 @@ let useBackupCode = $state(false); let trustDevice = $state(false); let rateLimitCountdown = $state(0); + let magicLinkSent = $state(false); + let sendingMagicLink = $state(false); $effect(() => { if (rateLimitCountdown > 0) { @@ -336,6 +340,26 @@ } } + async function handleSendMagicLink() { + if (!onSendMagicLink || !email) return; + if (!isValidEmail(email)) { + setError(t.emailInvalid, 'email'); + return; + } + sendingMagicLink = true; + clearError(); + magicLinkSent = false; + + const result = await onSendMagicLink(email); + sendingMagicLink = false; + + if (result.success) { + magicLinkSent = true; + } else { + setError(result.error || t.signInFailed, 'general'); + } + } + function skipToForm() { if (emailInput) emailInput.focus(); } @@ -702,6 +726,33 @@ + {#if onSendMagicLink} + {#if magicLinkSent} +
+ +

Login-Link an {email} gesendet!

+ +
+ {:else} + + {/if} + {/if} +