Replace the header "Byt lösenord" link with an Inställningar menu for changing email or password. Email changes are two-step: request with password, confirmation link to the new address, then password again on confirm so a wrong inbox cannot take over the account. - Backend: EmailChangeService, V10 email_change_tokens, confirm API - Frontend: ChangeEmailPage, ConfirmEmailChangePage, header dropdown - E2E: account-settings round-trips, Mailpit verification, wrong-password guard - Flyway: V9 restore for dev DBs, CI migration checks, V10 for email tokens Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
4.9 KiB
TypeScript
164 lines
4.9 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
|
|
}
|
|
|
|
export async function waitForEmailChangeToken(
|
|
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 = extractEmailChangeToken(body, options.publicBaseUrl)
|
|
if (token) return token
|
|
}
|
|
|
|
await sleep(500)
|
|
}
|
|
|
|
throw new Error(
|
|
`No email change confirmation for ${recipientEmail} in Mailpit within ${timeoutMs}ms`,
|
|
)
|
|
}
|
|
|
|
function extractEmailChangeToken(body: string, publicBaseUrl?: string): string | null {
|
|
const pathPattern = /\/bekrafta-epost\?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}/bekrafta-epost\\?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))
|
|
}
|