diff --git a/apps/web/e2e/dev-system/helpers.ts b/apps/web/e2e/dev-system/helpers.ts index ed675e1..c4f28c9 100644 --- a/apps/web/e2e/dev-system/helpers.ts +++ b/apps/web/e2e/dev-system/helpers.ts @@ -39,6 +39,81 @@ export async function signOut(page: Page) { 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 { + 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. * Returns a list of intercepted tRPC paths that were called. diff --git a/apps/web/e2e/dev-system/invite-flow.spec.ts b/apps/web/e2e/dev-system/invite-flow.spec.ts new file mode 100644 index 0000000..7cd7d2e --- /dev/null +++ b/apps/web/e2e/dev-system/invite-flow.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/e2e/dev-system/password-reset.spec.ts b/apps/web/e2e/dev-system/password-reset.spec.ts new file mode 100644 index 0000000..61ecf8d --- /dev/null +++ b/apps/web/e2e/dev-system/password-reset.spec.ts @@ -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 }); + }); +}); diff --git a/apps/web/src/app/auth/forgot-password/page.tsx b/apps/web/src/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..aceefd4 --- /dev/null +++ b/apps/web/src/app/auth/forgot-password/page.tsx @@ -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 ( +
+
+ {submitted ? ( +
+
📬
+

+ Check your email +

+

+ If {email} is registered, you will receive a + password reset link within a few minutes. +

+ + Back to sign in + +
+ ) : ( + <> +
+

+ Reset your password +

+

+ Enter your email address and we will send you a reset link. +

+
+ +
+
+ + 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" + /> +
+ + + +
+ + Back to sign in + +
+
+ + )} +
+
+ ); +} diff --git a/apps/web/src/app/auth/reset-password/[token]/page.tsx b/apps/web/src/app/auth/reset-password/[token]/page.tsx new file mode 100644 index 0000000..c7589e2 --- /dev/null +++ b/apps/web/src/app/auth/reset-password/[token]/page.tsx @@ -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(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 ( +
+
+
+

+ Password updated +

+

+ Your password has been changed successfully. +

+ +
+
+ ); + } + + return ( +
+
+
+

+ Set a new password +

+

+ Choose a new password for your account. +

+
+ +
+ {formError && ( +
+ {formError} +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx index 4d136e7..a481cbd 100644 --- a/apps/web/src/app/auth/signin/page.tsx +++ b/apps/web/src/app/auth/signin/page.tsx @@ -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() {
- +
+ + + Forgot password? + +
({ 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>; + resetToken?: Partial>; +} = {}) { + 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/); + }); +}); diff --git a/packages/api/src/__tests__/email-smtp-env-override.test.ts b/packages/api/src/__tests__/email-smtp-env-override.test.ts new file mode 100644 index 0000000..37d39da --- /dev/null +++ b/packages/api/src/__tests__/email-smtp-env-override.test.ts @@ -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, + }), + ); + }); +}); diff --git a/packages/api/src/lib/email.ts b/packages/api/src/lib/email.ts index 77134e2..86d8cf3 100644 --- a/packages/api/src/lib/email.ts +++ b/packages/api/src/lib/email.ts @@ -51,9 +51,14 @@ async function getSmtpConfig() { await db.systemSettings.findUnique({ where: { id: "singleton" } }), ); 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 { host: settings.smtpHost, - port: settings.smtpPort ?? 587, + port, secure: settings.smtpTls === false ? false : true, auth: settings.smtpUser && settings.smtpPassword diff --git a/packages/api/src/lib/system-settings-runtime.ts b/packages/api/src/lib/system-settings-runtime.ts index 1be1626..4171199 100644 --- a/packages/api/src/lib/system-settings-runtime.ts +++ b/packages/api/src/lib/system-settings-runtime.ts @@ -4,6 +4,11 @@ type RuntimeAwareSystemSettings = { azureDalleApiKey?: string | null; geminiApiKey?: string | null; smtpPassword?: string | null; + smtpHost?: string | null; + smtpPort?: number | null; + smtpUser?: string | null; + smtpFrom?: string | null; + smtpTls?: boolean | null; anonymizationSeed?: string | null; }; @@ -119,10 +124,37 @@ export function getRuntimeSecretStatuses( ) as Record; } +/** 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( settings: T | null | undefined, -): T & Required> { - const resolved = { ...(settings ?? {}) } as T & Required>; +): T & Required> { + const resolved = { ...(settings ?? {}) } as T & Required>; resolved.azureOpenAiApiKey = resolveSecretEnvOverride("azureOpenAiApiKey", resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? null; resolved.azureDalleApiKey = resolveSecretEnvOverride("azureDalleApiKey", resolved.aiProvider) ?? settings?.azureDalleApiKey ?? null; @@ -130,5 +162,12 @@ export function resolveSystemSettingsRuntimeYou requested a password reset for your CapaKraken account.

+

Click the link below to set a new password:

+

${resetUrl}

+

This link expires in 1 hour and can only be used once.

+

If you did not request this, you can ignore this email.

+ `; +} + +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 }; + }), +}); diff --git a/packages/api/src/router/index.ts b/packages/api/src/router/index.ts index 3add205..0169d1a 100644 --- a/packages/api/src/router/index.ts +++ b/packages/api/src/router/index.ts @@ -35,8 +35,12 @@ import { userRouter } from "./user.js"; import { utilizationCategoryRouter } from "./utilization-category.js"; import { vacationRouter } from "./vacation.js"; import { webhookRouter } from "./webhook.js"; +import { inviteRouter } from "./invite.js"; +import { authRouter } from "./auth.js"; export const appRouter = createTRPCRouter({ + auth: authRouter, + invite: inviteRouter, assistant: assistantRouter, auditLog: auditLogRouter, dashboard: dashboardRouter, diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 84280a4..59637ec 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1688,3 +1688,33 @@ model Webhook { @@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") +}