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) {
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<string, number> }> {
return platformApi.fetch<{ total: number; byPlan: Record<string, number> }>(
'/auth/users/count',
{ headers: { Authorization: `Bearer ${token}` } }
);
return platformApi.fetch<{ total: number; byPlan: Record<string, number> }>('/auth/users/count', {
headers: { Authorization: `Bearer ${token}` },
});
}
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[] }> {
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<string, unknown>): Promise<FlagDo
});
}
export async function updateFlag(
key: string,
updates: Record<string, unknown>
): Promise<FlagDoc> {
export async function updateFlag(key: string, updates: Record<string, unknown>): Promise<FlagDoc> {
return platformApi.fetch<FlagDoc>(`/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<ThemeDoc> {
export async function activateTheme(token: string, id: string): Promise<ThemeDoc> {
return platformApi.fetch<ThemeDoc>(`/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<TelemetryMetrics> {
export async function getTelemetryMetrics(token: string): Promise<TelemetryMetrics> {
return platformApi.fetch<TelemetryMetrics>('/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(

View File

@ -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(),
}),

View File

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

View File

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

View File

@ -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<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 ────────────────────────────────────────
async function sendViaSendGrid(

View File

@ -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<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 ───────────────────────────────────────────
export interface EmailChannelConfig {
provider: 'sendgrid' | 'postmark' | 'console';
provider: 'sendgrid' | 'postmark' | 'bytelyst' | 'console';
apiKey?: string;
fromEmail: string;
fromName: string;