Add stage 7: email verification magic links and a profile page
Backend - New Mail namespace: a Mailer interface with SMTP (phpmailer), PHP mail() (the default fallback), and log-to-file transports, selected by MAIL_TRANSPORT. EmailVerifier issues a hashed, 15-minute magic-link token and sends the link (APP_URL/verify-email?token=...). - Migration 005: email_verifications table + users.verification_email_sent_at. - Registration now emails a verification link (best effort — a send failure doesn't fail registration). - POST /api/auth/verify-email consumes a token and returns a session, so opening the link verifies the address (or applies a pending email change) and logs the user in. Single-use; distinct 400s for invalid/used/expired. - POST /api/email/verification resends; POST /api/email/change requests a deferred change (current password required; link goes to the new address; users.email only updates when that link is opened). Both throttled to once per 60s, returning 429 + retry_after. - GET /api/me and every session payload now include pending_email. Shared SessionPayload builds the user/session JSON for all entry points. Frontend - /verify-email view: posts the token, adopts the returned session, redirects. - /profile view: shows address + status, a resend button with a live cooldown (driven by retry_after / 429), and a change-email form (new address + current password) that surfaces the pending change. - Header shows a "verify email" badge linking to the profile. Tests: 9 new (EmailVerificationTest) covering the link lifecycle, throttle, and deferred change; AuthTest folded into ApiTestCase, which now routes mail to a per-test log. Suite: 32 passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+23
-1
@@ -15,6 +15,9 @@ final class Config
|
||||
public readonly string $jwtSecret,
|
||||
public readonly int $jwtTtl,
|
||||
public readonly bool $displayErrors,
|
||||
/** Base URL of the frontend, used to build magic links. */
|
||||
public readonly string $appUrl,
|
||||
public readonly MailConfig $mail,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -38,7 +41,26 @@ final class Config
|
||||
$jwtTtl = (int) (self::env('JWT_TTL') ?? '86400');
|
||||
$displayErrors = filter_var(self::env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL);
|
||||
|
||||
return new self($databasePath, $jwtSecret, $jwtTtl, $displayErrors);
|
||||
$appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/');
|
||||
|
||||
$mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log');
|
||||
if (!self::isAbsolutePath($mailLogPath)) {
|
||||
$mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/');
|
||||
}
|
||||
|
||||
$mail = new MailConfig(
|
||||
transport: strtolower(self::env('MAIL_TRANSPORT', 'mail')),
|
||||
fromAddress: self::env('MAIL_FROM', 'no-reply@localhost'),
|
||||
fromName: self::env('MAIL_FROM_NAME', 'Todo List'),
|
||||
logPath: $mailLogPath,
|
||||
smtpHost: self::env('MAIL_SMTP_HOST'),
|
||||
smtpPort: (int) (self::env('MAIL_SMTP_PORT') ?? '587'),
|
||||
smtpUsername: self::env('MAIL_SMTP_USERNAME'),
|
||||
smtpPassword: self::env('MAIL_SMTP_PASSWORD'),
|
||||
smtpEncryption: strtolower(self::env('MAIL_SMTP_ENCRYPTION', 'tls')),
|
||||
);
|
||||
|
||||
return new self($databasePath, $jwtSecret, $jwtTtl, $displayErrors, $appUrl, $mail);
|
||||
}
|
||||
|
||||
private static function env(string $key, ?string $default = null): ?string
|
||||
|
||||
Reference in New Issue
Block a user