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:
neon
2026-03-21 04:12:55 -06:00
parent 2df7e093c5
commit 9bcde5b0c8
25 changed files with 469 additions and 271 deletions
@@ -1,11 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import { toast } from 'vue-sonner' 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 { useEmailProviders, type EmailProvider, type EmailProviderForm } from '../composables/useEmailProviders'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' 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 showForm = ref(false)
const editingId = ref<number | null>(null) 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) { async function test(p: EmailProvider) {
testing.value = p.id testing.value = p.id
try { try {
@@ -260,11 +286,26 @@ async function test(p: EmailProvider) {
<span class="text-sm font-medium text-foreground">{{ p.name }}</span> <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.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.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> </div>
<p class="text-xs text-muted-foreground mt-0.5">{{ p.host }}:{{ p.port }} · {{ p.fromAddress || p.username || 'no from address' }}</p> <p class="text-xs text-muted-foreground mt-0.5">{{ p.host }}:{{ p.port }} · {{ p.fromAddress || p.username || 'no from address' }}</p>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <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> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button <button
@@ -329,7 +370,8 @@ async function test(p: EmailProvider) {
<div class="border border-border rounded-lg p-4 bg-card/50"> <div class="border border-border rounded-lg p-4 bg-card/50">
<p class="text-xs text-muted-foreground"> <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> </p>
</div> </div>
</div> </div>
@@ -15,6 +15,7 @@ export interface EmailProvider {
startTls: boolean startTls: boolean
isDefault: boolean isDefault: boolean
isShared: boolean isShared: boolean
isSystemProvider: boolean
hasPassword: boolean hasPassword: boolean
createdAt: string createdAt: string
} }
@@ -108,5 +109,35 @@ export function useEmailProviders() {
return res.json() 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
View File
@@ -4,6 +4,8 @@ export enum AuditAction {
AuthLoginFailed = 'auth.login.failed', AuthLoginFailed = 'auth.login.failed',
AuthLogout = 'auth.logout', AuthLogout = 'auth.logout',
AuthPasswordChange = 'auth.password.change', AuthPasswordChange = 'auth.password.change',
AuthPasswordResetRequest = 'auth.password.reset_request',
AuthPasswordReset = 'auth.password.reset',
AuthPasswordAdminReset = 'auth.password.admin_reset', AuthPasswordAdminReset = 'auth.password.admin_reset',
AuthSessionRevoke = 'auth.session.revoke', AuthSessionRevoke = 'auth.session.revoke',
@@ -62,6 +64,8 @@ export enum AuditAction {
EmailProviderUpdate = 'email.provider.update', EmailProviderUpdate = 'email.provider.update',
EmailProviderDelete = 'email.provider.delete', EmailProviderDelete = 'email.provider.delete',
EmailProviderSetDefault = 'email.provider.set_default', EmailProviderSetDefault = 'email.provider.set_default',
EmailProviderSetSystem = 'email.provider.set_system',
EmailProviderClearSystem = 'email.provider.clear_system',
EmailTemplateCreate = 'email.template.create', EmailTemplateCreate = 'email.template.create',
EmailTemplateUpdate = 'email.template.update', EmailTemplateUpdate = 'email.template.update',
+8 -7
View File
@@ -26,11 +26,12 @@ BOOKS_PATH=../local/data
# OIDC (optional — configure via admin settings UI; these are just for reference) # 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) # OIDC config is stored in the database (app_settings table, key: oidc_config)
# SMTP (optional — if unset, self-service password reset is disabled) # App URL (used for building password reset links in emails)
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@yourdomain.com
APP_URL=http://localhost:5173 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.
+2 -2
View File
@@ -4,7 +4,7 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { LoggerModule } from 'nestjs-pino'; 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 { validateEnv } from './config/env.validation';
import { loggerConfig } from './common/logger.config'; import { loggerConfig } from './common/logger.config';
import { CommonModule } from './common/common.module'; import { CommonModule } from './common/common.module';
@@ -57,7 +57,7 @@ import { AuditModule } from './modules/audit/audit.module';
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
validate: validateEnv, validate: validateEnv,
load: [appConfig, dbConfig, authConfig, storageConfig, mailerConfig, externalApiConfig, emailConfig], load: [appConfig, dbConfig, authConfig, storageConfig, externalApiConfig, emailConfig],
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
DbModule, DbModule,
+2 -3
View File
@@ -1,10 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MailerService } from './services/mailer.service';
import { PermissionService } from './services/permission.service'; import { PermissionService } from './services/permission.service';
@Module({ @Module({
providers: [PermissionService, MailerService], providers: [PermissionService],
exports: [PermissionService, MailerService], exports: [PermissionService],
}) })
export class CommonModule {} 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);
}
}
}
+1 -10
View File
@@ -4,6 +4,7 @@ export const appConfig = registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '3000', 10), port: parseInt(process.env.PORT ?? '3000', 10),
nodeEnv: process.env.NODE_ENV ?? 'development', nodeEnv: process.env.NODE_ENV ?? 'development',
logLevel: process.env.LOG_LEVEL, logLevel: process.env.LOG_LEVEL,
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
})); }));
export const dbConfig = registerAs('db', () => ({ export const dbConfig = registerAs('db', () => ({
@@ -29,13 +30,3 @@ export const externalApiConfig = registerAs('externalApi', () => ({
export const emailConfig = registerAs('email', () => ({ export const emailConfig = registerAs('email', () => ({
encryptionKey: process.env.EMAIL_ENCRYPTION_KEY ?? '', 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',
}));
-6
View File
@@ -18,12 +18,6 @@ const envSchema = z.object({
STAGING_PATH: z.string().optional(), STAGING_PATH: z.string().optional(),
CLIENT_URL: z.string().url().optional(), CLIENT_URL: z.string().url().optional(),
GOOGLE_BOOKS_API_KEY: z.string().default(''), 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'), APP_URL: z.string().url().default('http://localhost:5173'),
EMAIL_ENCRYPTION_KEY: z.string().optional(), EMAIL_ENCRYPTION_KEY: z.string().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).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, "start_tls" boolean DEFAULT true NOT NULL,
"is_default" boolean DEFAULT false NOT NULL, "is_default" boolean DEFAULT false NOT NULL,
"is_shared" 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, "created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "email_providers_user_id_name_unique" UNIQUE("user_id","name") 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", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -4161,6 +4161,13 @@
"notNull": true, "notNull": true,
"default": false "default": false
}, },
"is_system_provider": {
"name": "is_system_provider",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamp", "type": "timestamp",
+1 -1
View File
@@ -5,7 +5,7 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1774047704263, "when": 1774130193988,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
} }
+1
View File
@@ -19,6 +19,7 @@ export const emailProviders = pgTable(
startTls: boolean('start_tls').notNull().default(true), startTls: boolean('start_tls').notNull().default(true),
isDefault: boolean('is_default').notNull().default(false), isDefault: boolean('is_default').notNull().default(false),
isShared: boolean('is_shared').notNull().default(false), isShared: boolean('is_shared').notNull().default(false),
isSystemProvider: boolean('is_system_provider').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at') updatedAt: timestamp('updated_at')
.defaultNow() .defaultNow()
+4 -4
View File
@@ -88,15 +88,15 @@ export class AuthController {
@Public() @Public()
@Post('forgot-password') @Post('forgot-password')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
forgotPassword(@Body() dto: ForgotPasswordDto) { forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: FastifyRequest) {
return this.authService.forgotPassword(dto); return this.authService.forgotPassword(dto, req.ip);
} }
@Public() @Public()
@Post('reset-password') @Post('reset-password')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
resetPassword(@Body() dto: ResetPasswordDto) { resetPassword(@Body() dto: ResetPasswordDto, @Req() req: FastifyRequest) {
return this.authService.resetPassword(dto); return this.authService.resetPassword(dto, req.ip);
} }
@Post('change-password') @Post('change-password')
+2
View File
@@ -7,6 +7,7 @@ import type { StringValue } from 'ms';
import { CommonModule } from '../../common/common.module'; import { CommonModule } from '../../common/common.module';
import { AppSettingsModule } from '../app-settings/app-settings.module'; import { AppSettingsModule } from '../app-settings/app-settings.module';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { EmailModule } from '../email/email.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@@ -35,6 +36,7 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
CommonModule, CommonModule,
AppSettingsModule, AppSettingsModule,
AuditModule, AuditModule,
EmailModule,
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [ providers: [
+106 -10
View File
@@ -76,8 +76,8 @@ function makeService(dbOverrides?: Record<string, unknown>) {
return undefined; return undefined;
}), }),
}; };
const mailerService = { const systemMailService = {
isConfigured: vi.fn().mockReturnValue(true), isConfigured: vi.fn().mockResolvedValue(true),
sendPasswordReset: vi.fn().mockResolvedValue(undefined), sendPasswordReset: vi.fn().mockResolvedValue(undefined),
}; };
const appSettings = { const appSettings = {
@@ -95,7 +95,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
userService as never, userService as never,
jwtService as never, jwtService as never,
config as never, config as never,
mailerService as never, systemMailService as never,
appSettings as never, appSettings as never,
oidcSessionRepo as never, oidcSessionRepo as never,
oidcDiscovery as never, oidcDiscovery as never,
@@ -103,7 +103,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
db, db,
); );
return { service, db, userService, jwtService, config, mailerService, appSettings, oidcSessionRepo, oidcDiscovery }; return { service, db, userService, jwtService, config, systemMailService, appSettings, oidcSessionRepo, oidcDiscovery };
} }
describe('AuthService', () => { describe('AuthService', () => {
@@ -382,9 +382,9 @@ describe('AuthService', () => {
}); });
describe('forgotPassword', () => { describe('forgotPassword', () => {
it('throws ServiceUnavailableException when mailer is not configured', async () => { it('throws ServiceUnavailableException when system mail is not configured', async () => {
const { service, mailerService } = makeService(); const { service, systemMailService } = makeService();
mailerService.isConfigured.mockReturnValue(false); systemMailService.isConfigured.mockResolvedValue(false);
await expect(service.forgotPassword({ email: 'u@example.com' })).rejects.toThrow(ServiceUnavailableException); 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 () => { it('sends reset email when user exists', async () => {
const { service, userService, mailerService } = makeService(); const { service, userService, systemMailService } = makeService();
userService.findByEmail.mockResolvedValue({ id: 1, email: 'u@example.com', name: 'User' }); userService.findByEmail.mockResolvedValue({
id: 1,
email: 'u@example.com',
name: 'User',
active: true,
provisioningMethod: 'local',
username: 'user',
});
await service.forgotPassword({ email: 'u@example.com' }); 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); 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', () => { describe('logout', () => {
+48 -12
View File
@@ -23,7 +23,7 @@ import { DB } from '../../db/db.module';
import * as schema from '../../db/schema'; import * as schema from '../../db/schema';
import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service'; import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service';
import type { RequestUser } from '../../common/types/request-user'; 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 { AppSettingsService } from '../app-settings/app-settings.service';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { ChangePasswordDto } from './dto/change-password.dto'; import { ChangePasswordDto } from './dto/change-password.dto';
@@ -57,7 +57,7 @@ export class AuthService {
private readonly userService: UserService, private readonly userService: UserService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly mailerService: MailerService, private readonly systemMailService: SystemMailService,
private readonly appSettings: AppSettingsService, private readonly appSettings: AppSettingsService,
private readonly oidcSessionRepo: OidcSessionRepository, private readonly oidcSessionRepo: OidcSessionRepository,
private readonly oidcDiscovery: OidcDiscoveryService, 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)); await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, sessionId));
} }
async forgotPassword(dto: ForgotPasswordDto): Promise<void> { async forgotPassword(dto: ForgotPasswordDto, ip?: string): Promise<void> {
if (!this.mailerService.isConfigured()) { if (!(await this.systemMailService.isConfigured())) {
throw new ServiceUnavailableException('Self-service password reset is not configured. Contact your administrator.'); throw new ServiceUnavailableException('Self-service password reset is not configured. Contact your administrator.');
} }
void this.processPasswordResetAsync(dto.email, ip).catch((err) => this.logger.error('Unhandled error during password reset processing', err));
// 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);
} }
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 tokenHash = sha256(dto.token);
const row = await this.db.query.passwordResetTokens.findFirst({ const row = await this.db.query.passwordResetTokens.findFirst({
@@ -361,6 +384,11 @@ export class AuthService {
throw new BadRequestException('Invalid or expired reset token'); 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); const passwordHash = await hash(dto.newPassword, 12);
await this.db.transaction(async (tx) => { await this.db.transaction(async (tx) => {
@@ -376,6 +404,14 @@ export class AuthService {
.set({ revokedAt: new Date() }) .set({ revokedAt: new Date() })
.where(and(eq(schema.refreshTokens.userId, row.userId), isNull(schema.refreshTokens.revokedAt))); .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) { async changePassword(userId: number, dto: ChangePasswordDto, reply: FastifyReply, ip?: string) {
@@ -70,6 +70,12 @@ export class EmailProviderController {
} }
@Patch(':id/share') @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) { toggleShared(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.service.toggleShared(id, user); return this.service.toggleShared(id, user);
} }
@@ -79,4 +85,26 @@ export class EmailProviderController {
testConnection(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) { testConnection(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.service.testConnection(id, user); 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 { 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 { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db'; import { DB } from '../../db';
@@ -73,4 +73,27 @@ export class EmailProviderRepository {
.where(and(eq(emailProviders.id, id), eq(emailProviders.userId, userId))) .where(and(eq(emailProviders.id, id), eq(emailProviders.userId, userId)))
.returning(); .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 type { RequestUser } from '../../common/types/request-user';
import { EmailEncryptionService } from './email-encryption.service'; import { EmailEncryptionService } from './email-encryption.service';
@@ -59,7 +59,10 @@ export class EmailProviderService {
} }
async remove(id: number, user: RequestUser) { 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); await this.repo.delete(id, user.id);
} }
@@ -72,13 +75,26 @@ export class EmailProviderService {
} }
async toggleShared(id: number, user: RequestUser) { 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 provider = await this.getOwned(id, user);
const [updated] = await this.repo.setSharedByOwner(id, user.id, !provider.isShared); const [updated] = await this.repo.setSharedByOwner(id, user.id, !provider.isShared);
if (!updated) throw new NotFoundException('Provider not found'); if (!updated) throw new NotFoundException('Provider not found');
return this.sanitize(updated); 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 }> { async testConnection(id: number, user: RequestUser): Promise<{ success: boolean; error?: string }> {
const provider = await this.getOwnedOrShared(id, user); const provider = await this.getOwnedOrShared(id, user);
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined; const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
@@ -128,8 +144,4 @@ export class EmailProviderService {
const { passwordEnc, ...rest } = provider; const { passwordEnc, ...rest } = provider;
return { ...rest, hasPassword: !!provider.passwordEnc }; return { ...rest, hasPassword: !!provider.passwordEnc };
} }
private isSuperuser(user: RequestUser): boolean {
return user.isSuperuser;
}
} }
@@ -38,7 +38,7 @@ export class EmailTemplateContextService {
const [meta] = metaResult; const [meta] = metaResult;
const file: typeof bookFiles.$inferSelect | undefined = fileResult[0]; 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` : ''; const coverUrl = meta ? `${appUrl}/api/books/${bookId}/cover` : '';
return { return {
+3 -1
View File
@@ -26,6 +26,7 @@ import { EmailTemplateController } from './email-template.controller';
import { EmailTemplateRepository } from './email-template.repository'; import { EmailTemplateRepository } from './email-template.repository';
import { EmailTemplateService } from './email-template.service'; import { EmailTemplateService } from './email-template.service';
import { EmailTransportService } from './email-transport.service'; import { EmailTransportService } from './email-transport.service';
import { SystemMailService } from './system-mail.service';
@Module({ @Module({
imports: [], imports: [],
@@ -58,7 +59,8 @@ import { EmailTransportService } from './email-transport.service';
EmailSendLogRepository, EmailSendLogRepository,
EmailSendLogService, EmailSendLogService,
EmailSendOrchestrator, EmailSendOrchestrator,
SystemMailService,
], ],
exports: [EmailSendOrchestrator, EmailTransportService, EmailEncryptionService], exports: [EmailSendOrchestrator, EmailTransportService, EmailEncryptionService, SystemMailService],
}) })
export class EmailModule {} 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedUrl = resetUrl.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
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>`;
}
}
+2 -2
View File
@@ -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 rawToken = await this.userRepo.generateResetToken(user.id);
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`; const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
@@ -180,7 +180,7 @@ export class UserService {
if (target.isSuperuser && !requestingUser.isSuperuser) { if (target.isSuperuser && !requestingUser.isSuperuser) {
throw new ForbiddenException('Only administrators can reset administrator passwords'); 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); const rawToken = await this.userRepo.generateResetToken(targetUserId);
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` }; return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };
} }