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

@ -103,6 +103,27 @@ export class BetterAuthPassthroughController {
}
}
/**
* Magic Link passthrough
*
* Forwards all /api/auth/magic-link/* requests to Better Auth's handler.
* The magicLink plugin registers these routes:
* - POST /magic-link/send-magic-link
* - GET /magic-link/verify (callback from email)
*/
@All('magic-link/*')
async handleMagicLink(@Req() req: Request, @Res() res: Response) {
try {
return await this.forwardToBetterAuth(req, res);
} catch (error) {
this.logger.error(
'Magic link passthrough failed',
error instanceof Error ? error.stack : undefined
);
return res.status(500).json({ error: 'Magic link request failed' });
}
}
/**
* Handle SSO get-session request
*

View file

@ -20,6 +20,7 @@ import { jwt } from 'better-auth/plugins/jwt';
import { organization } from 'better-auth/plugins/organization';
import { oidcProvider } from 'better-auth/plugins/oidc-provider';
import { twoFactor } from 'better-auth/plugins/two-factor';
import { magicLink } from 'better-auth/plugins/magic-link';
import { getDb } from '../db/connection';
import { organizations, members, invitations } from '../db/schema/organizations.schema';
import {
@ -39,6 +40,7 @@ import {
sendPasswordResetEmail,
sendInvitationEmail,
sendVerificationEmail,
sendMagicLinkEmail,
} from '../email/email.service';
import { sourceAppStore } from './stores/source-app.store';
import { passwordResetRedirectStore } from './stores/password-reset-redirect.store';
@ -248,6 +250,7 @@ export function createBetterAuth(databaseUrl: string) {
'https://context.mana.how',
'https://docs.mana.how',
'https://element.mana.how',
'https://inventar.mana.how',
'https://link.mana.how',
'https://manadeck.mana.how',
'https://matrix.mana.how',
@ -269,6 +272,7 @@ export function createBetterAuth(databaseUrl: string) {
'http://localhost:3001',
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:5190',
],
// Plugins
@ -423,6 +427,20 @@ export function createBetterAuth(databaseUrl: string) {
twoFactor({
issuer: 'ManaCore',
}),
/**
* Magic Link Plugin (Passwordless Email Login)
*
* Sends a one-time login link via email.
* Endpoints via Better Auth passthrough:
* - POST /magic-link/send-magic-link
* - GET /magic-link/verify (callback from email)
*/
magicLink({
sendMagicLink: async ({ email, url }: { email: string; url: string }) => {
await sendMagicLinkEmail(email, url);
},
expiresIn: 600, // 10 minutes
}),
],
});
}

View file

@ -274,6 +274,49 @@ export async function sendAccountDeletionEmail(email: string, userName?: string)
});
}
/**
* Send magic link email for passwordless login
*/
export async function sendMagicLinkEmail(email: string, magicLinkUrl: string): Promise<boolean> {
return sendEmail({
to: email,
subject: 'Dein Login-Link für ManaCore',
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #2563eb; margin: 0;">ManaCore</h1>
</div>
<p>Hallo,</p>
<p>Klicke auf den Button unten, um dich bei ManaCore anzumelden:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${magicLinkUrl}" style="background-color: #2563eb; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">Jetzt anmelden</a>
</div>
<p style="color: #666; font-size: 14px;">Dieser Link ist 10 Minuten gültig. Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese E-Mail wurde automatisch von ManaCore gesendet.<br>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<a href="${magicLinkUrl}" style="color: #2563eb; word-break: break-all;">${magicLinkUrl}</a>
</p>
</body>
</html>
`,
text: `Klicke auf den folgenden Link, um dich anzumelden: ${magicLinkUrl}`,
});
}
/**
* Send welcome/verification email
*/