mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 18:33:01 +00:00
verification emails and full email setup
This commit is contained in:
@@ -39,6 +39,7 @@ export default async function login(req: BunRequest) {
|
||||
username: user.username,
|
||||
avatarURL: user.avatarURL,
|
||||
iconPreference: user.iconPreference,
|
||||
emailVerified: user.emailVerified,
|
||||
},
|
||||
csrfToken: session.csrfToken,
|
||||
}),
|
||||
|
||||
@@ -13,5 +13,6 @@ export default async function me(req: AuthedRequest) {
|
||||
return Response.json({
|
||||
user: safeUser as Omit<UserRecord, "passwordHash">,
|
||||
csrfToken: req.csrfToken,
|
||||
emailVerified: user.emailVerified,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { RegisterRequestSchema } from "@sprint/shared";
|
||||
import type { BunRequest } from "bun";
|
||||
import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils";
|
||||
import { createSession, createUser, getUserByUsername } from "../../db/queries";
|
||||
import { createSession, createUser, createVerificationCode, getUserByUsername } from "../../db/queries";
|
||||
import { getUserByEmail } from "../../db/queries/users";
|
||||
import { VerificationCode } from "../../emails";
|
||||
import { sendEmailWithRetry } from "../../lib/email/service";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function register(req: BunRequest) {
|
||||
@@ -36,6 +38,19 @@ export default async function register(req: BunRequest) {
|
||||
return errorResponse("failed to create session", "SESSION_ERROR", 500);
|
||||
}
|
||||
|
||||
const verification = await createVerificationCode(user.id);
|
||||
|
||||
try {
|
||||
await sendEmailWithRetry({
|
||||
to: user.email,
|
||||
subject: "Verify your Sprint account",
|
||||
template: VerificationCode({ code: verification.code }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send verification email:", error);
|
||||
// don't fail registration if email fails - user can resend
|
||||
}
|
||||
|
||||
const token = generateToken(session.id, user.id);
|
||||
|
||||
return new Response(
|
||||
@@ -46,6 +61,7 @@ export default async function register(req: BunRequest) {
|
||||
username: user.username,
|
||||
avatarURL: user.avatarURL,
|
||||
iconPreference: user.iconPreference,
|
||||
emailVerified: user.emailVerified,
|
||||
},
|
||||
csrfToken: session.csrfToken,
|
||||
}),
|
||||
|
||||
69
packages/backend/src/routes/auth/resend-verification.ts
Normal file
69
packages/backend/src/routes/auth/resend-verification.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { BunRequest } from "bun";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { createVerificationCode } from "../../db/queries";
|
||||
import { getUserById } from "../../db/queries/users";
|
||||
import { VerificationCode } from "../../emails";
|
||||
import { sendEmailWithRetry } from "../../lib/email/service";
|
||||
import { errorResponse } from "../../validation";
|
||||
|
||||
const resendAttempts = new Map<number, number[]>();
|
||||
|
||||
const MAX_RESENDS_PER_HOUR = 3;
|
||||
const HOUR_IN_MS = 60 * 60 * 1000;
|
||||
|
||||
function canResend(userId: number): boolean {
|
||||
const now = Date.now();
|
||||
const attempts = resendAttempts.get(userId) || [];
|
||||
|
||||
const recentAttempts = attempts.filter((time) => now - time < HOUR_IN_MS);
|
||||
|
||||
if (recentAttempts.length >= MAX_RESENDS_PER_HOUR) {
|
||||
return false;
|
||||
}
|
||||
|
||||
recentAttempts.push(now);
|
||||
resendAttempts.set(userId, recentAttempts);
|
||||
return true;
|
||||
}
|
||||
|
||||
export default async function resendVerification(req: BunRequest | AuthedRequest) {
|
||||
if (req.method !== "POST") {
|
||||
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
|
||||
}
|
||||
|
||||
const authedReq = req as AuthedRequest;
|
||||
if (!authedReq.userId) {
|
||||
return errorResponse("unauthorized", "UNAUTHORIZED", 401);
|
||||
}
|
||||
|
||||
if (!canResend(authedReq.userId)) {
|
||||
return errorResponse("too many resend attempts. please try again later", "RATE_LIMITED", 429);
|
||||
}
|
||||
|
||||
const user = await getUserById(authedReq.userId);
|
||||
if (!user) {
|
||||
return errorResponse("user not found", "USER_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return errorResponse("email already verified", "ALREADY_VERIFIED", 400);
|
||||
}
|
||||
|
||||
const verification = await createVerificationCode(user.id);
|
||||
|
||||
try {
|
||||
await sendEmailWithRetry({
|
||||
to: user.email,
|
||||
subject: "Verify your Sprint account",
|
||||
template: VerificationCode({ code: verification.code }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send verification email:", error);
|
||||
return errorResponse("failed to send verification email", "EMAIL_SEND_FAILED", 500);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
32
packages/backend/src/routes/auth/verify-email.ts
Normal file
32
packages/backend/src/routes/auth/verify-email.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { VerifyEmailRequestSchema } from "@sprint/shared";
|
||||
import type { BunRequest } from "bun";
|
||||
import type { AuthedRequest } from "../../auth/middleware";
|
||||
import { verifyCode } from "../../db/queries";
|
||||
import { errorResponse, parseJsonBody } from "../../validation";
|
||||
|
||||
export default async function verifyEmail(req: BunRequest | AuthedRequest) {
|
||||
if (req.method !== "POST") {
|
||||
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
|
||||
}
|
||||
|
||||
const authedReq = req as AuthedRequest;
|
||||
if (!authedReq.userId) {
|
||||
return errorResponse("unauthorized", "UNAUTHORIZED", 401);
|
||||
}
|
||||
|
||||
const parsed = await parseJsonBody(req, VerifyEmailRequestSchema);
|
||||
if ("error" in parsed) return parsed.error;
|
||||
|
||||
const { code } = parsed.data;
|
||||
|
||||
const result = await verifyCode(authedReq.userId, code);
|
||||
|
||||
if (!result.success) {
|
||||
return errorResponse(result.error || "verification failed", "VERIFICATION_FAILED", 400);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import authLogin from "./auth/login";
|
||||
import authLogout from "./auth/logout";
|
||||
import authMe from "./auth/me";
|
||||
import authRegister from "./auth/register";
|
||||
import authResendVerification from "./auth/resend-verification";
|
||||
import authVerifyEmail from "./auth/verify-email";
|
||||
import issueById from "./issue/by-id";
|
||||
import issueCreate from "./issue/create";
|
||||
import issueDelete from "./issue/delete";
|
||||
@@ -57,6 +59,8 @@ export const routes = {
|
||||
authLogin,
|
||||
authLogout,
|
||||
authMe,
|
||||
authVerifyEmail,
|
||||
authResendVerification,
|
||||
|
||||
userByUsername,
|
||||
userUpdate,
|
||||
|
||||
Reference in New Issue
Block a user