feat: SMTP full ENV override, password reset flow, and E2E email testing

- SMTP: SMTP_HOST/PORT/USER/FROM/TLS now all have ENV override support
  (previously only SMTP_PASSWORD was env-aware). ENV takes priority over DB.
- docker-compose.yml: forward all SMTP_* env vars to app container + add
  Mailhog service (ports 1025 SMTP / 8025 HTTP, always available in dev)
- Password reset: PasswordResetToken Prisma model + authRouter with
  requestPasswordReset (timing-safe, no email enumeration) + resetPassword
- UI: /auth/forgot-password, /auth/reset-password/[token] pages +
  "Forgot password?" link on sign-in page
- E2E: Mailhog helpers (getLatestEmailTo, clearMailhog, extractUrlFromEmail)
  + invite-flow.spec.ts + password-reset.spec.ts

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-02 08:55:39 +02:00
parent e5ecea81c5
commit fceceeee4b
14 changed files with 1030 additions and 11 deletions
@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const mutation = trpc.auth.requestPasswordReset.useMutation({
onSuccess: () => setSubmitted(true),
onError: () => setSubmitted(true), // never reveal failure
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate({ email });
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
{submitted ? (
<div className="text-center">
<div className="text-4xl mb-4">📬</div>
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Check your email
</h1>
<p className="text-sm text-gray-500 mb-6">
If <span className="font-medium">{email}</span> is registered, you will receive a
password reset link within a few minutes.
</p>
<Link
href="/auth/signin"
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
Back to sign in
</Link>
</div>
) : (
<>
<div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
Reset your password
</h1>
<p className="mt-1 text-sm text-gray-500">
Enter your email address and we will send you a reset link.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="you@company.com"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<button
type="submit"
disabled={mutation.isPending}
className="w-full rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 transition-colors disabled:opacity-50"
>
{mutation.isPending ? "Sending…" : "Send reset link"}
</button>
<div className="text-center">
<Link
href="/auth/signin"
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Back to sign in
</Link>
</div>
</form>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,125 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { trpc } from "~/lib/trpc/client.js";
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params);
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const mutation = trpc.auth.resetPassword.useMutation({
onSuccess: () => setDone(true),
onError: (err) => setFormError(err.message),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
if (password.length < 8) {
setFormError("Password must be at least 8 characters.");
return;
}
if (password !== confirm) {
setFormError("Passwords do not match.");
return;
}
mutation.mutate({ token, password });
}
if (done) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8 text-center">
<div className="text-4xl mb-4"></div>
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Password updated
</h1>
<p className="text-sm text-gray-500 mb-6">
Your password has been changed successfully.
</p>
<button
type="button"
onClick={() => router.push("/auth/signin")}
className="rounded-lg bg-brand-600 px-5 py-2 text-sm font-semibold text-white hover:bg-brand-700 transition-colors"
>
Go to sign in
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
<div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
Set a new password
</h1>
<p className="mt-1 text-sm text-gray-500">
Choose a new password for your account.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{formError}
</div>
)}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
New password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
placeholder="At least 8 characters"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div>
<label
htmlFor="confirm"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm new password
</label>
<input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
placeholder="Repeat your password"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<button
type="submit"
disabled={mutation.isPending}
className="w-full rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 transition-colors disabled:opacity-50"
>
{mutation.isPending ? "Saving…" : "Set new password"}
</button>
</form>
</div>
</div>
);
}
+12 -3
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
@@ -140,9 +141,17 @@ export default function SignInPage() {
</div>
<div>
<label htmlFor="password" className="app-label">
Password
</label>
<div className="flex items-center justify-between mb-1">
<label htmlFor="password" className="app-label">
Password
</label>
<Link
href="/auth/forgot-password"
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
Forgot password?
</Link>
</div>
<input
id="password"
type="password"