mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
86d1da3587
commit
cc50c0c2ab
49 changed files with 430 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@ export const authStore = {
|
|||
return result;
|
||||
},
|
||||
|
||||
async sendMagicLink(email: string) {
|
||||
return authService.sendMagicLink(email);
|
||||
},
|
||||
|
||||
isPasskeyAvailable(): boolean {
|
||||
return authService.isPasskeyAvailable();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { Eye, EyeSlash } from '@manacore/shared-icons';
|
||||
import PasswordStrength from './PasswordStrength.svelte';
|
||||
|
||||
interface Props {
|
||||
onChangePassword: (
|
||||
|
|
@ -138,6 +139,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<PasswordStrength password={newPassword} {primaryColor} />
|
||||
|
||||
<div class="input-group">
|
||||
<label for="confirm-password" class="input-label">Neues Passwort bestätigen</label>
|
||||
<div class="input-wrapper">
|
||||
|
|
|
|||
102
packages/shared-auth-ui/src/components/PasswordStrength.svelte
Normal file
102
packages/shared-auth-ui/src/components/PasswordStrength.svelte
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script lang="ts">
|
||||
let initialized = false;
|
||||
let zxcvbnFn: any = null;
|
||||
|
||||
interface Props {
|
||||
password: string;
|
||||
primaryColor?: string;
|
||||
}
|
||||
|
||||
let { password, primaryColor = '#6366f1' }: Props = $props();
|
||||
|
||||
let score = $state(0);
|
||||
let feedback = $state('');
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
async function initZxcvbn() {
|
||||
if (initialized) return;
|
||||
const [{ zxcvbn, zxcvbnOptions }, common, de] = await Promise.all([
|
||||
import('@zxcvbn-ts/core'),
|
||||
import('@zxcvbn-ts/language-common'),
|
||||
import('@zxcvbn-ts/language-de'),
|
||||
]);
|
||||
zxcvbnOptions.setOptions({
|
||||
translations: de.translations,
|
||||
graphs: common.adjacencyGraphs,
|
||||
dictionary: { ...common.dictionary, ...de.dictionary },
|
||||
});
|
||||
zxcvbnFn = zxcvbn;
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
const labels = ['Sehr schwach', 'Schwach', 'OK', 'Stark', 'Sehr stark'];
|
||||
const colors = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e'];
|
||||
|
||||
$effect(() => {
|
||||
if (!password) {
|
||||
score = 0;
|
||||
feedback = '';
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
await initZxcvbn();
|
||||
if (zxcvbnFn && password) {
|
||||
const result = zxcvbnFn(password);
|
||||
score = result.score;
|
||||
const suggestions = result.feedback.suggestions || [];
|
||||
const warning = result.feedback.warning || '';
|
||||
feedback = warning || suggestions[0] || '';
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if password}
|
||||
<div class="strength-container">
|
||||
<div class="strength-bar">
|
||||
{#each [0, 1, 2, 3] as i}
|
||||
<div
|
||||
class="strength-segment"
|
||||
style:background-color={i <= score - 1 ? colors[score] : 'rgba(128,128,128,0.2)'}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="strength-label" style:color={colors[score]}>{labels[score]}</span>
|
||||
{#if feedback}
|
||||
<p class="strength-feedback">{feedback}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.strength-container {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
height: 4px;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.strength-segment {
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.strength-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.strength-feedback {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(156, 163, 175, 0.8);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@
|
|||
passkeyAvailable?: boolean;
|
||||
onVerifyTwoFactor?: (code: string, trustDevice?: boolean) => Promise<AuthResult>;
|
||||
onVerifyBackupCode?: (code: string) => Promise<AuthResult>;
|
||||
onSendMagicLink?: (email: string) => Promise<AuthResult>;
|
||||
}
|
||||
|
||||
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 @@
|
|||
</button>
|
||||
</form>
|
||||
|
||||
{#if onSendMagicLink}
|
||||
{#if magicLinkSent}
|
||||
<div class="verified-banner" role="status" aria-live="polite">
|
||||
<Check size={18} class="text-green-500 shrink-0" />
|
||||
<p>Login-Link an {email} gesendet!</p>
|
||||
<button
|
||||
type="button"
|
||||
class="verified-banner-close"
|
||||
onclick={() => (magicLinkSent = false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSendMagicLink}
|
||||
disabled={sendingMagicLink || !email}
|
||||
class="magic-link-button"
|
||||
style:color={primaryColor}
|
||||
>
|
||||
{sendingMagicLink ? 'Wird gesendet...' : 'Login-Link per E-Mail senden'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<p class="register-link">
|
||||
{t.noAccount}
|
||||
<button type="button" onclick={() => goto(registerPath)} style:color={primaryColor}>
|
||||
|
|
@ -1203,6 +1254,27 @@
|
|||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.magic-link-button {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.magic-link-button:hover:not(:disabled) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.magic-link-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import type { Component } from 'svelte';
|
||||
import type { AuthResult } from '../types';
|
||||
import { Eye, EyeSlash, UserPlus, ArrowLeft, Sun, Moon } from '@manacore/shared-icons';
|
||||
import PasswordStrength from '../components/PasswordStrength.svelte';
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
|
|
@ -505,6 +506,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<PasswordStrength {password} {primaryColor} />
|
||||
|
||||
<div>
|
||||
<div class="relative">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -778,6 +778,33 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send magic link for passwordless login
|
||||
*/
|
||||
async sendMagicLink(email: string): Promise<AuthResult> {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/auth/magic-link/send-magic-link`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
return { success: false, error: err.message || 'Failed to send magic link' };
|
||||
}
|
||||
|
||||
trackAuth('magic_link_sent');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to send magic link',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get security events (audit log)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue