Operators can fix prod admin passwords without email via Byt lösenord; end users can use forgot-password when SMTP is configured. Local and CI use Mailpit to capture outbound mail and verify reset links end-to-end. - Backend: V8 password_reset_tokens, PasswordResetService, EmailService, POST /api/auth/forgot-password, reset-password, change-password - Optional testToken in forgot-password response (docker profile only, for E2E) - Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage, routes, login link, header Byt lösenord - Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack - E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery - Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend - Docs: README email section, production-email-checklist, .env.example - Unit/integration tests for reset, change password, and Vitest page specs Co-authored-by: Cursor <cursoragent@cursor.com>
105 lines
3 KiB
TypeScript
105 lines
3 KiB
TypeScript
import type { APIRequestContext } from '@playwright/test'
|
|
|
|
const mailpitApiBase =
|
|
process.env.MAILPIT_API_URL?.replace(/\/$/, '') || 'http://localhost:8025'
|
|
|
|
interface MailpitAddress {
|
|
Name: string
|
|
Address: string
|
|
}
|
|
|
|
interface MailpitMessageSummary {
|
|
ID: string
|
|
To: MailpitAddress[]
|
|
Subject: string
|
|
}
|
|
|
|
interface MailpitMessagesResponse {
|
|
messages: MailpitMessageSummary[]
|
|
}
|
|
|
|
interface MailpitMessageDetail {
|
|
Text?: string
|
|
HTML?: string
|
|
}
|
|
|
|
export async function clearMailpit(request: APIRequestContext): Promise<void> {
|
|
await request.delete(`${mailpitApiBase}/api/v1/messages`)
|
|
}
|
|
|
|
export async function countMessagesTo(
|
|
request: APIRequestContext,
|
|
recipientEmail: string,
|
|
): Promise<number> {
|
|
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
|
|
if (!listResponse.ok()) return 0
|
|
|
|
const list = (await listResponse.json()) as MailpitMessagesResponse
|
|
const normalized = recipientEmail.toLowerCase().trim()
|
|
return (list.messages ?? []).filter((msg) =>
|
|
msg.To?.some((to) => to.Address.toLowerCase() === normalized),
|
|
).length
|
|
}
|
|
|
|
export async function waitForPasswordResetToken(
|
|
request: APIRequestContext,
|
|
recipientEmail: string,
|
|
options: { timeoutMs?: number; publicBaseUrl?: string } = {},
|
|
): Promise<string> {
|
|
const timeoutMs = options.timeoutMs ?? 20_000
|
|
const deadline = Date.now() + timeoutMs
|
|
const normalizedRecipient = recipientEmail.toLowerCase().trim()
|
|
|
|
while (Date.now() < deadline) {
|
|
const listResponse = await request.get(`${mailpitApiBase}/api/v1/messages`)
|
|
if (!listResponse.ok()) {
|
|
await sleep(500)
|
|
continue
|
|
}
|
|
|
|
const list = (await listResponse.json()) as MailpitMessagesResponse
|
|
for (const summary of list.messages ?? []) {
|
|
const matchesRecipient = summary.To?.some(
|
|
(to) => to.Address.toLowerCase() === normalizedRecipient,
|
|
)
|
|
if (!matchesRecipient) continue
|
|
|
|
const detailResponse = await request.get(
|
|
`${mailpitApiBase}/api/v1/message/${summary.ID}`,
|
|
)
|
|
if (!detailResponse.ok()) continue
|
|
|
|
const detail = (await detailResponse.json()) as MailpitMessageDetail
|
|
const body = detail.Text ?? detail.HTML ?? ''
|
|
const token = extractResetToken(body, options.publicBaseUrl)
|
|
if (token) return token
|
|
}
|
|
|
|
await sleep(500)
|
|
}
|
|
|
|
throw new Error(
|
|
`No password reset email for ${recipientEmail} in Mailpit within ${timeoutMs}ms`,
|
|
)
|
|
}
|
|
|
|
function extractResetToken(body: string, publicBaseUrl?: string): string | null {
|
|
const pathPattern = /\/aterstall-losenord\?token=([A-Za-z0-9_-]+)/
|
|
const pathMatch = body.match(pathPattern)
|
|
if (pathMatch) return pathMatch[1]
|
|
|
|
if (publicBaseUrl) {
|
|
const escaped = publicBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
const fullPattern = new RegExp(
|
|
`${escaped}/aterstall-losenord\\?token=([A-Za-z0-9_-]+)`,
|
|
)
|
|
const fullMatch = body.match(fullPattern)
|
|
if (fullMatch) return fullMatch[1]
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|