From 4e94ecd721397ef6004df4077bfaaa22438a6b92 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 04:17:04 -0800 Subject: [PATCH] feat(auth): add forgot-password/change-password API routes to admin dashboard + wire email delivery Admin dashboard: - platform-client.ts: Added forgotPasswordViaService, changePasswordViaService, deleteAccountViaService - app/api/auth/forgot-password/route.ts: New API route proxying to platform-service - app/api/auth/change-password/route.ts: New API route for authenticated password change Platform-service (from prior session): - auth/routes.ts: Added /auth/change-password and DELETE /auth/account endpoints, wired email verification on register - auth/types.ts: Added ChangePasswordSchema and DeleteAccountSchema - delivery/subscribers.ts: Updated password reset subscriber, added email verification subscriber - delivery/channels/email.ts: Added ByteLyst email API provider (sendViaBytelyst) - delivery/types.ts: Added 'bytelyst' provider - events/types.ts: Added resetToken/displayName to user.password_reset, added user.email_verification_requested event --- .../src/app/api/auth/change-password/route.ts | 25 +++ .../src/app/api/auth/forgot-password/route.ts | 20 +++ .../admin-web/src/lib/platform-client.ts | 103 +++++++------ packages/events/src/types.ts | 10 ++ .../src/modules/auth/routes.ts | 145 +++++++++++++++--- .../src/modules/auth/types.ts | 13 ++ .../src/modules/delivery/channels/email.ts | 43 ++++++ .../src/modules/delivery/subscribers.ts | 47 +++++- .../src/modules/delivery/types.ts | 2 +- 9 files changed, 334 insertions(+), 74 deletions(-) create mode 100644 dashboards/admin-web/src/app/api/auth/change-password/route.ts create mode 100644 dashboards/admin-web/src/app/api/auth/forgot-password/route.ts diff --git a/dashboards/admin-web/src/app/api/auth/change-password/route.ts b/dashboards/admin-web/src/app/api/auth/change-password/route.ts new file mode 100644 index 00000000..d005de7d --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/change-password/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { changePasswordViaService } from '@/lib/platform-client'; +import { logError } from '@/lib/logger'; + +export async function POST(req: NextRequest) { + try { + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + const token = authHeader.replace('Bearer ', ''); + + const { currentPassword, newPassword } = await req.json(); + if (!currentPassword || !newPassword) { + return NextResponse.json({ error: 'Current and new password required' }, { status: 400 }); + } + + const result = await changePasswordViaService(token, currentPassword, newPassword); + return NextResponse.json(result); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logError('Change password error', error); + return NextResponse.json({ error: msg }, { status: 400 }); + } +} diff --git a/dashboards/admin-web/src/app/api/auth/forgot-password/route.ts b/dashboards/admin-web/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 00000000..f6a40aa6 --- /dev/null +++ b/dashboards/admin-web/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { forgotPasswordViaService } from '@/lib/platform-client'; +import { PRODUCT_ID } from '@/lib/product-config'; +import { logError } from '@/lib/logger'; + +export async function POST(req: NextRequest) { + try { + const { email } = await req.json(); + if (!email) { + return NextResponse.json({ error: 'Email required' }, { status: 400 }); + } + + const result = await forgotPasswordViaService(email, PRODUCT_ID); + return NextResponse.json(result); + } catch (error) { + logError('Forgot password error', error); + // Always return success to prevent email enumeration + return NextResponse.json({ message: 'If that email exists, a reset link has been sent.' }); + } +} diff --git a/dashboards/admin-web/src/lib/platform-client.ts b/dashboards/admin-web/src/lib/platform-client.ts index 87d5da42..1d2ea353 100644 --- a/dashboards/admin-web/src/lib/platform-client.ts +++ b/dashboards/admin-web/src/lib/platform-client.ts @@ -67,6 +67,33 @@ export async function loginViaService(email: string, password: string, productId }); } +export async function forgotPasswordViaService(email: string, productId: string) { + return platformApi.fetch<{ message: string }>('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email, productId }), + }); +} + +export async function changePasswordViaService( + token: string, + currentPassword: string, + newPassword: string +) { + return platformApi.fetch<{ message: string }>('/auth/change-password', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ currentPassword, newPassword }), + }); +} + +export async function deleteAccountViaService(token: string, password: string) { + return platformApi.fetch<{ message: string }>('/auth/account', { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ password }), + }); +} + export async function getMeViaService(token: string) { return platformApi.fetch<{ id: string; @@ -109,19 +136,17 @@ export async function listUsers( limit = 100, offset = 0 ): Promise<{ users: UserDoc[] }> { - return platformApi.fetch<{ users: UserDoc[] }>( - `/auth/users?limit=${limit}&offset=${offset}`, - { headers: { Authorization: `Bearer ${token}` } } - ); + return platformApi.fetch<{ users: UserDoc[] }>(`/auth/users?limit=${limit}&offset=${offset}`, { + headers: { Authorization: `Bearer ${token}` }, + }); } export async function getUserCounts( token: string ): Promise<{ total: number; byPlan: Record }> { - return platformApi.fetch<{ total: number; byPlan: Record }>( - '/auth/users/count', - { headers: { Authorization: `Bearer ${token}` } } - ); + return platformApi.fetch<{ total: number; byPlan: Record }>('/auth/users/count', { + headers: { Authorization: `Bearer ${token}` }, + }); } export async function getUser(token: string, id: string): Promise { @@ -223,17 +248,16 @@ export interface PlanConfig { } export async function listPlans(productId: string): Promise<{ plans: PlanConfig[] }> { - return platformApi.fetch<{ plans: PlanConfig[] }>( - `/plans`, - { headers: { 'x-product-id': productId } } - ); + return platformApi.fetch<{ plans: PlanConfig[] }>(`/plans`, { + headers: { 'x-product-id': productId }, + }); } export async function seedPlans(productId: string): Promise<{ plans: PlanConfig[] }> { - return platformApi.fetch<{ plans: PlanConfig[] }>( - '/plans/seed', - { method: 'POST', headers: { 'x-product-id': productId } } - ); + return platformApi.fetch<{ plans: PlanConfig[] }>('/plans/seed', { + method: 'POST', + headers: { 'x-product-id': productId }, + }); } // ── Product Onboarding ──────────────────────────────────────── @@ -298,10 +322,7 @@ export async function createFlag(input: Record): Promise -): Promise { +export async function updateFlag(key: string, updates: Record): Promise { return platformApi.fetch(`/flags/${encodeURIComponent(key)}`, { method: 'PUT', body: JSON.stringify(updates), @@ -354,10 +375,7 @@ export async function createToken( }); } -export async function revokeToken( - token: string, - tokenId: string -): Promise<{ success: boolean }> { +export async function revokeToken(token: string, tokenId: string): Promise<{ success: boolean }> { return platformApi.fetch<{ success: boolean }>(`/tokens/${tokenId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}` }, @@ -365,19 +383,14 @@ export async function revokeToken( }); } -export async function deleteToken( - token: string, - tokenId: string -): Promise<{ success: boolean }> { +export async function deleteToken(token: string, tokenId: string): Promise<{ success: boolean }> { return platformApi.fetch<{ success: boolean }>(`/tokens/${tokenId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); } -export async function countActiveTokens( - token: string -): Promise<{ count: number }> { +export async function countActiveTokens(token: string): Promise<{ count: number }> { return platformApi.fetch<{ count: number }>('/tokens/count', { headers: { Authorization: `Bearer ${token}` }, }); @@ -436,20 +449,14 @@ export async function updateTheme( }); } -export async function deleteTheme( - token: string, - id: string -): Promise<{ success: boolean }> { +export async function deleteTheme(token: string, id: string): Promise<{ success: boolean }> { return platformApi.fetch<{ success: boolean }>(`/themes/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); } -export async function activateTheme( - token: string, - id: string -): Promise { +export async function activateTheme(token: string, id: string): Promise { return platformApi.fetch(`/themes/${id}/activate`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, @@ -612,10 +619,9 @@ export interface TelemetryPolicy { export async function listTelemetryPolicies( token: string ): Promise<{ policies: TelemetryPolicy[] }> { - return platformApi.fetch<{ policies: TelemetryPolicy[] }>( - '/telemetry/policies', - { headers: { Authorization: `Bearer ${token}` } } - ); + return platformApi.fetch<{ policies: TelemetryPolicy[] }>('/telemetry/policies', { + headers: { Authorization: `Bearer ${token}` }, + }); } export async function createTelemetryPolicy( @@ -685,9 +691,7 @@ export async function updateClusterStatus( ); } -export async function getTelemetryMetrics( - token: string -): Promise { +export async function getTelemetryMetrics(token: string): Promise { return platformApi.fetch('/telemetry/metrics', { headers: { Authorization: `Bearer ${token}` }, }); @@ -707,10 +711,9 @@ export async function getTelemetryGeoDistribution( if (from) params.set('from', from); if (to) params.set('to', to); const qs = params.toString() ? `?${params.toString()}` : ''; - return platformApi.fetch<{ distribution: GeoDistributionEntry[] }>( - `/telemetry/geo${qs}`, - { headers: { Authorization: `Bearer ${token}` } } - ); + return platformApi.fetch<{ distribution: GeoDistributionEntry[] }>(`/telemetry/geo${qs}`, { + headers: { Authorization: `Bearer ${token}` }, + }); } export async function eraseTelemetryUser( diff --git a/packages/events/src/types.ts b/packages/events/src/types.ts index 365fe328..65233cf2 100644 --- a/packages/events/src/types.ts +++ b/packages/events/src/types.ts @@ -14,6 +14,7 @@ export const PlatformEventSchemas = { }), 'user.deleted': z.object({ userId: z.string(), + email: z.string(), productId: z.string(), }), 'user.email_verified': z.object({ @@ -24,6 +25,15 @@ export const PlatformEventSchemas = { 'user.password_reset': z.object({ userId: z.string(), email: z.string(), + resetToken: z.string(), + displayName: z.string().optional(), + productId: z.string(), + }), + 'user.email_verification_requested': z.object({ + userId: z.string(), + email: z.string(), + verificationToken: z.string(), + displayName: z.string().optional(), productId: z.string(), }), diff --git a/services/platform-service/src/modules/auth/routes.ts b/services/platform-service/src/modules/auth/routes.ts index bb8a341c..826c5367 100644 --- a/services/platform-service/src/modules/auth/routes.ts +++ b/services/platform-service/src/modules/auth/routes.ts @@ -1,13 +1,23 @@ /** * Auth REST endpoints. * - * POST /auth/login — login with email + password - * POST /auth/register — register new user - * POST /auth/refresh — refresh access token - * GET /auth/me — get current user from token - * PUT /auth/profile — update own profile (displayName, phone, bio, avatarUrl) - * POST /auth/sso — SSO login (verified external identity) - * POST /auth/verify — service-to-service token verification + * POST /auth/login — login with email + password + * POST /auth/register — register new user + * POST /auth/refresh — refresh access token + * GET /auth/me — get current user from token + * PUT /auth/profile — update own profile (displayName, phone, bio, avatarUrl) + * POST /auth/change-password — change password (authenticated) + * DELETE /auth/account — delete own account (authenticated) + * POST /auth/sso — SSO login (verified external identity) + * POST /auth/verify — service-to-service token verification + * + * Password reset (unauthenticated): + * POST /auth/forgot-password — request reset token + * POST /auth/reset-password — reset password with token + * + * Email verification: + * POST /auth/verify-email — verify email with token + * POST /auth/resend-verification — resend verification email * * Admin user management (requires super_admin or admin role): * GET /auth/users — list users (paginated) @@ -32,13 +42,14 @@ import { UpdateUserSchema, SsoLoginSchema, ProfileUpdateSchema, + ChangePasswordSchema, + DeleteAccountSchema, ForgotPasswordSchema, ResetPasswordSchema, VerifyEmailSchema, ResendVerificationSchema, type UserDoc, } from './types.js'; - export async function authRoutes(app: FastifyInstance) { // Login app.post('/auth/login', async req => { @@ -187,6 +198,40 @@ export async function authRoutes(app: FastifyInstance) { ) .catch(() => {}); + // Create email verification token and emit event (best-effort) + try { + const verifyRawToken = crypto.randomUUID(); + const verifyTokenHash = repo.hashToken(verifyRawToken); + const verifyExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + await repo.createEmailVerification({ + id: `evf_${crypto.randomUUID()}`, + productId, + userId: user.id, + email: user.email, + tokenHash: verifyTokenHash, + expiresAt: verifyExpiresAt, + createdAt: nowIso, + }); + bus + .emit( + 'user.email_verification_requested', + { + userId: user.id, + email: user.email, + verificationToken: verifyRawToken, + displayName, + productId, + }, + { source: 'auth/register' } + ) + .catch(() => {}); + } catch (err) { + req.log.warn( + { err, userId: user.id }, + 'Email verification provisioning failed during register' + ); + } + reply.code(201); return { accessToken, @@ -271,6 +316,59 @@ export async function authRoutes(app: FastifyInstance) { return safe; }); + // Change password (authenticated) + app.post('/auth/change-password', async req => { + const payload = req.jwtPayload; + if (!payload?.sub) throw new UnauthorizedError('Authentication required'); + + const parsed = ChangePasswordSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const user = await repo.getById(payload.sub); + if (!user) throw new UnauthorizedError('User not found'); + + const valid = await repo.verifyPassword(parsed.data.currentPassword, user.passwordHash); + if (!valid) throw new BadRequestError('Current password is incorrect'); + + const newHash = await repo.hashPassword(parsed.data.newPassword); + await repo.updatePassword(user.id, newHash); + + req.log.info({ userId: user.id }, '[auth] Password changed'); + return { message: 'Password changed successfully.' }; + }); + + // Delete own account (authenticated) + app.delete('/auth/account', async req => { + const payload = req.jwtPayload; + if (!payload?.sub) throw new UnauthorizedError('Authentication required'); + + const parsed = DeleteAccountSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const user = await repo.getById(payload.sub); + if (!user) throw new UnauthorizedError('User not found'); + + const valid = await repo.verifyPassword(parsed.data.password, user.passwordHash); + if (!valid) throw new BadRequestError('Incorrect password'); + + await repo.remove(user.id); + + bus + .emit( + 'user.deleted', + { userId: user.id, email: user.email, productId: user.productId }, + { source: 'auth/delete-account' } + ) + .catch(() => {}); + + req.log.info({ userId: user.id }, '[auth] Account deleted'); + return { message: 'Account deleted successfully.' }; + }); + // SSO login — verified external identity (called by dashboard OAuth callbacks) // The caller has already verified the user's identity via Microsoft/Google OAuth. // This endpoint finds or creates the user and issues platform JWT tokens. @@ -421,19 +519,17 @@ export async function authRoutes(app: FastifyInstance) { createdAt: new Date().toISOString(), }); - // TODO: Send email via delivery module. For now, log the token for dev/testing. - req.log.info( - { userId: user.id, productId, resetToken: rawToken }, - '[auth] Password reset token generated' - ); + req.log.info({ userId: user.id, productId }, '[auth] Password reset token generated'); - // Emit password_reset event (fire-and-forget) + // Emit password_reset event — subscriber sends the email bus .emit( 'user.password_reset', { userId: user.id, email: user.email, + resetToken: rawToken, + displayName: user.displayName, productId, }, { source: 'auth/forgot-password' } @@ -552,11 +648,22 @@ export async function authRoutes(app: FastifyInstance) { createdAt: new Date().toISOString(), }); - // TODO: Send email via delivery module. For now, log the token for dev/testing. - req.log.info( - { userId: user.id, productId, verificationToken: rawToken }, - '[auth] Email verification token generated' - ); + req.log.info({ userId: user.id, productId }, '[auth] Email verification token generated'); + + // Emit event — subscriber sends the email + bus + .emit( + 'user.email_verification_requested', + { + userId: user.id, + email: user.email, + verificationToken: rawToken, + displayName: user.displayName, + productId, + }, + { source: 'auth/resend-verification' } + ) + .catch(() => {}); return { message: 'If that email exists, a verification link has been sent.' }; }); diff --git a/services/platform-service/src/modules/auth/types.ts b/services/platform-service/src/modules/auth/types.ts index cc55e19b..a6bc3993 100644 --- a/services/platform-service/src/modules/auth/types.ts +++ b/services/platform-service/src/modules/auth/types.ts @@ -77,6 +77,19 @@ export type UpdateUserInput = z.infer; export type SsoLoginInput = z.infer; export type ProfileUpdateInput = z.infer; +// ── Password Change (authenticated) ───────────────────────── + +export const ChangePasswordSchema = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(8), +}); + +// ── Delete Account (authenticated) ────────────────────────── + +export const DeleteAccountSchema = z.object({ + password: z.string().min(1), +}); + // ── Password Reset ─────────────────────────────────────────── export const ForgotPasswordSchema = z.object({ diff --git a/services/platform-service/src/modules/delivery/channels/email.ts b/services/platform-service/src/modules/delivery/channels/email.ts index c37d34b9..01318921 100644 --- a/services/platform-service/src/modules/delivery/channels/email.ts +++ b/services/platform-service/src/modules/delivery/channels/email.ts @@ -31,6 +31,8 @@ export async function sendEmail( switch (config.provider) { case 'console': return sendViaConsole(message, log); + case 'bytelyst': + return sendViaBytelyst(message, log); case 'sendgrid': return sendViaSendGrid(message, config); case 'postmark': @@ -59,6 +61,47 @@ async function sendViaConsole( return { success: true, messageId }; } +// ── ByteLyst Provider (Railway FastAPI) ────────────────────── + +const BYTELYST_EMAIL_URL = + process.env.BYTELYST_EMAIL_URL || + 'https://bytelyst-notification-fastapi-production.up.railway.app/api/send-email'; + +async function sendViaBytelyst( + message: EmailMessage, + log: { info: (...a: unknown[]) => void; error: (...a: unknown[]) => void } +): Promise { + try { + const response = await fetch(BYTELYST_EMAIL_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: message.to, + subject: message.subject, + email_body: message.bodyHtml, + }), + }); + + if (response.ok) { + const messageId = `bl_${crypto.randomUUID()}`; + log.info( + { messageId, to: message.to, subject: message.subject }, + `[delivery/email] ByteLyst email sent to ${message.to}` + ); + return { success: true, messageId }; + } + + const errorText = await response.text(); + log.error( + { status: response.status, error: errorText }, + `[delivery/email] ByteLyst email failed for ${message.to}` + ); + return { success: false, error: `ByteLyst email error ${response.status}: ${errorText}` }; + } catch (err: unknown) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } +} + // ── SendGrid Provider ──────────────────────────────────────── async function sendViaSendGrid( diff --git a/services/platform-service/src/modules/delivery/subscribers.ts b/services/platform-service/src/modules/delivery/subscribers.ts index d79ac130..4a8c8ef2 100644 --- a/services/platform-service/src/modules/delivery/subscribers.ts +++ b/services/platform-service/src/modules/delivery/subscribers.ts @@ -42,14 +42,16 @@ export function registerDeliverySubscribers( // Password reset email bus.on('user.password_reset', async event => { try { + const baseUrl = resolveDashboardUrl(event.payload.productId); + const resetUrl = `${baseUrl}/reset-password?token=${event.payload.resetToken}`; await dispatchEmail( { to: event.payload.email, templateId: 'password-reset', variables: { - displayName: event.payload.email.split('@')[0], - productName: event.payload.productId, - resetUrl: `${resolveDashboardUrl(event.payload.productId)}/reset-password`, + displayName: event.payload.displayName || event.payload.email.split('@')[0], + productName: resolveProductName(event.payload.productId), + resetUrl, }, productId: event.payload.productId, userId: event.payload.userId, @@ -64,7 +66,34 @@ export function registerDeliverySubscribers( } }); - // Email verification + // Email verification request — send verification email + bus.on('user.email_verification_requested', async event => { + try { + const baseUrl = resolveDashboardUrl(event.payload.productId); + const verifyUrl = `${baseUrl}/verify-email?token=${event.payload.verificationToken}`; + await dispatchEmail( + { + to: event.payload.email, + templateId: 'email-verification', + variables: { + displayName: event.payload.displayName || event.payload.email.split('@')[0], + productName: resolveProductName(event.payload.productId), + verifyUrl, + }, + productId: event.payload.productId, + userId: event.payload.userId, + }, + log + ); + } catch (err) { + log.error( + { err, eventId: event.id }, + '[delivery/subscriber] Failed to send email verification' + ); + } + }); + + // Email verified — confirmation (no email needed) bus.on('user.email_verified', async event => { log.info( { userId: event.payload.userId, productId: event.payload.productId }, @@ -119,3 +148,13 @@ function resolveDashboardUrl(productId: string): string { }; return urls[productId] || `https://${productId}.bytelyst.com`; } + +function resolveProductName(productId: string): string { + const names: Record = { + lysnrai: 'LysnrAI', + chronomind: 'ChronoMind', + nomgap: 'NomGap', + mindlyst: 'MindLyst', + }; + return names[productId] || productId; +} diff --git a/services/platform-service/src/modules/delivery/types.ts b/services/platform-service/src/modules/delivery/types.ts index d4a3175d..e8c1fe04 100644 --- a/services/platform-service/src/modules/delivery/types.ts +++ b/services/platform-service/src/modules/delivery/types.ts @@ -64,7 +64,7 @@ export interface DeliveryLogDoc { // ── Channel Config ─────────────────────────────────────────── export interface EmailChannelConfig { - provider: 'sendgrid' | 'postmark' | 'console'; + provider: 'sendgrid' | 'postmark' | 'bytelyst' | 'console'; apiKey?: string; fromEmail: string; fromName: string;