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:
@@ -39,6 +39,81 @@ export async function signOut(page: Page) {
|
|||||||
await page.waitForURL(/\/auth\/signin/, { timeout: 10000 });
|
await page.waitForURL(/\/auth\/signin/, { timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mailhog helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MAILHOG_API = process.env["MAILHOG_API"] ?? "http://localhost:8025";
|
||||||
|
|
||||||
|
type MailhogMessage = {
|
||||||
|
Content: {
|
||||||
|
Headers: { Subject?: string[]; To?: string[] };
|
||||||
|
Body: string;
|
||||||
|
};
|
||||||
|
MIME: { Parts?: Array<{ Headers: { "Content-Type"?: string[] }; Body: string }> } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MailhogResponse = {
|
||||||
|
count: number;
|
||||||
|
items: MailhogMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Delete all messages in Mailhog (call in beforeEach to prevent cross-test contamination). */
|
||||||
|
export async function clearMailhog(): Promise<void> {
|
||||||
|
await fetch(`${MAILHOG_API}/api/v1/messages`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll Mailhog until a message to `address` appears. Returns the message.
|
||||||
|
* Throws after `timeoutMs` if no matching message is found.
|
||||||
|
*/
|
||||||
|
export async function getLatestEmailTo(
|
||||||
|
address: string,
|
||||||
|
{ timeoutMs = 10_000, pollIntervalMs = 500 }: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
||||||
|
): Promise<{ subject: string; body: string; html: string }> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const res = await fetch(`${MAILHOG_API}/api/v2/messages?limit=50`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as MailhogResponse;
|
||||||
|
const match = data.items.find((msg) => {
|
||||||
|
const to = msg.Content.Headers.To ?? [];
|
||||||
|
return to.some((t) => t.toLowerCase().includes(address.toLowerCase()));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const subject = (match.Content.Headers.Subject ?? [])[0] ?? "";
|
||||||
|
const htmlPart = match.MIME?.Parts?.find((p) =>
|
||||||
|
(p.Headers["Content-Type"]?.[0] ?? "").includes("text/html"),
|
||||||
|
);
|
||||||
|
const html = htmlPart?.Body ?? match.Content.Body;
|
||||||
|
return { subject, body: match.Content.Body, html };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`No email to "${address}" found within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a URL from email body/html matching a path prefix.
|
||||||
|
* e.g. extractUrlFromEmail(email, "/invite/") → "http://localhost:3100/invite/abc123"
|
||||||
|
*/
|
||||||
|
export function extractUrlFromEmail(
|
||||||
|
email: { body: string; html: string },
|
||||||
|
pathPrefix: string,
|
||||||
|
): string {
|
||||||
|
const text = email.html || email.body;
|
||||||
|
const match = text.match(new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`));
|
||||||
|
if (!match?.[0]) {
|
||||||
|
throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
|
||||||
|
}
|
||||||
|
return match[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tRPC helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Intercept all tRPC batch responses and assert none return HTTP 401.
|
* Intercept all tRPC batch responses and assert none return HTTP 401.
|
||||||
* Returns a list of intercepted tRPC paths that were called.
|
* Returns a list of intercepted tRPC paths that were called.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* E2E — Invite flow
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - Dev server running on http://localhost:3100
|
||||||
|
* - Mailhog running on http://localhost:8025
|
||||||
|
* - SMTP_HOST=mailhog (or localhost), SMTP_PORT=1025, SMTP_TLS=false configured
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Admin opens /admin/users → clicks "Invite User"
|
||||||
|
* 2. Fills in a unique test email address + role USER
|
||||||
|
* 3. Waits for success toast
|
||||||
|
* 4. Reads the invite email from Mailhog
|
||||||
|
* 5. Visits the invite link → sets a password
|
||||||
|
* 6. Signs in with the new credentials
|
||||||
|
* 7. Lands on the dashboard
|
||||||
|
*/
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
||||||
|
import { clearMailhog, extractUrlFromEmail, getLatestEmailTo } from "./helpers.js";
|
||||||
|
|
||||||
|
test.describe("invite flow", () => {
|
||||||
|
test.use({ storageState: STORAGE_STATE.admin });
|
||||||
|
|
||||||
|
test("admin invites a new user and invited user can sign in", async ({ page }) => {
|
||||||
|
await clearMailhog();
|
||||||
|
|
||||||
|
const testEmail = `invite-e2e-${Date.now()}@capakraken.test`;
|
||||||
|
|
||||||
|
// Step 1: Navigate to admin users page
|
||||||
|
await page.goto("/admin/users");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Step 2: Open invite modal
|
||||||
|
await page.click('button:has-text("Invite User")');
|
||||||
|
await page.waitForSelector('[role="dialog"], form:has(input[type="email"])');
|
||||||
|
|
||||||
|
// Step 3: Fill in invite form
|
||||||
|
await page.fill('input[type="email"]', testEmail);
|
||||||
|
|
||||||
|
// Step 4: Submit
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Step 5: Wait for success (toast or modal close)
|
||||||
|
await expect(page.locator("text=Invite sent")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Step 6: Read email from Mailhog
|
||||||
|
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
|
||||||
|
const inviteUrl = extractUrlFromEmail(email, "/invite/");
|
||||||
|
|
||||||
|
// Strip base URL — Playwright navigates relative to baseURL
|
||||||
|
const invitePath = new URL(inviteUrl).pathname;
|
||||||
|
|
||||||
|
// Step 7: Accept invite in a new context (not logged in as admin)
|
||||||
|
const invitePage = await page.context().newPage();
|
||||||
|
await invitePage.goto(invitePath);
|
||||||
|
|
||||||
|
// Wait for password form
|
||||||
|
await expect(invitePage.locator("text=Accept invitation")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await invitePage.fill('input[type="password"]', "TestPass123!");
|
||||||
|
// Confirm field
|
||||||
|
const passwordInputs = invitePage.locator('input[type="password"]');
|
||||||
|
await passwordInputs.nth(1).fill("TestPass123!");
|
||||||
|
await invitePage.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Account created state
|
||||||
|
await expect(invitePage.locator("text=Account created")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Step 8: Sign in with new credentials
|
||||||
|
await invitePage.click('button:has-text("Go to sign in")');
|
||||||
|
await invitePage.waitForURL(/\/auth\/signin/);
|
||||||
|
|
||||||
|
await invitePage.fill('input[type="email"]', testEmail);
|
||||||
|
await invitePage.fill('input[type="password"]', "TestPass123!");
|
||||||
|
await invitePage.click('button[type="submit"]');
|
||||||
|
|
||||||
|
await invitePage.waitForURL(/\/(dashboard|resources)/, { timeout: 15_000 });
|
||||||
|
await invitePage.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* E2E — Password reset flow
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - Dev server running on http://localhost:3100
|
||||||
|
* - Mailhog running on http://localhost:8025
|
||||||
|
* - SMTP_HOST=mailhog (or localhost), SMTP_PORT=1025, SMTP_TLS=false configured
|
||||||
|
*
|
||||||
|
* Uses a dedicated test user "reset-test@planarchy.dev" (Dev123456!) that
|
||||||
|
* exists in the dev seed. This avoids modifying shared admin/manager/viewer
|
||||||
|
* credentials that other E2E tests depend on.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Request password reset for reset-test@planarchy.dev
|
||||||
|
* 2. Read reset email from Mailhog
|
||||||
|
* 3. Visit reset link → enter new password
|
||||||
|
* 4. Sign in with new password → land on dashboard
|
||||||
|
* 5. (Cleanup) Reset the password back to Dev123456!
|
||||||
|
*/
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { clearMailhog, extractUrlFromEmail, getLatestEmailTo, signIn } from "./helpers.js";
|
||||||
|
|
||||||
|
const RESET_USER = { email: "reset-test@planarchy.dev", originalPassword: "Dev123456!" };
|
||||||
|
const NEW_PASSWORD = "ResetPass456!";
|
||||||
|
|
||||||
|
test.describe("password reset flow", () => {
|
||||||
|
// No storageState — these tests exercise the unauthenticated flow
|
||||||
|
|
||||||
|
test("user can reset password via email link", async ({ page }) => {
|
||||||
|
await clearMailhog();
|
||||||
|
|
||||||
|
// Step 1: Navigate to forgot-password page
|
||||||
|
await page.goto("/auth/forgot-password");
|
||||||
|
await page.fill('input[type="email"]', RESET_USER.email);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Step 2: Confirm the "check your email" state is shown
|
||||||
|
await expect(page.locator("text=Check your email")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Step 3: Read reset email from Mailhog
|
||||||
|
const email = await getLatestEmailTo(RESET_USER.email, { timeoutMs: 15_000 });
|
||||||
|
expect(email.subject).toMatch(/reset/i);
|
||||||
|
|
||||||
|
const resetUrl = extractUrlFromEmail(email, "/auth/reset-password/");
|
||||||
|
const resetPath = new URL(resetUrl).pathname;
|
||||||
|
|
||||||
|
// Step 4: Visit reset link
|
||||||
|
await page.goto(resetPath);
|
||||||
|
await expect(page.locator("text=Set a new password")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Step 5: Set new password
|
||||||
|
const passwordInputs = page.locator('input[type="password"]');
|
||||||
|
await passwordInputs.nth(0).fill(NEW_PASSWORD);
|
||||||
|
await passwordInputs.nth(1).fill(NEW_PASSWORD);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Password updated state
|
||||||
|
await expect(page.locator("text=Password updated")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Step 6: Sign in with new password
|
||||||
|
await page.click('button:has-text("Go to sign in")');
|
||||||
|
await page.waitForURL(/\/auth\/signin/);
|
||||||
|
|
||||||
|
await signIn(page, RESET_USER.email, NEW_PASSWORD);
|
||||||
|
await page.waitForURL(/\/(dashboard|resources)/, { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Step 7: Cleanup — reset password back to original so next test run works
|
||||||
|
await clearMailhog();
|
||||||
|
await page.goto("/auth/forgot-password");
|
||||||
|
await page.fill('input[type="email"]', RESET_USER.email);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.locator("text=Check your email")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
const cleanupEmail = await getLatestEmailTo(RESET_USER.email, { timeoutMs: 15_000 });
|
||||||
|
const cleanupPath = new URL(extractUrlFromEmail(cleanupEmail, "/auth/reset-password/")).pathname;
|
||||||
|
|
||||||
|
await page.goto(cleanupPath);
|
||||||
|
await page.waitForSelector('input[type="password"]');
|
||||||
|
const cleanupInputs = page.locator('input[type="password"]');
|
||||||
|
await cleanupInputs.nth(0).fill(RESET_USER.originalPassword);
|
||||||
|
await cleanupInputs.nth(1).fill(RESET_USER.originalPassword);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.locator("text=Password updated")).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid reset token shows an error message", async ({ page }) => {
|
||||||
|
await page.goto("/auth/reset-password/this-token-does-not-exist");
|
||||||
|
|
||||||
|
// Submit the form with a bad token
|
||||||
|
await page.waitForSelector('input[type="password"]');
|
||||||
|
const inputs = page.locator('input[type="password"]');
|
||||||
|
await inputs.nth(0).fill("SomePass1!");
|
||||||
|
await inputs.nth(1).fill("SomePass1!");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Should display an error message (NOT_FOUND or BAD_REQUEST from server)
|
||||||
|
await expect(page.locator('[class*="red"]').first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
@@ -140,9 +141,17 @@ export default function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
<label htmlFor="password" className="app-label">
|
<label htmlFor="password" className="app-label">
|
||||||
Password
|
Password
|
||||||
</label>
|
</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
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
+21
-5
@@ -34,6 +34,12 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
mailhog:
|
||||||
|
image: mailhog/mailhog
|
||||||
|
ports:
|
||||||
|
- "1025:1025" # SMTP
|
||||||
|
- "8025:8025" # HTTP API / Web UI
|
||||||
|
|
||||||
pgadmin:
|
pgadmin:
|
||||||
image: dpage/pgadmin4
|
image: dpage/pgadmin4
|
||||||
ports:
|
ports:
|
||||||
@@ -67,6 +73,13 @@ services:
|
|||||||
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-}
|
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||||
|
# SMTP — forwarded from host .env; overrides SystemSettings DB values
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-}
|
||||||
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
|
SMTP_TLS: ${SMTP_TLS:-}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -74,11 +87,10 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
# Anonymous volumes mask the bind-mount for generated/installed artefacts.
|
# Named volumes mask the bind-mount for generated/installed artefacts.
|
||||||
# Docker seeds them from the image layer on first start; they persist across restarts.
|
# Named (not anonymous) so they can be selectively removed: docker volume rm capakraken_node_modules
|
||||||
# pnpm stores all packages in the root node_modules/.pnpm virtual store — one volume covers it all.
|
- capakraken_node_modules:/app/node_modules
|
||||||
- /app/node_modules
|
- capakraken_next:/app/apps/web/.next
|
||||||
- /app/apps/web/.next
|
|
||||||
profiles:
|
profiles:
|
||||||
- full
|
- full
|
||||||
|
|
||||||
@@ -98,3 +110,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
capakraken_pgdata:
|
capakraken_pgdata:
|
||||||
name: capakraken_pgdata
|
name: capakraken_pgdata
|
||||||
|
capakraken_node_modules:
|
||||||
|
name: capakraken_node_modules
|
||||||
|
capakraken_next:
|
||||||
|
name: capakraken_next
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for auth tRPC router — password reset flow.
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - requestPasswordReset: known email → token created, email sent
|
||||||
|
* - requestPasswordReset: unknown email → success (no token, no email)
|
||||||
|
* - requestPasswordReset: second request → old token deleted, new one created
|
||||||
|
* - resetPassword: valid token → passwordHash updated, usedAt set
|
||||||
|
* - resetPassword: expired token → BAD_REQUEST
|
||||||
|
* - resetPassword: used token → BAD_REQUEST
|
||||||
|
* - resetPassword: non-existent token → NOT_FOUND
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
vi.mock("../lib/email.js", () => ({ sendEmail: vi.fn().mockResolvedValue(true) }));
|
||||||
|
vi.mock("@node-rs/argon2", () => ({ hash: vi.fn().mockResolvedValue("$argon2id$newhash") }));
|
||||||
|
|
||||||
|
const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
const PAST = new Date(Date.now() - 1000);
|
||||||
|
|
||||||
|
function makeDb(overrides: {
|
||||||
|
user?: Partial<Record<string, unknown>>;
|
||||||
|
resetToken?: Partial<Record<string, unknown>>;
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "user_1", email: "user@example.com" }),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
...overrides.user,
|
||||||
|
},
|
||||||
|
passwordResetToken: {
|
||||||
|
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
...overrides.resetToken,
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(db = makeDb()) {
|
||||||
|
return { db, dbUser: null, session: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { authRouter } = await import("../router/auth.js");
|
||||||
|
const { sendEmail } = await import("../lib/email.js");
|
||||||
|
|
||||||
|
describe("auth.requestPasswordReset", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("creates a token and sends an email for a known address", async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const ctx = makeCtx(db);
|
||||||
|
|
||||||
|
const result = await authRouter.createCaller(ctx).requestPasswordReset({
|
||||||
|
email: "user@example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(db.passwordResetToken.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: { email: "user@example.com", usedAt: null },
|
||||||
|
});
|
||||||
|
expect(db.passwordResetToken.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ email: "user@example.com", token: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sendEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ to: "user@example.com", subject: expect.stringContaining("reset") }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns success silently for an unknown email (no token, no email sent)", async () => {
|
||||||
|
const db = makeDb({ user: { findUnique: vi.fn().mockResolvedValue(null) } });
|
||||||
|
const ctx = makeCtx(db);
|
||||||
|
|
||||||
|
const result = await authRouter.createCaller(ctx).requestPasswordReset({
|
||||||
|
email: "ghost@example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(db.passwordResetToken.create).not.toHaveBeenCalled();
|
||||||
|
expect(sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes existing unused tokens before creating a new one", async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const ctx = makeCtx(db);
|
||||||
|
|
||||||
|
await authRouter.createCaller(ctx).requestPasswordReset({ email: "user@example.com" });
|
||||||
|
|
||||||
|
// deleteMany called before create
|
||||||
|
const deleteManyOrder = vi.mocked(db.passwordResetToken.deleteMany).mock.invocationCallOrder[0]!;
|
||||||
|
const createOrder = vi.mocked(db.passwordResetToken.create).mock.invocationCallOrder[0]!;
|
||||||
|
expect(deleteManyOrder).toBeLessThan(createOrder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth.resetPassword", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("updates passwordHash and sets usedAt for a valid token", async () => {
|
||||||
|
const validRecord = {
|
||||||
|
token: "valid-token",
|
||||||
|
email: "user@example.com",
|
||||||
|
expiresAt: FUTURE,
|
||||||
|
usedAt: null,
|
||||||
|
};
|
||||||
|
const db = makeDb({
|
||||||
|
resetToken: { findUnique: vi.fn().mockResolvedValue(validRecord) },
|
||||||
|
});
|
||||||
|
const ctx = makeCtx(db);
|
||||||
|
|
||||||
|
const result = await authRouter.createCaller(ctx).resetPassword({
|
||||||
|
token: "valid-token",
|
||||||
|
password: "NewPassword1!",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
expect(db.user.update).toHaveBeenCalledWith({
|
||||||
|
where: { email: "user@example.com" },
|
||||||
|
data: { passwordHash: "$argon2id$newhash" },
|
||||||
|
});
|
||||||
|
expect(db.passwordResetToken.update).toHaveBeenCalledWith({
|
||||||
|
where: { token: "valid-token" },
|
||||||
|
data: { usedAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NOT_FOUND for an unknown token", async () => {
|
||||||
|
const db = makeDb({ resetToken: { findUnique: vi.fn().mockResolvedValue(null) } });
|
||||||
|
const ctx = makeCtx(db);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
authRouter.createCaller(ctx).resetPassword({ token: "bad-token", password: "Password1!" }),
|
||||||
|
).rejects.toThrow(TRPCError);
|
||||||
|
|
||||||
|
const err = await authRouter
|
||||||
|
.createCaller(ctx)
|
||||||
|
.resetPassword({ token: "bad-token", password: "Password1!" })
|
||||||
|
.catch((e: TRPCError) => e);
|
||||||
|
expect((err as TRPCError).code).toBe("NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws BAD_REQUEST for an already-used token", async () => {
|
||||||
|
const usedRecord = {
|
||||||
|
token: "used-token",
|
||||||
|
email: "user@example.com",
|
||||||
|
expiresAt: FUTURE,
|
||||||
|
usedAt: new Date(Date.now() - 5000),
|
||||||
|
};
|
||||||
|
const db = makeDb({ resetToken: { findUnique: vi.fn().mockResolvedValue(usedRecord) } });
|
||||||
|
const ctx = makeCtx(db);
|
||||||
|
|
||||||
|
const err = await authRouter
|
||||||
|
.createCaller(ctx)
|
||||||
|
.resetPassword({ token: "used-token", password: "Password1!" })
|
||||||
|
.catch((e: TRPCError) => e);
|
||||||
|
expect((err as TRPCError).code).toBe("BAD_REQUEST");
|
||||||
|
expect((err as TRPCError).message).toMatch(/already been used/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws BAD_REQUEST for an expired token", async () => {
|
||||||
|
const expiredRecord = {
|
||||||
|
token: "expired-token",
|
||||||
|
email: "user@example.com",
|
||||||
|
expiresAt: PAST,
|
||||||
|
usedAt: null,
|
||||||
|
};
|
||||||
|
const db = makeDb({ resetToken: { findUnique: vi.fn().mockResolvedValue(expiredRecord) } });
|
||||||
|
const ctx = makeCtx(db);
|
||||||
|
|
||||||
|
const err = await authRouter
|
||||||
|
.createCaller(ctx)
|
||||||
|
.resetPassword({ token: "expired-token", password: "Password1!" })
|
||||||
|
.catch((e: TRPCError) => e);
|
||||||
|
expect((err as TRPCError).code).toBe("BAD_REQUEST");
|
||||||
|
expect((err as TRPCError).message).toMatch(/expired/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
const { findUnique, sendMail, createTransport } = vi.hoisted(() => {
|
||||||
|
const sendMail = vi.fn().mockResolvedValue({ messageId: "test" });
|
||||||
|
return {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
sendMail,
|
||||||
|
createTransport: vi.fn(() => ({ sendMail })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@capakraken/db", () => ({
|
||||||
|
prisma: {
|
||||||
|
systemSettings: { findUnique },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("nodemailer", () => ({
|
||||||
|
default: { createTransport },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/logger.js", () => ({
|
||||||
|
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-import after mocks are set up
|
||||||
|
const { sendEmail } = await import("../lib/email.js");
|
||||||
|
|
||||||
|
describe("SMTP ENV overrides", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Default DB row — used as fallback
|
||||||
|
findUnique.mockResolvedValue({
|
||||||
|
smtpHost: "db-smtp.example.com",
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpUser: "db-user@example.com",
|
||||||
|
smtpPassword: "db-password",
|
||||||
|
smtpFrom: "db-from@example.com",
|
||||||
|
smtpTls: true,
|
||||||
|
});
|
||||||
|
sendMail.mockResolvedValue({ messageId: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SMTP_HOST env overrides DB smtpHost", async () => {
|
||||||
|
vi.stubEnv("SMTP_HOST", "env-smtp.example.com");
|
||||||
|
|
||||||
|
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ host: "env-smtp.example.com" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SMTP_PORT env overrides DB smtpPort (parsed as integer)", async () => {
|
||||||
|
vi.stubEnv("SMTP_PORT", "1025");
|
||||||
|
|
||||||
|
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ port: 1025 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SMTP_TLS=false sets secure: false", async () => {
|
||||||
|
vi.stubEnv("SMTP_TLS", "false");
|
||||||
|
|
||||||
|
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ secure: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SMTP_TLS=true sets secure: true", async () => {
|
||||||
|
vi.stubEnv("SMTP_TLS", "true");
|
||||||
|
|
||||||
|
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ secure: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no SMTP_USER → auth uses DB user; with SMTP_USER env → auth uses env user", async () => {
|
||||||
|
vi.stubEnv("SMTP_USER", "env-user@example.com");
|
||||||
|
|
||||||
|
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
auth: expect.objectContaining({ user: "env-user@example.com" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no SMTP_USER and no SMTP_PASSWORD → auth is undefined (Mailhog scenario)", async () => {
|
||||||
|
// Clear all user/password from both ENV and DB
|
||||||
|
vi.stubEnv("SMTP_HOST", "mailhog");
|
||||||
|
vi.stubEnv("SMTP_PORT", "1025");
|
||||||
|
vi.stubEnv("SMTP_TLS", "false");
|
||||||
|
// Explicitly no SMTP_USER / SMTP_PASSWORD
|
||||||
|
findUnique.mockResolvedValue({
|
||||||
|
smtpHost: null,
|
||||||
|
smtpPort: null,
|
||||||
|
smtpUser: null,
|
||||||
|
smtpPassword: null,
|
||||||
|
smtpFrom: "noreply@capakraken.app",
|
||||||
|
smtpTls: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ auth: undefined }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DB values are used when no ENV overrides are set", async () => {
|
||||||
|
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith({
|
||||||
|
host: "db-smtp.example.com",
|
||||||
|
port: 587,
|
||||||
|
secure: true,
|
||||||
|
auth: { user: "db-user@example.com", pass: "db-password" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENV overrides take priority over DB for all SMTP fields simultaneously", async () => {
|
||||||
|
vi.stubEnv("SMTP_HOST", "mailhog");
|
||||||
|
vi.stubEnv("SMTP_PORT", "1025");
|
||||||
|
vi.stubEnv("SMTP_USER", ""); // empty string = no override
|
||||||
|
vi.stubEnv("SMTP_TLS", "false");
|
||||||
|
vi.stubEnv("SMTP_PASSWORD", ""); // empty = no override
|
||||||
|
|
||||||
|
findUnique.mockResolvedValue({
|
||||||
|
smtpHost: "db-smtp.example.com",
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpUser: "db-user@example.com",
|
||||||
|
smtpPassword: "db-password",
|
||||||
|
smtpFrom: "from@example.com",
|
||||||
|
smtpTls: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmail({ to: "user@test.com", subject: "Test", text: "Hello" });
|
||||||
|
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
host: "mailhog",
|
||||||
|
port: 1025,
|
||||||
|
secure: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,9 +51,14 @@ async function getSmtpConfig() {
|
|||||||
await db.systemSettings.findUnique({ where: { id: "singleton" } }),
|
await db.systemSettings.findUnique({ where: { id: "singleton" } }),
|
||||||
);
|
);
|
||||||
if (!settings.smtpHost) return null;
|
if (!settings.smtpHost) return null;
|
||||||
|
const port = typeof settings.smtpPort === "number"
|
||||||
|
? settings.smtpPort
|
||||||
|
: settings.smtpPort !== null && settings.smtpPort !== undefined
|
||||||
|
? parseInt(String(settings.smtpPort), 10)
|
||||||
|
: 587;
|
||||||
return {
|
return {
|
||||||
host: settings.smtpHost,
|
host: settings.smtpHost,
|
||||||
port: settings.smtpPort ?? 587,
|
port,
|
||||||
secure: settings.smtpTls === false ? false : true,
|
secure: settings.smtpTls === false ? false : true,
|
||||||
auth:
|
auth:
|
||||||
settings.smtpUser && settings.smtpPassword
|
settings.smtpUser && settings.smtpPassword
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ type RuntimeAwareSystemSettings = {
|
|||||||
azureDalleApiKey?: string | null;
|
azureDalleApiKey?: string | null;
|
||||||
geminiApiKey?: string | null;
|
geminiApiKey?: string | null;
|
||||||
smtpPassword?: string | null;
|
smtpPassword?: string | null;
|
||||||
|
smtpHost?: string | null;
|
||||||
|
smtpPort?: number | null;
|
||||||
|
smtpUser?: string | null;
|
||||||
|
smtpFrom?: string | null;
|
||||||
|
smtpTls?: boolean | null;
|
||||||
anonymizationSeed?: string | null;
|
anonymizationSeed?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,10 +124,37 @@ export function getRuntimeSecretStatuses(
|
|||||||
) as Record<RuntimeSecretField, RuntimeSecretStatus>;
|
) as Record<RuntimeSecretField, RuntimeSecretStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve non-secret SMTP fields from ENV (take precedence over DB). */
|
||||||
|
function resolveSmtpNonSecretOverrides(settings: RuntimeAwareSystemSettings | null | undefined): {
|
||||||
|
smtpHost: string | null;
|
||||||
|
smtpPort: number | null;
|
||||||
|
smtpUser: string | null;
|
||||||
|
smtpFrom: string | null;
|
||||||
|
smtpTls: boolean | null;
|
||||||
|
} {
|
||||||
|
const envHost = readEnvOverride("SMTP_HOST");
|
||||||
|
const envPort = readEnvOverride("SMTP_PORT");
|
||||||
|
const envUser = readEnvOverride("SMTP_USER");
|
||||||
|
const envFrom = readEnvOverride("SMTP_FROM");
|
||||||
|
const envTlsRaw = process.env["SMTP_TLS"]?.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
smtpHost: envHost ?? settings?.smtpHost ?? null,
|
||||||
|
smtpPort: envPort !== null
|
||||||
|
? parseInt(envPort, 10)
|
||||||
|
: settings?.smtpPort ?? null,
|
||||||
|
smtpUser: envUser ?? settings?.smtpUser ?? null,
|
||||||
|
smtpFrom: envFrom ?? settings?.smtpFrom ?? null,
|
||||||
|
smtpTls: envTlsRaw !== undefined
|
||||||
|
? envTlsRaw !== "false"
|
||||||
|
: settings?.smtpTls ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveSystemSettingsRuntime<T extends RuntimeAwareSystemSettings>(
|
export function resolveSystemSettingsRuntime<T extends RuntimeAwareSystemSettings>(
|
||||||
settings: T | null | undefined,
|
settings: T | null | undefined,
|
||||||
): T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "anonymizationSeed">> {
|
): T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "smtpHost" | "smtpPort" | "smtpUser" | "smtpFrom" | "smtpTls" | "anonymizationSeed">> {
|
||||||
const resolved = { ...(settings ?? {}) } as T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "anonymizationSeed">>;
|
const resolved = { ...(settings ?? {}) } as T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "smtpHost" | "smtpPort" | "smtpUser" | "smtpFrom" | "smtpTls" | "anonymizationSeed">>;
|
||||||
|
|
||||||
resolved.azureOpenAiApiKey = resolveSecretEnvOverride("azureOpenAiApiKey", resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null;
|
resolved.azureOpenAiApiKey = resolveSecretEnvOverride("azureOpenAiApiKey", resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null;
|
||||||
resolved.azureDalleApiKey = resolveSecretEnvOverride("azureDalleApiKey", resolved.aiProvider) ?? settings?.azureDalleApiKey ?? null;
|
resolved.azureDalleApiKey = resolveSecretEnvOverride("azureDalleApiKey", resolved.aiProvider) ?? settings?.azureDalleApiKey ?? null;
|
||||||
@@ -130,5 +162,12 @@ export function resolveSystemSettingsRuntime<T extends RuntimeAwareSystemSetting
|
|||||||
resolved.smtpPassword = resolveSecretEnvOverride("smtpPassword", resolved.aiProvider) ?? settings?.smtpPassword ?? null;
|
resolved.smtpPassword = resolveSecretEnvOverride("smtpPassword", resolved.aiProvider) ?? settings?.smtpPassword ?? null;
|
||||||
resolved.anonymizationSeed = resolveSecretEnvOverride("anonymizationSeed", resolved.aiProvider) ?? settings?.anonymizationSeed ?? null;
|
resolved.anonymizationSeed = resolveSecretEnvOverride("anonymizationSeed", resolved.aiProvider) ?? settings?.anonymizationSeed ?? null;
|
||||||
|
|
||||||
|
const smtpOverrides = resolveSmtpNonSecretOverrides(settings);
|
||||||
|
resolved.smtpHost = smtpOverrides.smtpHost;
|
||||||
|
resolved.smtpPort = smtpOverrides.smtpPort;
|
||||||
|
resolved.smtpUser = smtpOverrides.smtpUser;
|
||||||
|
resolved.smtpFrom = smtpOverrides.smtpFrom;
|
||||||
|
resolved.smtpTls = smtpOverrides.smtpTls;
|
||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createTRPCRouter, publicProcedure } from "../trpc.js";
|
||||||
|
import { sendEmail } from "../lib/email.js";
|
||||||
|
|
||||||
|
const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
function resetEmailHtml(resetUrl: string): string {
|
||||||
|
return `
|
||||||
|
<p>You requested a password reset for your CapaKraken account.</p>
|
||||||
|
<p>Click the link below to set a new password:</p>
|
||||||
|
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
||||||
|
<p>This link expires in 1 hour and can only be used once.</p>
|
||||||
|
<p>If you did not request this, you can ignore this email.</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authRouter = createTRPCRouter({
|
||||||
|
/**
|
||||||
|
* Request a password reset email.
|
||||||
|
* Always returns { success: true } — even if the email is not registered —
|
||||||
|
* to prevent user enumeration.
|
||||||
|
*/
|
||||||
|
requestPasswordReset: publicProcedure
|
||||||
|
.input(z.object({ email: z.string().email() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = await ctx.db.user.findUnique({
|
||||||
|
where: { email: input.email },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Timing-safe: don't reveal whether the email exists
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete any existing (unused) reset tokens for this email
|
||||||
|
await ctx.db.passwordResetToken.deleteMany({
|
||||||
|
where: { email: input.email, usedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
const expiresAt = new Date(Date.now() + RESET_TTL_MS);
|
||||||
|
|
||||||
|
await ctx.db.passwordResetToken.create({
|
||||||
|
data: { email: input.email, token, expiresAt },
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUrl = process.env["NEXTAUTH_URL"] ?? "http://localhost:3100";
|
||||||
|
const resetUrl = `${baseUrl}/auth/reset-password/${token}`;
|
||||||
|
|
||||||
|
void sendEmail({
|
||||||
|
to: input.email,
|
||||||
|
subject: "CapaKraken — reset your password",
|
||||||
|
text: `You requested a password reset.\n\nReset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`,
|
||||||
|
html: resetEmailHtml(resetUrl),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Validate a reset token and set a new password. */
|
||||||
|
resetPassword: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters."),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const record = await ctx.db.passwordResetToken.findUnique({
|
||||||
|
where: { token: input.token },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Reset link not found." });
|
||||||
|
}
|
||||||
|
if (record.usedAt) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has already been used." });
|
||||||
|
}
|
||||||
|
if (record.expiresAt < new Date()) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hash } = await import("@node-rs/argon2");
|
||||||
|
const passwordHash = await hash(input.password);
|
||||||
|
|
||||||
|
await ctx.db.user.update({
|
||||||
|
where: { email: record.email },
|
||||||
|
data: { passwordHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.passwordResetToken.update({
|
||||||
|
where: { token: input.token },
|
||||||
|
data: { usedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -35,8 +35,12 @@ import { userRouter } from "./user.js";
|
|||||||
import { utilizationCategoryRouter } from "./utilization-category.js";
|
import { utilizationCategoryRouter } from "./utilization-category.js";
|
||||||
import { vacationRouter } from "./vacation.js";
|
import { vacationRouter } from "./vacation.js";
|
||||||
import { webhookRouter } from "./webhook.js";
|
import { webhookRouter } from "./webhook.js";
|
||||||
|
import { inviteRouter } from "./invite.js";
|
||||||
|
import { authRouter } from "./auth.js";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
|
auth: authRouter,
|
||||||
|
invite: inviteRouter,
|
||||||
assistant: assistantRouter,
|
assistant: assistantRouter,
|
||||||
auditLog: auditLogRouter,
|
auditLog: auditLogRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
|
|||||||
@@ -1688,3 +1688,33 @@ model Webhook {
|
|||||||
|
|
||||||
@@map("webhooks")
|
@@map("webhooks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Invite Token ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model InviteToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String
|
||||||
|
role SystemRole @default(USER)
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
usedAt DateTime?
|
||||||
|
createdById String // userId of the inviting admin
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([token])
|
||||||
|
@@index([email])
|
||||||
|
@@map("invite_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([token])
|
||||||
|
@@index([email])
|
||||||
|
@@map("password_reset_tokens")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user