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,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" } }),
|
||||
);
|
||||
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
|
||||
|
||||
@@ -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<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>(
|
||||
settings: T | null | undefined,
|
||||
): T & Required<Pick<RuntimeAwareSystemSettings, "azureOpenAiApiKey" | "azureDalleApiKey" | "geminiApiKey" | "smtpPassword" | "anonymizationSeed">> {
|
||||
const resolved = { ...(settings ?? {}) } as 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" | "smtpHost" | "smtpPort" | "smtpUser" | "smtpFrom" | "smtpTls" | "anonymizationSeed">>;
|
||||
|
||||
resolved.azureOpenAiApiKey = resolveSecretEnvOverride("azureOpenAiApiKey", resolved.aiProvider) ?? settings?.azureOpenAiApiKey ?? 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.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;
|
||||
}
|
||||
|
||||
@@ -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