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 1e0aba3fc..170418e5a 100644 --- a/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/auth.svelte.ts @@ -192,7 +192,32 @@ export const authStore = { } try { - const result = await authService.forgotPassword(email); + // Pass current app origin so user is redirected back here after clicking email link + const redirectTo = browser ? window.location.origin : undefined; + const result = await authService.forgotPassword(email, redirectTo); + + if (!result.success) { + return { success: false, error: result.error || 'Password reset failed' }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + /** + * Reset password with token (from email link) + */ + async resetPasswordWithToken(token: string, newPassword: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.resetPassword(token, newPassword); if (!result.success) { return { success: false, error: result.error || 'Password reset failed' }; diff --git a/apps/calendar/apps/web/src/routes/(auth)/reset-password/+page.svelte b/apps/calendar/apps/web/src/routes/(auth)/reset-password/+page.svelte new file mode 100644 index 000000000..fe6b028e2 --- /dev/null +++ b/apps/calendar/apps/web/src/routes/(auth)/reset-password/+page.svelte @@ -0,0 +1,231 @@ + + + + {t.title} | Kalender + + +
+ +
+ + + Kalender + + +
+ + +
+
+
+

{t.title}

+

+ {#if success} + {t.success} + {:else if hasToken} + {t.subtitle} + {:else} + {t.invalidToken} + {/if} +

+
+ + {#if success} + +
+
+
+

+ {t.successMessage} +

+ + {t.goToLogin} + +
+
+ {:else if hasToken} + +
+
+ {#if error} +
+ {error} +
+ {/if} + +
+
+ + +

+ {t.minChars} +

+
+ +
+ + +
+ + +
+
+
+ {:else} + +
+
+
⚠️
+

+ {t.invalidTokenMessage} +

+ + {t.requestNew} + +
+
+ {/if} +
+
+
diff --git a/packages/shared-auth/src/core/authService.ts b/packages/shared-auth/src/core/authService.ts index dde72da55..a65116dfb 100644 --- a/packages/shared-auth/src/core/authService.ts +++ b/packages/shared-auth/src/core/authService.ts @@ -181,13 +181,15 @@ export function createAuthService(config: AuthServiceConfig) { /** * Send password reset email + * @param email - User's email address + * @param redirectTo - Optional URL to redirect after password reset (current app origin) */ - async forgotPassword(email: string): Promise { + async forgotPassword(email: string, redirectTo?: string): Promise { try { const response = await fetch(`${baseUrl}${endpoints.forgotPassword}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), + body: JSON.stringify({ email, redirectTo }), }); if (!response.ok) { diff --git a/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts b/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts index f4e5b9b61..61c390a52 100644 --- a/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts +++ b/services/mana-core-auth/src/auth/better-auth-passthrough.controller.ts @@ -6,12 +6,13 @@ * * Routes handled: * - GET /api/auth/verify-email - Email verification from verification emails + * - GET /api/auth/reset-password/:token - Password reset from reset emails * * This is necessary because Better Auth generates URLs with `/api/auth/*` * but our NestJS API uses `/api/v1/*` as the global prefix. */ -import { Controller, Get, Query, Res, HttpStatus } from '@nestjs/common'; +import { Controller, Get, Param, Query, Res, HttpStatus } from '@nestjs/common'; import { Response } from 'express'; import { BetterAuthService } from './services/better-auth.service'; @@ -116,4 +117,50 @@ export class BetterAuthPassthroughController { return res.redirect(`${fallbackUrl}/verification-failed?error=verification_failed`); } } + + /** + * Handle password reset link from email + * + * Better Auth sends password reset emails with links to: + * {baseURL}/api/auth/reset-password/{token}?callbackURL=... + * + * This endpoint: + * 1. Extracts the reset token from the URL + * 2. Redirects the user to the frontend /reset-password page with the token + * 3. The frontend then shows a form to enter the new password + * 4. Frontend submits to POST /api/v1/auth/reset-password with token + newPassword + */ + @Get('reset-password/:token') + async resetPassword( + @Param('token') token: string, + @Query('callbackURL') callbackURL: string | undefined, + @Res() res: Response + ) { + const fallbackUrl = process.env.FRONTEND_URL || this.defaultFrontendUrl; + + try { + if (!token) { + return res.redirect(`${fallbackUrl}/login?error=missing_reset_token`); + } + + // Determine redirect URL: + // 1. First try the callbackURL query param (from the email link) + // 2. Fall back to default frontend URL + let baseUrl = this.validateRedirectUrl(callbackURL); + + if (!baseUrl) { + baseUrl = fallbackUrl; + } + + // Redirect to frontend's reset-password page with token + const resetUrl = new URL('/reset-password', baseUrl); + resetUrl.searchParams.set('token', token); + + console.log(`[reset-password] Redirecting to: ${resetUrl.toString()}`); + return res.redirect(resetUrl.toString()); + } catch (error) { + console.error('[reset-password] Error:', error); + return res.redirect(`${fallbackUrl}/login?error=reset_failed`); + } + } } diff --git a/services/mana-core-auth/src/auth/better-auth.config.ts b/services/mana-core-auth/src/auth/better-auth.config.ts index ff0d354c0..d79b44bb8 100644 --- a/services/mana-core-auth/src/auth/better-auth.config.ts +++ b/services/mana-core-auth/src/auth/better-auth.config.ts @@ -28,6 +28,7 @@ import { sendVerificationEmail, } from '../email/email.service'; import { sourceAppStore } from './stores/source-app.store'; +import { passwordResetRedirectStore } from './stores/password-reset-redirect.store'; /** * JWT Custom Payload Interface @@ -100,6 +101,9 @@ export function createBetterAuth(databaseUrl: string) { * - auth.api.requestPasswordReset({ body: { email } }) - Sends reset email * - auth.api.resetPassword({ body: { newPassword, token } }) - Resets password * + * The reset URL is modified to include callbackURL parameter + * so users are redirected back to the app they requested reset from. + * * @see https://www.better-auth.com/docs/authentication/email-password#password-reset */ sendResetPassword: async ({ @@ -109,7 +113,18 @@ export function createBetterAuth(databaseUrl: string) { user: { email: string; name: string }; url: string; }) => { - await sendPasswordResetEmail(user.email, url, user.name); + // Check if we have a redirect URL stored for this user's password reset request + const redirectUrl = passwordResetRedirectStore.get(user.email); + + // Modify reset URL to include callbackURL parameter + let resetUrl = url; + if (redirectUrl) { + const urlObj = new URL(url); + urlObj.searchParams.set('callbackURL', redirectUrl); + resetUrl = urlObj.toString(); + } + + await sendPasswordResetEmail(user.email, resetUrl, user.name); }, }, diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index b164d8eca..646ae3b57 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -33,6 +33,7 @@ import { ReferralTierService } from '../../referrals/services/referral-tier.serv import { ReferralTrackingService } from '../../referrals/services/referral-tracking.service'; import { hasUser, hasToken, hasMember, hasMembers, hasSession } from '../types/better-auth.types'; import { sourceAppStore } from '../stores/source-app.store'; +import { passwordResetRedirectStore } from '../stores/password-reset-redirect.store'; import type { RegisterB2CDto, RegisterB2BDto, @@ -876,6 +877,11 @@ export class BetterAuthService { redirectTo?: string ): Promise<{ success: boolean; message: string }> { try { + // Store the redirect URL so sendResetPassword callback can include it in the email link + if (redirectTo) { + passwordResetRedirectStore.set(email, redirectTo); + } + // Better Auth's requestPasswordReset endpoint // See: https://www.better-auth.com/docs/authentication/email-password#password-reset await (this.auth.api as any).requestPasswordReset({ diff --git a/services/mana-core-auth/src/auth/stores/password-reset-redirect.store.ts b/services/mana-core-auth/src/auth/stores/password-reset-redirect.store.ts new file mode 100644 index 000000000..e51a007ec --- /dev/null +++ b/services/mana-core-auth/src/auth/stores/password-reset-redirect.store.ts @@ -0,0 +1,96 @@ +/** + * Password Reset Redirect Store + * + * Temporary in-memory store for tracking which app a user requested + * password reset from. This allows redirecting users back to the correct + * app's reset-password page after clicking the email link. + * + * TTL: 1 hour (password reset tokens are short-lived) + */ + +interface ResetRedirectEntry { + redirectUrl: string; + expiresAt: number; +} + +// In-memory store: email -> { redirectUrl, expiresAt } +const store = new Map(); + +// TTL in milliseconds (1 hour) +const TTL_MS = 60 * 60 * 1000; + +// Cleanup interval (every 15 minutes) +const CLEANUP_INTERVAL_MS = 15 * 60 * 1000; + +// Start cleanup interval +setInterval(() => { + const now = Date.now(); + for (const [email, entry] of store.entries()) { + if (entry.expiresAt < now) { + store.delete(email); + } + } +}, CLEANUP_INTERVAL_MS); + +export const passwordResetRedirectStore = { + /** + * Store the redirect URL for a password reset request + */ + set(email: string, redirectUrl: string): void { + const normalizedEmail = email.toLowerCase().trim(); + store.set(normalizedEmail, { + redirectUrl, + expiresAt: Date.now() + TTL_MS, + }); + }, + + /** + * Get the redirect URL for an email + * Returns null if not found or expired + */ + get(email: string): string | null { + const normalizedEmail = email.toLowerCase().trim(); + const entry = store.get(normalizedEmail); + + if (!entry) { + return null; + } + + // Check if expired + if (entry.expiresAt < Date.now()) { + store.delete(normalizedEmail); + return null; + } + + return entry.redirectUrl; + }, + + /** + * Get and remove the redirect URL for an email + * This is used after the user clicks the link to prevent re-use + */ + getAndDelete(email: string): string | null { + const normalizedEmail = email.toLowerCase().trim(); + const entry = store.get(normalizedEmail); + + if (!entry) { + return null; + } + + store.delete(normalizedEmail); + + // Check if expired + if (entry.expiresAt < Date.now()) { + return null; + } + + return entry.redirectUrl; + }, + + /** + * Clear all entries (for testing) + */ + clear(): void { + store.clear(); + }, +};