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
This commit is contained in:
saravanakumardb1 2026-02-28 04:17:04 -08:00
parent e3e332cee6
commit 4e94ecd721
9 changed files with 334 additions and 74 deletions

View File

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

View File

@ -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.' });
}
}

View File

@ -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) { export async function getMeViaService(token: string) {
return platformApi.fetch<{ return platformApi.fetch<{
id: string; id: string;
@ -109,19 +136,17 @@ export async function listUsers(
limit = 100, limit = 100,
offset = 0 offset = 0
): Promise<{ users: UserDoc[] }> { ): Promise<{ users: UserDoc[] }> {
return platformApi.fetch<{ users: UserDoc[] }>( return platformApi.fetch<{ users: UserDoc[] }>(`/auth/users?limit=${limit}&offset=${offset}`, {
`/auth/users?limit=${limit}&offset=${offset}`, headers: { Authorization: `Bearer ${token}` },
{ headers: { Authorization: `Bearer ${token}` } } });
);
} }
export async function getUserCounts( export async function getUserCounts(
token: string token: string
): Promise<{ total: number; byPlan: Record<string, number> }> { ): Promise<{ total: number; byPlan: Record<string, number> }> {
return platformApi.fetch<{ total: number; byPlan: Record<string, number> }>( return platformApi.fetch<{ total: number; byPlan: Record<string, number> }>('/auth/users/count', {
'/auth/users/count', headers: { Authorization: `Bearer ${token}` },
{ headers: { Authorization: `Bearer ${token}` } } });
);
} }
export async function getUser(token: string, id: string): Promise<UserDoc> { export async function getUser(token: string, id: string): Promise<UserDoc> {
@ -223,17 +248,16 @@ export interface PlanConfig {
} }
export async function listPlans(productId: string): Promise<{ plans: PlanConfig[] }> { export async function listPlans(productId: string): Promise<{ plans: PlanConfig[] }> {
return platformApi.fetch<{ plans: PlanConfig[] }>( return platformApi.fetch<{ plans: PlanConfig[] }>(`/plans`, {
`/plans`, headers: { 'x-product-id': productId },
{ headers: { 'x-product-id': productId } } });
);
} }
export async function seedPlans(productId: string): Promise<{ plans: PlanConfig[] }> { export async function seedPlans(productId: string): Promise<{ plans: PlanConfig[] }> {
return platformApi.fetch<{ plans: PlanConfig[] }>( return platformApi.fetch<{ plans: PlanConfig[] }>('/plans/seed', {
'/plans/seed', method: 'POST',
{ method: 'POST', headers: { 'x-product-id': productId } } headers: { 'x-product-id': productId },
); });
} }
// ── Product Onboarding ──────────────────────────────────────── // ── Product Onboarding ────────────────────────────────────────
@ -298,10 +322,7 @@ export async function createFlag(input: Record<string, unknown>): Promise<FlagDo
}); });
} }
export async function updateFlag( export async function updateFlag(key: string, updates: Record<string, unknown>): Promise<FlagDoc> {
key: string,
updates: Record<string, unknown>
): Promise<FlagDoc> {
return platformApi.fetch<FlagDoc>(`/flags/${encodeURIComponent(key)}`, { return platformApi.fetch<FlagDoc>(`/flags/${encodeURIComponent(key)}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(updates), body: JSON.stringify(updates),
@ -354,10 +375,7 @@ export async function createToken(
}); });
} }
export async function revokeToken( export async function revokeToken(token: string, tokenId: string): Promise<{ success: boolean }> {
token: string,
tokenId: string
): Promise<{ success: boolean }> {
return platformApi.fetch<{ success: boolean }>(`/tokens/${tokenId}`, { return platformApi.fetch<{ success: boolean }>(`/tokens/${tokenId}`, {
method: 'PATCH', method: 'PATCH',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
@ -365,19 +383,14 @@ export async function revokeToken(
}); });
} }
export async function deleteToken( export async function deleteToken(token: string, tokenId: string): Promise<{ success: boolean }> {
token: string,
tokenId: string
): Promise<{ success: boolean }> {
return platformApi.fetch<{ success: boolean }>(`/tokens/${tokenId}`, { return platformApi.fetch<{ success: boolean }>(`/tokens/${tokenId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
} }
export async function countActiveTokens( export async function countActiveTokens(token: string): Promise<{ count: number }> {
token: string
): Promise<{ count: number }> {
return platformApi.fetch<{ count: number }>('/tokens/count', { return platformApi.fetch<{ count: number }>('/tokens/count', {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
@ -436,20 +449,14 @@ export async function updateTheme(
}); });
} }
export async function deleteTheme( export async function deleteTheme(token: string, id: string): Promise<{ success: boolean }> {
token: string,
id: string
): Promise<{ success: boolean }> {
return platformApi.fetch<{ success: boolean }>(`/themes/${id}`, { return platformApi.fetch<{ success: boolean }>(`/themes/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
} }
export async function activateTheme( export async function activateTheme(token: string, id: string): Promise<ThemeDoc> {
token: string,
id: string
): Promise<ThemeDoc> {
return platformApi.fetch<ThemeDoc>(`/themes/${id}/activate`, { return platformApi.fetch<ThemeDoc>(`/themes/${id}/activate`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
@ -612,10 +619,9 @@ export interface TelemetryPolicy {
export async function listTelemetryPolicies( export async function listTelemetryPolicies(
token: string token: string
): Promise<{ policies: TelemetryPolicy[] }> { ): Promise<{ policies: TelemetryPolicy[] }> {
return platformApi.fetch<{ policies: TelemetryPolicy[] }>( return platformApi.fetch<{ policies: TelemetryPolicy[] }>('/telemetry/policies', {
'/telemetry/policies', headers: { Authorization: `Bearer ${token}` },
{ headers: { Authorization: `Bearer ${token}` } } });
);
} }
export async function createTelemetryPolicy( export async function createTelemetryPolicy(
@ -685,9 +691,7 @@ export async function updateClusterStatus(
); );
} }
export async function getTelemetryMetrics( export async function getTelemetryMetrics(token: string): Promise<TelemetryMetrics> {
token: string
): Promise<TelemetryMetrics> {
return platformApi.fetch<TelemetryMetrics>('/telemetry/metrics', { return platformApi.fetch<TelemetryMetrics>('/telemetry/metrics', {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
@ -707,10 +711,9 @@ export async function getTelemetryGeoDistribution(
if (from) params.set('from', from); if (from) params.set('from', from);
if (to) params.set('to', to); if (to) params.set('to', to);
const qs = params.toString() ? `?${params.toString()}` : ''; const qs = params.toString() ? `?${params.toString()}` : '';
return platformApi.fetch<{ distribution: GeoDistributionEntry[] }>( return platformApi.fetch<{ distribution: GeoDistributionEntry[] }>(`/telemetry/geo${qs}`, {
`/telemetry/geo${qs}`, headers: { Authorization: `Bearer ${token}` },
{ headers: { Authorization: `Bearer ${token}` } } });
);
} }
export async function eraseTelemetryUser( export async function eraseTelemetryUser(

View File

@ -14,6 +14,7 @@ export const PlatformEventSchemas = {
}), }),
'user.deleted': z.object({ 'user.deleted': z.object({
userId: z.string(), userId: z.string(),
email: z.string(),
productId: z.string(), productId: z.string(),
}), }),
'user.email_verified': z.object({ 'user.email_verified': z.object({
@ -24,6 +25,15 @@ export const PlatformEventSchemas = {
'user.password_reset': z.object({ 'user.password_reset': z.object({
userId: z.string(), userId: z.string(),
email: 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(), productId: z.string(),
}), }),

View File

@ -1,13 +1,23 @@
/** /**
* Auth REST endpoints. * Auth REST endpoints.
* *
* POST /auth/login login with email + password * POST /auth/login login with email + password
* POST /auth/register register new user * POST /auth/register register new user
* POST /auth/refresh refresh access token * POST /auth/refresh refresh access token
* GET /auth/me get current user from token * GET /auth/me get current user from token
* PUT /auth/profile update own profile (displayName, phone, bio, avatarUrl) * PUT /auth/profile update own profile (displayName, phone, bio, avatarUrl)
* POST /auth/sso SSO login (verified external identity) * POST /auth/change-password change password (authenticated)
* POST /auth/verify service-to-service token verification * 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): * Admin user management (requires super_admin or admin role):
* GET /auth/users list users (paginated) * GET /auth/users list users (paginated)
@ -32,13 +42,14 @@ import {
UpdateUserSchema, UpdateUserSchema,
SsoLoginSchema, SsoLoginSchema,
ProfileUpdateSchema, ProfileUpdateSchema,
ChangePasswordSchema,
DeleteAccountSchema,
ForgotPasswordSchema, ForgotPasswordSchema,
ResetPasswordSchema, ResetPasswordSchema,
VerifyEmailSchema, VerifyEmailSchema,
ResendVerificationSchema, ResendVerificationSchema,
type UserDoc, type UserDoc,
} from './types.js'; } from './types.js';
export async function authRoutes(app: FastifyInstance) { export async function authRoutes(app: FastifyInstance) {
// Login // Login
app.post('/auth/login', async req => { app.post('/auth/login', async req => {
@ -187,6 +198,40 @@ export async function authRoutes(app: FastifyInstance) {
) )
.catch(() => {}); .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); reply.code(201);
return { return {
accessToken, accessToken,
@ -271,6 +316,59 @@ export async function authRoutes(app: FastifyInstance) {
return safe; 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) // SSO login — verified external identity (called by dashboard OAuth callbacks)
// The caller has already verified the user's identity via Microsoft/Google OAuth. // 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. // 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(), 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 }, '[auth] Password reset token generated');
req.log.info(
{ userId: user.id, productId, resetToken: rawToken },
'[auth] Password reset token generated'
);
// Emit password_reset event (fire-and-forget) // Emit password_reset event — subscriber sends the email
bus bus
.emit( .emit(
'user.password_reset', 'user.password_reset',
{ {
userId: user.id, userId: user.id,
email: user.email, email: user.email,
resetToken: rawToken,
displayName: user.displayName,
productId, productId,
}, },
{ source: 'auth/forgot-password' } { source: 'auth/forgot-password' }
@ -552,11 +648,22 @@ export async function authRoutes(app: FastifyInstance) {
createdAt: new Date().toISOString(), 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 }, '[auth] Email verification token generated');
req.log.info(
{ userId: user.id, productId, verificationToken: rawToken }, // Emit event — subscriber sends the email
'[auth] Email verification token generated' 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.' }; return { message: 'If that email exists, a verification link has been sent.' };
}); });

View File

@ -77,6 +77,19 @@ export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type SsoLoginInput = z.infer<typeof SsoLoginSchema>; export type SsoLoginInput = z.infer<typeof SsoLoginSchema>;
export type ProfileUpdateInput = z.infer<typeof ProfileUpdateSchema>; export type ProfileUpdateInput = z.infer<typeof ProfileUpdateSchema>;
// ── 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 ─────────────────────────────────────────── // ── Password Reset ───────────────────────────────────────────
export const ForgotPasswordSchema = z.object({ export const ForgotPasswordSchema = z.object({

View File

@ -31,6 +31,8 @@ export async function sendEmail(
switch (config.provider) { switch (config.provider) {
case 'console': case 'console':
return sendViaConsole(message, log); return sendViaConsole(message, log);
case 'bytelyst':
return sendViaBytelyst(message, log);
case 'sendgrid': case 'sendgrid':
return sendViaSendGrid(message, config); return sendViaSendGrid(message, config);
case 'postmark': case 'postmark':
@ -59,6 +61,47 @@ async function sendViaConsole(
return { success: true, messageId }; 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<SendResult> {
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 ──────────────────────────────────────── // ── SendGrid Provider ────────────────────────────────────────
async function sendViaSendGrid( async function sendViaSendGrid(

View File

@ -42,14 +42,16 @@ export function registerDeliverySubscribers(
// Password reset email // Password reset email
bus.on('user.password_reset', async event => { bus.on('user.password_reset', async event => {
try { try {
const baseUrl = resolveDashboardUrl(event.payload.productId);
const resetUrl = `${baseUrl}/reset-password?token=${event.payload.resetToken}`;
await dispatchEmail( await dispatchEmail(
{ {
to: event.payload.email, to: event.payload.email,
templateId: 'password-reset', templateId: 'password-reset',
variables: { variables: {
displayName: event.payload.email.split('@')[0], displayName: event.payload.displayName || event.payload.email.split('@')[0],
productName: event.payload.productId, productName: resolveProductName(event.payload.productId),
resetUrl: `${resolveDashboardUrl(event.payload.productId)}/reset-password`, resetUrl,
}, },
productId: event.payload.productId, productId: event.payload.productId,
userId: event.payload.userId, 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 => { bus.on('user.email_verified', async event => {
log.info( log.info(
{ userId: event.payload.userId, productId: event.payload.productId }, { userId: event.payload.userId, productId: event.payload.productId },
@ -119,3 +148,13 @@ function resolveDashboardUrl(productId: string): string {
}; };
return urls[productId] || `https://${productId}.bytelyst.com`; return urls[productId] || `https://${productId}.bytelyst.com`;
} }
function resolveProductName(productId: string): string {
const names: Record<string, string> = {
lysnrai: 'LysnrAI',
chronomind: 'ChronoMind',
nomgap: 'NomGap',
mindlyst: 'MindLyst',
};
return names[productId] || productId;
}

View File

@ -64,7 +64,7 @@ export interface DeliveryLogDoc {
// ── Channel Config ─────────────────────────────────────────── // ── Channel Config ───────────────────────────────────────────
export interface EmailChannelConfig { export interface EmailChannelConfig {
provider: 'sendgrid' | 'postmark' | 'console'; provider: 'sendgrid' | 'postmark' | 'bytelyst' | 'console';
apiKey?: string; apiKey?: string;
fromEmail: string; fromEmail: string;
fromName: string; fromName: string;