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:
parent
e3e332cee6
commit
4e94ecd721
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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.' });
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
|
||||
|
||||
@ -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.' };
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user