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:
@@ -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 { 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,
|
||||
|
||||
Reference in New Issue
Block a user