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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 11:23:09 +01:00
parent 86d1da3587
commit cc50c0c2ab
49 changed files with 430 additions and 1 deletions

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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
*/

View file

@ -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"

View file

@ -111,6 +111,10 @@ export const authStore = {
return result;
},
async sendMagicLink(email: string) {
return authService.sendMagicLink(email);
},
isPasskeyAvailable(): boolean {
return authService.isPasskeyAvailable();
},

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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;

View file

@ -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"

View file

@ -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;

View file

@ -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"