mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: implement password reset via UI-configured system mail provider
- replace env-var SMTP config with DB-backed system mail provider - add isSystemProvider flag to emailProviders table - add SystemMailService for sending password reset emails - add PATCH :id/system and DELETE system endpoints on email providers - add forgotPassword/resetPassword audit logging with IP tracking - block OIDC and inactive users from password reset flow - fire-and-forget async work to prevent email enumeration timing oracle - add HTML email template for password reset - remove mailer.service and all SMTP env vars
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Plus, Pencil, Trash2, Star, Share2, Wifi } from 'lucide-vue-next'
|
||||
import { Plus, Pencil, Trash2, Star, Share2, Wifi, Server } from 'lucide-vue-next'
|
||||
import { useEmailProviders, type EmailProvider, type EmailProviderForm } from '../composables/useEmailProviders'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const { providers, createProvider, updateProvider, deleteProvider, setDefaultProvider, toggleSharedProvider, testProvider } = useEmailProviders()
|
||||
const {
|
||||
providers,
|
||||
createProvider,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
setDefaultProvider,
|
||||
toggleSharedProvider,
|
||||
testProvider,
|
||||
setSystemProvider,
|
||||
clearSystemProvider,
|
||||
} = useEmailProviders()
|
||||
const { isSuperuser } = usePermissions()
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
@@ -112,6 +124,20 @@ async function toggleShare(p: EmailProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setSystem(p: EmailProvider) {
|
||||
try {
|
||||
if (p.isSystemProvider) {
|
||||
await clearSystemProvider()
|
||||
toast.success(`"${p.name}" removed as system mail provider`)
|
||||
} else {
|
||||
await setSystemProvider(p.id)
|
||||
toast.success(`"${p.name}" set as system mail provider`)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to update system provider')
|
||||
}
|
||||
}
|
||||
|
||||
async function test(p: EmailProvider) {
|
||||
testing.value = p.id
|
||||
try {
|
||||
@@ -260,11 +286,26 @@ async function test(p: EmailProvider) {
|
||||
<span class="text-sm font-medium text-foreground">{{ p.name }}</span>
|
||||
<span v-if="p.isDefault" class="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-primary/15 text-primary">Default</span>
|
||||
<span v-if="p.isShared" class="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-muted text-muted-foreground">Shared</span>
|
||||
<span v-if="p.isSystemProvider" class="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-600 dark:text-amber-400"
|
||||
>System</span
|
||||
>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">{{ p.host }}:{{ p.port }} · {{ p.fromAddress || p.username || 'no from address' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Tooltip v-if="isSuperuser">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
||||
:class="p.isSystemProvider ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500 hover:bg-muted'"
|
||||
@click="setSystem(p)"
|
||||
>
|
||||
<Server :size="13" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ p.isSystemProvider ? 'Remove as system mail provider' : 'Set as system mail provider' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
@@ -329,7 +370,8 @@ async function test(p: EmailProvider) {
|
||||
|
||||
<div class="border border-border rounded-lg p-4 bg-card/50">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Providers with "Shared" enabled are available to all users. The "Default" provider is used when no provider is specified at send time.
|
||||
The <strong>System</strong> provider (superuser only) is used for password reset emails. The <strong>Default</strong> provider is used when
|
||||
sending books with no explicit provider selected. Providers marked <strong>Shared</strong> are available to all users.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface EmailProvider {
|
||||
startTls: boolean
|
||||
isDefault: boolean
|
||||
isShared: boolean
|
||||
isSystemProvider: boolean
|
||||
hasPassword: boolean
|
||||
createdAt: string
|
||||
}
|
||||
@@ -108,5 +109,35 @@ export function useEmailProviders() {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
return { providers, fetchProviders, createProvider, updateProvider, deleteProvider, setDefaultProvider, toggleSharedProvider, testProvider }
|
||||
async function setSystemProvider(id: number): Promise<void> {
|
||||
const res = await api(`/api/v1/email/providers/${id}/system`, { method: 'PATCH' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error((err as { message?: string }).message ?? 'Failed to set system provider')
|
||||
}
|
||||
const updated: EmailProvider = await res.json()
|
||||
providers.value = providers.value.map((p) => ({ ...p, isSystemProvider: p.id === updated.id }))
|
||||
}
|
||||
|
||||
async function clearSystemProvider(): Promise<void> {
|
||||
const res = await api('/api/v1/email/providers/system', { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error((err as { message?: string }).message ?? 'Failed to clear system provider')
|
||||
}
|
||||
providers.value = providers.value.map((p) => ({ ...p, isSystemProvider: false }))
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
fetchProviders,
|
||||
createProvider,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
setDefaultProvider,
|
||||
toggleSharedProvider,
|
||||
testProvider,
|
||||
setSystemProvider,
|
||||
clearSystemProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ export enum AuditAction {
|
||||
AuthLoginFailed = 'auth.login.failed',
|
||||
AuthLogout = 'auth.logout',
|
||||
AuthPasswordChange = 'auth.password.change',
|
||||
AuthPasswordResetRequest = 'auth.password.reset_request',
|
||||
AuthPasswordReset = 'auth.password.reset',
|
||||
AuthPasswordAdminReset = 'auth.password.admin_reset',
|
||||
AuthSessionRevoke = 'auth.session.revoke',
|
||||
|
||||
@@ -62,6 +64,8 @@ export enum AuditAction {
|
||||
EmailProviderUpdate = 'email.provider.update',
|
||||
EmailProviderDelete = 'email.provider.delete',
|
||||
EmailProviderSetDefault = 'email.provider.set_default',
|
||||
EmailProviderSetSystem = 'email.provider.set_system',
|
||||
EmailProviderClearSystem = 'email.provider.clear_system',
|
||||
|
||||
EmailTemplateCreate = 'email.template.create',
|
||||
EmailTemplateUpdate = 'email.template.update',
|
||||
|
||||
+8
-7
@@ -26,11 +26,12 @@ BOOKS_PATH=../local/data
|
||||
# OIDC (optional — configure via admin settings UI; these are just for reference)
|
||||
# OIDC config is stored in the database (app_settings table, key: oidc_config)
|
||||
|
||||
# SMTP (optional — if unset, self-service password reset is disabled)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=noreply@yourdomain.com
|
||||
# App URL (used for building password reset links in emails)
|
||||
APP_URL=http://localhost:5173
|
||||
|
||||
# Email encryption key (optional but recommended — 64-char hex string for AES-256)
|
||||
# Generate with: openssl rand -hex 32
|
||||
# EMAIL_ENCRYPTION_KEY=
|
||||
|
||||
# SMTP for system emails (password reset) is configured via the admin UI,
|
||||
# not via environment variables.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { appConfig, authConfig, dbConfig, emailConfig, externalApiConfig, mailerConfig, storageConfig } from './config/config';
|
||||
import { appConfig, authConfig, dbConfig, emailConfig, externalApiConfig, storageConfig } from './config/config';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import { loggerConfig } from './common/logger.config';
|
||||
import { CommonModule } from './common/common.module';
|
||||
@@ -57,7 +57,7 @@ import { AuditModule } from './modules/audit/audit.module';
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
load: [appConfig, dbConfig, authConfig, storageConfig, mailerConfig, externalApiConfig, emailConfig],
|
||||
load: [appConfig, dbConfig, authConfig, storageConfig, externalApiConfig, emailConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
DbModule,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MailerService } from './services/mailer.service';
|
||||
import { PermissionService } from './services/permission.service';
|
||||
|
||||
@Module({
|
||||
providers: [PermissionService, MailerService],
|
||||
exports: [PermissionService, MailerService],
|
||||
providers: [PermissionService],
|
||||
exports: [PermissionService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { MockedFunction } from 'vitest';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
|
||||
import { MailerService } from './mailer.service';
|
||||
|
||||
vi.mock('nodemailer', () => ({
|
||||
createTransport: vi.fn(),
|
||||
}));
|
||||
|
||||
type ConfigValues = Record<string, unknown>;
|
||||
|
||||
const DEFAULT_CONFIG: ConfigValues = {
|
||||
'mailer.host': 'smtp.example.com',
|
||||
'mailer.port': 587,
|
||||
'mailer.secure': false,
|
||||
'mailer.user': 'mailer-user',
|
||||
'mailer.pass': 'mailer-pass',
|
||||
'mailer.from': 'noreply@example.com',
|
||||
'mailer.appUrl': 'http://localhost:5173',
|
||||
'app.nodeEnv': 'development',
|
||||
};
|
||||
|
||||
function makeConfig(overrides: ConfigValues = {}): ConfigService {
|
||||
const values = { ...DEFAULT_CONFIG, ...overrides };
|
||||
return {
|
||||
get: vi.fn((key: string) => values[key]),
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
function makeService(overrides: ConfigValues = {}) {
|
||||
const config = makeConfig(overrides);
|
||||
const service = new MailerService(config);
|
||||
const logger = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
(service as unknown as { logger: typeof logger }).logger = logger;
|
||||
|
||||
return { service, config, logger };
|
||||
}
|
||||
|
||||
describe('MailerService', () => {
|
||||
const createTransportMock = nodemailer.createTransport as MockedFunction<typeof nodemailer.createTransport>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('isConfigured returns true only when SMTP host exists', () => {
|
||||
const configured = makeService();
|
||||
const notConfigured = makeService({ 'mailer.host': '' });
|
||||
|
||||
expect(configured.service.isConfigured()).toBe(true);
|
||||
expect(notConfigured.service.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not send email when mailer is not configured and still logs reset URL in non-production', async () => {
|
||||
const { service, logger } = makeService({
|
||||
'mailer.host': undefined,
|
||||
'mailer.appUrl': 'http://app.local/',
|
||||
'app.nodeEnv': 'development',
|
||||
});
|
||||
|
||||
await expect(service.sendPasswordReset('user@example.com', 'Alice', 'a+b&c/d==')).resolves.toBeUndefined();
|
||||
|
||||
expect(createTransportMock).not.toHaveBeenCalled();
|
||||
expect(logger.log).toHaveBeenCalledWith('[DEV] Password reset URL for Alice: http://app.local/reset-password?token=a%2Bb%26c%2Fd%3D%3D');
|
||||
});
|
||||
|
||||
it('sends password reset email with expected payload and no DEV log in production', async () => {
|
||||
const sendMail = vi.fn().mockResolvedValue(undefined);
|
||||
createTransportMock.mockReturnValue({ sendMail } as unknown as nodemailer.Transporter);
|
||||
|
||||
const { service, logger } = makeService({
|
||||
'app.nodeEnv': 'production',
|
||||
'mailer.appUrl': 'https://app.example.com/',
|
||||
});
|
||||
|
||||
await expect(service.sendPasswordReset('user@example.com', 'Alice', 'token+slash/value')).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.log).not.toHaveBeenCalled();
|
||||
expect(createTransportMock).toHaveBeenCalledWith({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: 'mailer-user',
|
||||
pass: 'mailer-pass',
|
||||
},
|
||||
});
|
||||
expect(sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
from: 'noreply@example.com',
|
||||
to: 'user@example.com',
|
||||
subject: 'Reset your password',
|
||||
}),
|
||||
);
|
||||
expect(sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('https://app.example.com/reset-password?token=token%2Bslash%2Fvalue'),
|
||||
html: expect.stringContaining('https://app.example.com/reset-password?token=token%2Bslash%2Fvalue'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the same transporter across multiple sends', async () => {
|
||||
const sendMail = vi.fn().mockResolvedValue(undefined);
|
||||
createTransportMock.mockReturnValue({ sendMail } as unknown as nodemailer.Transporter);
|
||||
|
||||
const { service } = makeService();
|
||||
|
||||
await service.sendPasswordReset('u1@example.com', 'User1', 'token-1');
|
||||
await service.sendPasswordReset('u2@example.com', 'User2', 'token-2');
|
||||
|
||||
expect(createTransportMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('swallows transport creation errors and logs them', async () => {
|
||||
const error = new Error('invalid SMTP configuration');
|
||||
createTransportMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const { service, logger } = makeService();
|
||||
|
||||
await expect(service.sendPasswordReset('user@example.com', 'Alice', 'token')).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith('Failed to send password reset email', error);
|
||||
});
|
||||
|
||||
it('swallows provider-specific non-Error failures from sendMail and logs them', async () => {
|
||||
const providerFailure = { code: 'EAUTH', response: 'Invalid login' };
|
||||
const sendMail = vi.fn().mockRejectedValue(providerFailure);
|
||||
createTransportMock.mockReturnValue({ sendMail } as unknown as nodemailer.Transporter);
|
||||
|
||||
const { service, logger } = makeService();
|
||||
|
||||
await expect(service.sendPasswordReset('user@example.com', 'Alice', 'token')).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith('Failed to send password reset email', providerFailure);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
@Injectable()
|
||||
export class MailerService {
|
||||
private readonly logger = new Logger(MailerService.name);
|
||||
private transporter: Transporter | null = null;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!this.config.get<string>('mailer.host');
|
||||
}
|
||||
|
||||
private getTransporter(): Transporter {
|
||||
if (!this.transporter) {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: this.config.get<string>('mailer.host'),
|
||||
port: this.config.get<number>('mailer.port'),
|
||||
secure: this.config.get<boolean>('mailer.secure'),
|
||||
auth: {
|
||||
user: this.config.get<string>('mailer.user'),
|
||||
pass: this.config.get<string>('mailer.pass'),
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
async sendPasswordReset(to: string, username: string, rawToken: string): Promise<void> {
|
||||
const appUrl = (this.config.get<string>('mailer.appUrl') ?? '').replace(/\/+$/, '');
|
||||
const resetUrl = `${appUrl}/reset-password?token=${encodeURIComponent(rawToken)}`;
|
||||
|
||||
if (this.config.get('app.nodeEnv') !== 'production') {
|
||||
this.logger.log(`[DEV] Password reset URL for ${username}: ${resetUrl}`);
|
||||
}
|
||||
|
||||
if (!this.isConfigured()) return;
|
||||
|
||||
try {
|
||||
await this.getTransporter().sendMail({
|
||||
from: this.config.get<string>('mailer.from'),
|
||||
to,
|
||||
subject: 'Reset your password',
|
||||
text: `Hi ${username},\n\nClick the link below to reset your password (expires in 15 minutes):\n\n${resetUrl}\n\nIf you did not request this, ignore this email.`,
|
||||
html: `<p>Hi ${username},</p><p>Click the link below to reset your password (expires in 15 minutes):</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>If you did not request this, ignore this email.</p>`,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to send password reset email', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export const appConfig = registerAs('app', () => ({
|
||||
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
logLevel: process.env.LOG_LEVEL,
|
||||
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
|
||||
}));
|
||||
|
||||
export const dbConfig = registerAs('db', () => ({
|
||||
@@ -29,13 +30,3 @@ export const externalApiConfig = registerAs('externalApi', () => ({
|
||||
export const emailConfig = registerAs('email', () => ({
|
||||
encryptionKey: process.env.EMAIL_ENCRYPTION_KEY ?? '',
|
||||
}));
|
||||
|
||||
export const mailerConfig = registerAs('mailer', () => ({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT ?? '587', 10),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
from: process.env.SMTP_FROM ?? 'noreply@projectx.local',
|
||||
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
|
||||
}));
|
||||
|
||||
@@ -18,12 +18,6 @@ const envSchema = z.object({
|
||||
STAGING_PATH: z.string().optional(),
|
||||
CLIENT_URL: z.string().url().optional(),
|
||||
GOOGLE_BOOKS_API_KEY: z.string().default(''),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.coerce.number().int().positive().default(587),
|
||||
SMTP_SECURE: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('noreply@projectx.local'),
|
||||
APP_URL: z.string().url().default('http://localhost:5173'),
|
||||
EMAIL_ENCRYPTION_KEY: z.string().optional(),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(),
|
||||
|
||||
@@ -511,6 +511,7 @@ CREATE TABLE "email_providers" (
|
||||
"start_tls" boolean DEFAULT true NOT NULL,
|
||||
"is_default" boolean DEFAULT false NOT NULL,
|
||||
"is_shared" boolean DEFAULT false NOT NULL,
|
||||
"is_system_provider" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_providers_user_id_name_unique" UNIQUE("user_id","name")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "34de1491-9bf5-4cfe-b899-32360bc9b18a",
|
||||
"id": "a5647da5-6734-4203-8698-c4192405229a",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -4161,6 +4161,13 @@
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"is_system_provider": {
|
||||
"name": "is_system_provider",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1774047704263,
|
||||
"when": 1774130193988,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const emailProviders = pgTable(
|
||||
startTls: boolean('start_tls').notNull().default(true),
|
||||
isDefault: boolean('is_default').notNull().default(false),
|
||||
isShared: boolean('is_shared').notNull().default(false),
|
||||
isSystemProvider: boolean('is_system_provider').notNull().default(false),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
|
||||
@@ -88,15 +88,15 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
forgotPassword(@Body() dto: ForgotPasswordDto) {
|
||||
return this.authService.forgotPassword(dto);
|
||||
forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: FastifyRequest) {
|
||||
return this.authService.forgotPassword(dto, req.ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
resetPassword(@Body() dto: ResetPasswordDto) {
|
||||
return this.authService.resetPassword(dto);
|
||||
resetPassword(@Body() dto: ResetPasswordDto, @Req() req: FastifyRequest) {
|
||||
return this.authService.resetPassword(dto, req.ip);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { StringValue } from 'ms';
|
||||
import { CommonModule } from '../../common/common.module';
|
||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { EmailModule } from '../email/email.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -35,6 +36,7 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
|
||||
CommonModule,
|
||||
AppSettingsModule,
|
||||
AuditModule,
|
||||
EmailModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
|
||||
@@ -76,8 +76,8 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
const mailerService = {
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
const systemMailService = {
|
||||
isConfigured: vi.fn().mockResolvedValue(true),
|
||||
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const appSettings = {
|
||||
@@ -95,7 +95,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
userService as never,
|
||||
jwtService as never,
|
||||
config as never,
|
||||
mailerService as never,
|
||||
systemMailService as never,
|
||||
appSettings as never,
|
||||
oidcSessionRepo as never,
|
||||
oidcDiscovery as never,
|
||||
@@ -103,7 +103,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
db,
|
||||
);
|
||||
|
||||
return { service, db, userService, jwtService, config, mailerService, appSettings, oidcSessionRepo, oidcDiscovery };
|
||||
return { service, db, userService, jwtService, config, systemMailService, appSettings, oidcSessionRepo, oidcDiscovery };
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
@@ -382,9 +382,9 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
describe('forgotPassword', () => {
|
||||
it('throws ServiceUnavailableException when mailer is not configured', async () => {
|
||||
const { service, mailerService } = makeService();
|
||||
mailerService.isConfigured.mockReturnValue(false);
|
||||
it('throws ServiceUnavailableException when system mail is not configured', async () => {
|
||||
const { service, systemMailService } = makeService();
|
||||
systemMailService.isConfigured.mockResolvedValue(false);
|
||||
|
||||
await expect(service.forgotPassword({ email: 'u@example.com' })).rejects.toThrow(ServiceUnavailableException);
|
||||
});
|
||||
@@ -397,11 +397,52 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('sends reset email when user exists', async () => {
|
||||
const { service, userService, mailerService } = makeService();
|
||||
userService.findByEmail.mockResolvedValue({ id: 1, email: 'u@example.com', name: 'User' });
|
||||
const { service, userService, systemMailService } = makeService();
|
||||
userService.findByEmail.mockResolvedValue({
|
||||
id: 1,
|
||||
email: 'u@example.com',
|
||||
name: 'User',
|
||||
active: true,
|
||||
provisioningMethod: 'local',
|
||||
username: 'user',
|
||||
});
|
||||
|
||||
await service.forgotPassword({ email: 'u@example.com' });
|
||||
expect(mailerService.sendPasswordReset).toHaveBeenCalledWith('u@example.com', 'User', 'raw-reset-token');
|
||||
// fire-and-forget — flush microtasks
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(systemMailService.sendPasswordReset).toHaveBeenCalledWith('u@example.com', 'User', 'raw-reset-token');
|
||||
});
|
||||
|
||||
it('silently returns without sending email for inactive user', async () => {
|
||||
const { service, userService, systemMailService } = makeService();
|
||||
userService.findByEmail.mockResolvedValue({
|
||||
id: 1,
|
||||
email: 'u@example.com',
|
||||
name: 'User',
|
||||
active: false,
|
||||
provisioningMethod: 'local',
|
||||
username: 'user',
|
||||
});
|
||||
|
||||
await service.forgotPassword({ email: 'u@example.com' });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(systemMailService.sendPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('silently returns without sending email for OIDC user', async () => {
|
||||
const { service, userService, systemMailService } = makeService();
|
||||
userService.findByEmail.mockResolvedValue({
|
||||
id: 1,
|
||||
email: 'u@example.com',
|
||||
name: 'User',
|
||||
active: true,
|
||||
provisioningMethod: 'oidc',
|
||||
username: 'user',
|
||||
});
|
||||
|
||||
await service.forgotPassword({ email: 'u@example.com' });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(systemMailService.sendPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -490,6 +531,61 @@ describe('AuthService', () => {
|
||||
|
||||
await expect(service.resetPassword({ token: 'used', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException for inactive user', async () => {
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
expiresAt: new Date(Date.now() + 10_000),
|
||||
usedAt: null,
|
||||
});
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).users.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'jdoe',
|
||||
active: false,
|
||||
provisioningMethod: 'local',
|
||||
});
|
||||
|
||||
await expect(service.resetPassword({ token: 'valid', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException for OIDC user', async () => {
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
expiresAt: new Date(Date.now() + 10_000),
|
||||
usedAt: null,
|
||||
});
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).users.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'jdoe',
|
||||
active: true,
|
||||
provisioningMethod: 'oidc',
|
||||
});
|
||||
|
||||
await expect(service.resetPassword({ token: 'valid', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('resets password, marks token used, and revokes sessions on success', async () => {
|
||||
const { service, db } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
|
||||
id: 5,
|
||||
userId: 1,
|
||||
expiresAt: new Date(Date.now() + 10_000),
|
||||
usedAt: null,
|
||||
});
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).users.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'jdoe',
|
||||
active: true,
|
||||
provisioningMethod: 'local',
|
||||
});
|
||||
|
||||
await expect(service.resetPassword({ token: 'valid', newPassword: 'NewPassword1!' })).resolves.toBeUndefined();
|
||||
expect(db.transaction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { DB } from '../../db/db.module';
|
||||
import * as schema from '../../db/schema';
|
||||
import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { MailerService } from '../../common/services/mailer.service';
|
||||
import { SystemMailService } from '../email/system-mail.service';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
@@ -57,7 +57,7 @@ export class AuthService {
|
||||
private readonly userService: UserService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly systemMailService: SystemMailService,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
private readonly oidcSessionRepo: OidcSessionRepository,
|
||||
private readonly oidcDiscovery: OidcDiscoveryService,
|
||||
@@ -337,20 +337,43 @@ export class AuthService {
|
||||
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, sessionId));
|
||||
}
|
||||
|
||||
async forgotPassword(dto: ForgotPasswordDto): Promise<void> {
|
||||
if (!this.mailerService.isConfigured()) {
|
||||
async forgotPassword(dto: ForgotPasswordDto, ip?: string): Promise<void> {
|
||||
if (!(await this.systemMailService.isConfigured())) {
|
||||
throw new ServiceUnavailableException('Self-service password reset is not configured. Contact your administrator.');
|
||||
}
|
||||
|
||||
// Always return without revealing whether email exists
|
||||
const user = await this.userService.findByEmail(dto.email);
|
||||
if (!user || !user.email) return;
|
||||
|
||||
const rawToken = await this.userService.generatePasswordResetToken(user.id);
|
||||
await this.mailerService.sendPasswordReset(user.email, user.name, rawToken);
|
||||
void this.processPasswordResetAsync(dto.email, ip).catch((err) => this.logger.error('Unhandled error during password reset processing', err));
|
||||
}
|
||||
|
||||
async resetPassword(dto: ResetPasswordDto): Promise<void> {
|
||||
private async processPasswordResetAsync(email: string, ip?: string): Promise<void> {
|
||||
const user = await this.userService.findByEmail(email);
|
||||
if (!user || !user.email) {
|
||||
this.logger.log(`Password reset requested for unknown email: ${email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.active) {
|
||||
this.logger.log(`Password reset requested for inactive account: ${email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.provisioningMethod === 'oidc') {
|
||||
this.logger.log(`Password reset requested for OIDC account: ${email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawToken = await this.userService.generatePasswordResetToken(user.id);
|
||||
await this.systemMailService.sendPasswordReset(user.email, user.name, rawToken);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.AuthPasswordResetRequest,
|
||||
description: `Password reset email sent to ${user.email}`,
|
||||
ip,
|
||||
});
|
||||
}
|
||||
|
||||
async resetPassword(dto: ResetPasswordDto, ip?: string): Promise<void> {
|
||||
const tokenHash = sha256(dto.token);
|
||||
|
||||
const row = await this.db.query.passwordResetTokens.findFirst({
|
||||
@@ -361,6 +384,11 @@ export class AuthService {
|
||||
throw new BadRequestException('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const user = await this.db.query.users.findFirst({ where: eq(schema.users.id, row.userId) });
|
||||
if (!user || !user.active || user.provisioningMethod === 'oidc') {
|
||||
throw new BadRequestException('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const passwordHash = await hash(dto.newPassword, 12);
|
||||
|
||||
await this.db.transaction(async (tx) => {
|
||||
@@ -376,6 +404,14 @@ export class AuthService {
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(schema.refreshTokens.userId, row.userId), isNull(schema.refreshTokens.revokedAt)));
|
||||
});
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.AuthPasswordReset,
|
||||
description: `Password reset completed for user '${user.username}'`,
|
||||
ip,
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(userId: number, dto: ChangePasswordDto, reply: FastifyReply, ip?: string) {
|
||||
|
||||
@@ -70,6 +70,12 @@ export class EmailProviderController {
|
||||
}
|
||||
|
||||
@Patch(':id/share')
|
||||
@Auditable({
|
||||
action: AuditAction.EmailProviderUpdate,
|
||||
resource: AuditResource.EmailProvider,
|
||||
getResourceId: (req) => parseInt(req.params['id'], 10),
|
||||
description: (req) => `Toggled sharing for email provider #${req.params['id']}`,
|
||||
})
|
||||
toggleShared(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
return this.service.toggleShared(id, user);
|
||||
}
|
||||
@@ -79,4 +85,26 @@ export class EmailProviderController {
|
||||
testConnection(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
return this.service.testConnection(id, user);
|
||||
}
|
||||
|
||||
@Patch(':id/system')
|
||||
@Auditable({
|
||||
action: AuditAction.EmailProviderSetSystem,
|
||||
resource: AuditResource.EmailProvider,
|
||||
getResourceId: (req) => parseInt(req.params['id'], 10),
|
||||
description: (req) => `Set email provider #${req.params['id']} as system mail provider`,
|
||||
})
|
||||
setSystemProvider(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||
return this.service.setSystemProvider(id, user);
|
||||
}
|
||||
|
||||
@Delete('system')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Auditable({
|
||||
action: AuditAction.EmailProviderClearSystem,
|
||||
resource: AuditResource.EmailProvider,
|
||||
description: () => 'Cleared system mail provider',
|
||||
})
|
||||
clearSystemProvider(@CurrentUser() user: RequestUser) {
|
||||
return this.service.clearSystemProvider(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNull, ne, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -73,4 +73,27 @@ export class EmailProviderRepository {
|
||||
.where(and(eq(emailProviders.id, id), eq(emailProviders.userId, userId)))
|
||||
.returning();
|
||||
}
|
||||
|
||||
findSystemProvider() {
|
||||
return this.db.select().from(emailProviders).where(eq(emailProviders.isSystemProvider, true)).limit(1);
|
||||
}
|
||||
|
||||
clearSystemProvider() {
|
||||
return this.db.update(emailProviders).set({ isSystemProvider: false }).where(eq(emailProviders.isSystemProvider, true));
|
||||
}
|
||||
|
||||
setSystemProvider(id: number) {
|
||||
return this.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(emailProviders)
|
||||
.set({ isSystemProvider: false })
|
||||
.where(and(eq(emailProviders.isSystemProvider, true), ne(emailProviders.id, id)));
|
||||
const [updated] = await tx
|
||||
.update(emailProviders)
|
||||
.set({ isSystemProvider: true, updatedAt: sql`now()` })
|
||||
.where(eq(emailProviders.id, id))
|
||||
.returning();
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { EmailEncryptionService } from './email-encryption.service';
|
||||
@@ -59,7 +59,10 @@ export class EmailProviderService {
|
||||
}
|
||||
|
||||
async remove(id: number, user: RequestUser) {
|
||||
await this.getOwned(id, user);
|
||||
const provider = await this.getOwned(id, user);
|
||||
if (provider.isSystemProvider) {
|
||||
throw new BadRequestException('Cannot delete the system mail provider. Remove the system designation first.');
|
||||
}
|
||||
await this.repo.delete(id, user.id);
|
||||
}
|
||||
|
||||
@@ -72,13 +75,26 @@ export class EmailProviderService {
|
||||
}
|
||||
|
||||
async toggleShared(id: number, user: RequestUser) {
|
||||
if (!this.isSuperuser(user)) throw new ForbiddenException('Only superusers can share providers');
|
||||
if (!user.isSuperuser) throw new ForbiddenException('Only superusers can share providers');
|
||||
const provider = await this.getOwned(id, user);
|
||||
const [updated] = await this.repo.setSharedByOwner(id, user.id, !provider.isShared);
|
||||
if (!updated) throw new NotFoundException('Provider not found');
|
||||
return this.sanitize(updated);
|
||||
}
|
||||
|
||||
async setSystemProvider(id: number, user: RequestUser) {
|
||||
if (!user.isSuperuser) throw new ForbiddenException('Only superusers can configure the system mail provider');
|
||||
await this.getOwnedOrShared(id, user);
|
||||
const updated = await this.repo.setSystemProvider(id);
|
||||
if (!updated) throw new NotFoundException('Provider not found');
|
||||
return this.sanitize(updated);
|
||||
}
|
||||
|
||||
async clearSystemProvider(user: RequestUser) {
|
||||
if (!user.isSuperuser) throw new ForbiddenException('Only superusers can configure the system mail provider');
|
||||
await this.repo.clearSystemProvider();
|
||||
}
|
||||
|
||||
async testConnection(id: number, user: RequestUser): Promise<{ success: boolean; error?: string }> {
|
||||
const provider = await this.getOwnedOrShared(id, user);
|
||||
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
|
||||
@@ -128,8 +144,4 @@ export class EmailProviderService {
|
||||
const { passwordEnc, ...rest } = provider;
|
||||
return { ...rest, hasPassword: !!provider.passwordEnc };
|
||||
}
|
||||
|
||||
private isSuperuser(user: RequestUser): boolean {
|
||||
return user.isSuperuser;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export class EmailTemplateContextService {
|
||||
const [meta] = metaResult;
|
||||
const file: typeof bookFiles.$inferSelect | undefined = fileResult[0];
|
||||
|
||||
const appUrl = this.config.get<string>('mailer.appUrl') ?? '';
|
||||
const appUrl = this.config.get<string>('app.appUrl') ?? '';
|
||||
const coverUrl = meta ? `${appUrl}/api/books/${bookId}/cover` : '';
|
||||
|
||||
return {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { EmailTemplateController } from './email-template.controller';
|
||||
import { EmailTemplateRepository } from './email-template.repository';
|
||||
import { EmailTemplateService } from './email-template.service';
|
||||
import { EmailTransportService } from './email-transport.service';
|
||||
import { SystemMailService } from './system-mail.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
@@ -58,7 +59,8 @@ import { EmailTransportService } from './email-transport.service';
|
||||
EmailSendLogRepository,
|
||||
EmailSendLogService,
|
||||
EmailSendOrchestrator,
|
||||
SystemMailService,
|
||||
],
|
||||
exports: [EmailSendOrchestrator, EmailTransportService, EmailEncryptionService],
|
||||
exports: [EmailSendOrchestrator, EmailTransportService, EmailEncryptionService, SystemMailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { EmailEncryptionService } from './email-encryption.service';
|
||||
import { EmailProviderRepository } from './email-provider.repository';
|
||||
import { EmailTransportService } from './email-transport.service';
|
||||
|
||||
@Injectable()
|
||||
export class SystemMailService {
|
||||
private readonly logger = new Logger(SystemMailService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repo: EmailProviderRepository,
|
||||
private readonly encryption: EmailEncryptionService,
|
||||
private readonly transport: EmailTransportService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async isConfigured(): Promise<boolean> {
|
||||
const [provider] = await this.repo.findSystemProvider();
|
||||
return !!provider;
|
||||
}
|
||||
|
||||
async sendPasswordReset(to: string, name: string, rawToken: string): Promise<void> {
|
||||
const appUrl = (this.config.get<string>('app.appUrl') ?? '').replace(/\/+$/, '');
|
||||
const resetUrl = `${appUrl}/reset-password?token=${encodeURIComponent(rawToken)}`;
|
||||
|
||||
if (this.config.get('app.nodeEnv') !== 'production') {
|
||||
this.logger.log(`[DEV] Password reset URL for ${name}: ${resetUrl}`);
|
||||
}
|
||||
|
||||
const [provider] = await this.repo.findSystemProvider();
|
||||
if (!provider) {
|
||||
this.logger.warn('System mail provider not found when sending password reset - provider may have been removed');
|
||||
return;
|
||||
}
|
||||
|
||||
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
|
||||
const transporter = this.transport.buildTransporter({
|
||||
host: provider.host,
|
||||
port: provider.port,
|
||||
username: provider.username,
|
||||
password: password ?? null,
|
||||
auth: provider.auth,
|
||||
ssl: provider.ssl,
|
||||
startTls: provider.startTls,
|
||||
});
|
||||
|
||||
const from = provider.fromAddress ? (provider.fromName ? `${provider.fromName} <${provider.fromAddress}>` : provider.fromAddress) : undefined;
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: 'Reset your password',
|
||||
text: this.buildResetText(name, resetUrl),
|
||||
html: this.buildResetHtml(name, resetUrl),
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error('Failed to send password reset email', err);
|
||||
}
|
||||
}
|
||||
|
||||
private buildResetText(name: string, resetUrl: string): string {
|
||||
return [
|
||||
`Hi ${name},`,
|
||||
'',
|
||||
'We received a request to reset the password for your account.',
|
||||
'',
|
||||
'Click the link below to set a new password. This link expires in 15 minutes.',
|
||||
'',
|
||||
resetUrl,
|
||||
'',
|
||||
'If you did not request a password reset, you can safely ignore this email.',
|
||||
'Your password will not change unless you click the link above.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private buildResetHtml(name: string, resetUrl: string): string {
|
||||
const escapedName = name.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escapedUrl = resetUrl.replace(/&/g, '&').replace(/"/g, '"');
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reset your password</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" style="max-width:520px;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
|
||||
<tr>
|
||||
<td style="background:#18181b;padding:24px 32px;">
|
||||
<p style="margin:0;color:#ffffff;font-size:18px;font-weight:600;letter-spacing:-0.3px;">Project X</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:32px 32px 24px;">
|
||||
<p style="margin:0 0 8px;font-size:20px;font-weight:600;color:#18181b;">Reset your password</p>
|
||||
<p style="margin:0 0 24px;font-size:14px;color:#71717a;line-height:1.6;">
|
||||
Hi ${escapedName}, we received a request to reset the password for your account.
|
||||
This link expires in <strong style="color:#18181b;">15 minutes</strong>.
|
||||
</p>
|
||||
<a href="${escapedUrl}"
|
||||
style="display:inline-block;padding:11px 24px;background:#18181b;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none;border-radius:6px;">
|
||||
Reset password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 32px 32px;">
|
||||
<p style="margin:24px 0 0;padding-top:20px;border-top:1px solid #e4e4e7;font-size:12px;color:#a1a1aa;line-height:1.6;">
|
||||
If you did not request a password reset, you can safely ignore this email.
|
||||
Your password will not change unless you click the button above.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
const appUrl = this.config.get<string>('mailer.appUrl') ?? 'http://localhost:5173';
|
||||
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
||||
const rawToken = await this.userRepo.generateResetToken(user.id);
|
||||
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
|
||||
|
||||
@@ -180,7 +180,7 @@ export class UserService {
|
||||
if (target.isSuperuser && !requestingUser.isSuperuser) {
|
||||
throw new ForbiddenException('Only administrators can reset administrator passwords');
|
||||
}
|
||||
const appUrl = this.config.get<string>('mailer.appUrl') ?? 'http://localhost:5173';
|
||||
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
||||
const rawToken = await this.userRepo.generateResetToken(targetUserId);
|
||||
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user