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">
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,
}
}