verification emails and full email setup

This commit is contained in:
2026-01-29 00:43:24 +00:00
parent 14520618d1
commit d943561e89
31 changed files with 2190 additions and 53 deletions

View File

@@ -13,4 +13,7 @@ S3_SECRET_ACCESS_KEY=your_secret_access_key
S3_BUCKET_NAME=issue
STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
STRIPE_SECRET_KEY=your_stripe_secret_key
STRIPE_SECRET_KEY=your_stripe_secret_key
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
EMAIL_FROM=Sprint <support@sprint.ob248.com>

View File

@@ -0,0 +1,28 @@
CREATE TABLE "EmailJob" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "EmailJob_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"userId" integer NOT NULL,
"type" varchar(64) NOT NULL,
"scheduledFor" timestamp NOT NULL,
"sentAt" timestamp,
"failedAt" timestamp,
"errorMessage" text,
"metadata" json,
"createdAt" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "EmailVerification" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "EmailVerification_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"userId" integer NOT NULL,
"code" varchar(6) NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"maxAttempts" integer DEFAULT 5 NOT NULL,
"expiresAt" timestamp NOT NULL,
"verifiedAt" timestamp,
"createdAt" timestamp DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "email" SET DATA TYPE varchar(256);--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "emailVerified" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "emailVerifiedAt" timestamp;--> statement-breakpoint
ALTER TABLE "EmailJob" ADD CONSTRAINT "EmailJob_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "EmailVerification" ADD CONSTRAINT "EmailVerification_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -197,6 +197,13 @@
"when": 1769635016079,
"tag": "0027_volatile_otto_octavius",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1769643481882,
"tag": "0028_quick_supernaut",
"breakpoints": true
}
]
}

View File

@@ -20,6 +20,8 @@
"@types/bun": "latest",
"@types/jsonwebtoken": "^9.0.10",
"@types/pg": "^8.15.6",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.8",
"tsx": "^4.21.0"
},
@@ -27,12 +29,17 @@
"typescript": "^5"
},
"dependencies": {
"@react-email/components": "^1.0.6",
"@react-email/render": "^2.0.4",
"@sprint/shared": "workspace:*",
"bcrypt": "^6.0.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.0",
"jsonwebtoken": "^9.0.3",
"pg": "^8.16.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"resend": "^6.9.1",
"sharp": "^0.34.5",
"stripe": "^20.2.0",
"zod": "^3.23.8"

View File

@@ -0,0 +1,121 @@
import { EmailVerification, type EmailVerificationRecord, User } from "@sprint/shared";
import { eq, lt, sql } from "drizzle-orm";
import { db } from "../client";
const CODE_EXPIRY_MINUTES = 15;
const MAX_ATTEMPTS = 5;
export function generateVerificationCode(): string {
const bytes = new Uint8Array(4);
crypto.getRandomValues(bytes);
// 6 digit
const code = ((bytes[0] ?? 0) * 256 * 256 + (bytes[1] ?? 0) * 256 + (bytes[2] ?? 0)) % 1000000;
return code.toString().padStart(6, "0");
}
export async function createVerificationCode(userId: number): Promise<EmailVerificationRecord> {
const code = generateVerificationCode();
const expiresAt = new Date(Date.now() + CODE_EXPIRY_MINUTES * 60 * 1000);
// delete existing codes for the user
await db.delete(EmailVerification).where(eq(EmailVerification.userId, userId));
const [verification] = await db
.insert(EmailVerification)
.values({
userId,
code,
expiresAt,
attempts: 0,
maxAttempts: MAX_ATTEMPTS,
})
.returning();
if (!verification) {
throw new Error("Failed to create verification code");
}
return verification;
}
export async function getVerificationByUserId(userId: number): Promise<EmailVerificationRecord | undefined> {
const [verification] = await db
.select()
.from(EmailVerification)
.where(eq(EmailVerification.userId, userId));
return verification;
}
export async function incrementAttempts(id: number): Promise<void> {
await db
.update(EmailVerification)
.set({
attempts: sql`CASE WHEN ${EmailVerification.attempts} IS NULL THEN 1 ELSE ${EmailVerification.attempts} + 1 END`,
})
.where(eq(EmailVerification.id, id));
}
export async function markAsVerified(id: number): Promise<void> {
await db.update(EmailVerification).set({ verifiedAt: new Date() }).where(eq(EmailVerification.id, id));
}
export async function deleteVerification(id: number): Promise<void> {
await db.delete(EmailVerification).where(eq(EmailVerification.id, id));
}
export async function deleteUserVerifications(userId: number): Promise<void> {
await db.delete(EmailVerification).where(eq(EmailVerification.userId, userId));
}
export async function cleanupExpiredVerifications(): Promise<number> {
const result = await db.delete(EmailVerification).where(lt(EmailVerification.expiresAt, new Date()));
return result.rowCount ?? 0;
}
export async function verifyCode(
userId: number,
code: string,
): Promise<{ success: boolean; error?: string }> {
const verification = await getVerificationByUserId(userId);
if (!verification) {
return { success: false, error: "No verification code found" };
}
if (verification.verifiedAt) {
return { success: false, error: "Email already verified" };
}
if (new Date() > verification.expiresAt) {
await deleteVerification(verification.id);
return { success: false, error: "Verification code expired" };
}
if (verification.attempts >= verification.maxAttempts) {
await deleteVerification(verification.id);
return { success: false, error: "Too many attempts. Please request a new code." };
}
if (verification.code !== code) {
await db
.update(EmailVerification)
.set({ attempts: verification.attempts + 1 })
.where(eq(EmailVerification.id, verification.id));
const remainingAttempts = verification.maxAttempts - (verification.attempts + 1);
return {
success: false,
error: `Invalid code. ${remainingAttempts} attempts remaining.`,
};
}
await db
.update(User)
.set({ emailVerified: true, emailVerifiedAt: new Date() })
.where(eq(User.id, userId));
await deleteVerification(verification.id);
return { success: true };
}

View File

@@ -1,3 +1,4 @@
export * from "./email-verification";
export * from "./issue-comments";
export * from "./issues";
export * from "./organisations";

View File

@@ -0,0 +1 @@
export { VerificationCode } from "./templates/VerificationCode";

View File

@@ -0,0 +1,3 @@
export function VerificationCode({ code }: { code: string }) {
return <body>{code}</body>;
}

View File

@@ -41,6 +41,8 @@ const main = async () => {
"/auth/login": withGlobal(routes.authLogin),
"/auth/logout": withGlobalAuthed(withAuth(withCSRF(routes.authLogout))),
"/auth/me": withGlobalAuthed(withAuth(routes.authMe)),
"/auth/verify-email": withGlobalAuthed(withAuth(withCSRF(routes.authVerifyEmail))),
"/auth/resend-verification": withGlobalAuthed(withAuth(withCSRF(routes.authResendVerification))),
"/user/by-username": withGlobalAuthed(withAuth(routes.userByUsername)),
"/user/update": withGlobalAuthed(withAuth(withCSRF(routes.userUpdate))),

View File

@@ -0,0 +1,54 @@
import { render } from "@react-email/render";
import type React from "react";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const FROM_EMAIL = process.env.EMAIL_FROM || "Sprint <noreply@sprint.app>";
export interface SendEmailOptions {
to: string;
subject: string;
template: React.ReactElement;
from?: string;
}
export async function sendEmail({ to, subject, template, from }: SendEmailOptions) {
const html = await render(template);
const { data, error } = await resend.emails.send({
from: from || FROM_EMAIL,
to,
subject,
html,
});
if (error) {
console.error("Failed to send email:", error);
throw new Error(`Email send failed: ${error.message}`);
}
return data;
}
export async function sendEmailWithRetry(
options: SendEmailOptions,
maxRetries = 3,
): Promise<ReturnType<typeof sendEmail>> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await sendEmail(options);
} catch (error) {
lastError = error as Error;
console.warn(`Email send attempt ${attempt} failed:`, error);
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** (attempt - 1)));
}
}
}
throw lastError || new Error("Email send failed after all retries");
}

View File

@@ -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,
}),

View File

@@ -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,
});
}

View File

@@ -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,
}),

View 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" },
});
}

View 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" },
});
}

View File

@@ -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,

View File

@@ -11,7 +11,6 @@
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@ts-rest/core": "^3.52.1",
"@nsmr/pixelart-react": "^2.0.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -26,33 +25,35 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sprint/shared": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-query-devtools": "^5.91.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-opener": "^2.5.3",
"@ts-rest/core": "^3.52.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react": "19.2.4",
"react-colorful": "^5.6.1",
"react-day-picker": "^9.13.0",
"react-dom": "^19.1.0",
"react-resizable-panels": "^4.0.15",
"react-router-dom": "^7.10.1",
"react-dom": "19.2.4",
"react-resizable-panels": "^4.5.3",
"react-router-dom": "^7.13.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/node": "^25.0.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"@tauri-apps/cli": "^2.9.6",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"vite": "^7.0.4"
"vite": "^7.3.1"
}
}

View File

@@ -2,7 +2,6 @@
import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import Avatar from "@/components/avatar";
import { useSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
@@ -26,9 +25,7 @@ export default function LogInForm({
showWarning: boolean;
setShowWarning: (value: boolean) => void;
}) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setUser } = useSession();
const { setUser, setEmailVerified } = useSession();
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
@@ -59,8 +56,7 @@ export default function LogInForm({
const data = await res.json();
setCsrfToken(data.csrfToken);
setUser(data.user);
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
setEmailVerified(data.user.emailVerified);
}
// unauthorized
else if (res.status === 401) {
@@ -98,8 +94,7 @@ export default function LogInForm({
const data = await res.json();
setCsrfToken(data.csrfToken);
setUser(data.user);
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
setEmailVerified(data.user.emailVerified);
}
// bad request (probably a bad user input)
else if (res.status === 400) {

View File

@@ -15,21 +15,21 @@ interface LoginModalProps {
export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }: LoginModalProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user, isLoading } = useSession();
const { user, isLoading, emailVerified } = useSession();
const [hasRedirected, setHasRedirected] = useState(false);
const [showWarning, setShowWarning] = useState(() => {
return localStorage.getItem("hide-under-construction") !== "true";
});
useEffect(() => {
if (open && !isLoading && user && !hasRedirected) {
if (open && !isLoading && user && emailVerified && !hasRedirected) {
setHasRedirected(true);
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
onSuccess?.();
onOpenChange(false);
}
}, [open, user, isLoading, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]);
}, [open, user, isLoading, emailVerified, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]);
useEffect(() => {
if (!open) {

View File

@@ -3,12 +3,16 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
import Loading from "@/components/loading";
import { LoginModal } from "@/components/login-modal";
import { VerificationModal } from "@/components/verification-modal";
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
interface SessionContextValue {
user: UserResponse | null;
setUser: (user: UserResponse) => void;
isLoading: boolean;
emailVerified: boolean;
setEmailVerified: (verified: boolean) => void;
refreshUser: () => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
@@ -39,6 +43,7 @@ export function useAuthenticatedSession(): { user: UserResponse; setUser: (user:
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUserState] = useState<UserResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [emailVerified, setEmailVerified] = useState(true);
const fetched = useRef(false);
const setUser = useCallback((user: UserResponse) => {
@@ -46,6 +51,19 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem("user", JSON.stringify(user));
}, []);
const refreshUser = useCallback(async () => {
const res = await fetch(`${getServerURL()}/auth/me`, {
credentials: "include",
});
if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`);
}
const data = (await res.json()) as { user: UserResponse; csrfToken: string; emailVerified: boolean };
setUser(data.user);
setCsrfToken(data.csrfToken);
setEmailVerified(data.emailVerified);
}, [setUser]);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
@@ -57,9 +75,10 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`);
}
const data = (await res.json()) as { user: UserResponse; csrfToken: string };
const data = (await res.json()) as { user: UserResponse; csrfToken: string; emailVerified: boolean };
setUser(data.user);
setCsrfToken(data.csrfToken);
setEmailVerified(data.emailVerified);
})
.catch(() => {
setUserState(null);
@@ -70,11 +89,17 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
});
}, [setUser]);
return <SessionContext.Provider value={{ user, setUser, isLoading }}>{children}</SessionContext.Provider>;
return (
<SessionContext.Provider
value={{ user, setUser, isLoading, emailVerified, setEmailVerified, refreshUser }}
>
{children}
</SessionContext.Provider>
);
}
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useSession();
const { user, isLoading, emailVerified } = useSession();
const [loginModalOpen, setLoginModalOpen] = useState(false);
useEffect(() => {
@@ -93,5 +118,9 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
return <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} dismissible={false} />;
}
if (user && !emailVerified) {
return <VerificationModal open={true} onOpenChange={() => {}} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,69 @@
/** biome-ignore-all lint/a11y/useFocusableInteractive: <> */
/** biome-ignore-all lint/a11y/useAriaPropsForRole: <> */
/** biome-ignore-all lint/a11y/useSemanticElements: <> */
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />;
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import { toast } from "sonner";
import Avatar from "@/components/avatar";
import { useAuthenticatedSession } from "@/components/session-provider";
import { useSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
import { Label } from "@/components/ui/label";
@@ -56,7 +56,7 @@ export function UploadAvatar({
skipOrgCheck?: boolean;
className?: string;
}) {
const { user } = useAuthenticatedSession();
const { user } = useSession();
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -68,7 +68,7 @@ export function UploadAvatar({
if (!file) return;
// check for animated GIF for free users
if (user.plan !== "pro" && file.type === "image/gif") {
if (user?.plan !== "pro" && file.type === "image/gif") {
const isAnimated = await isAnimatedGIF(file);
if (isAnimated) {
setError("Animated avatars are only available on Pro. Upgrade to upload animated avatars.");

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { useSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { useResendVerification, useVerifyEmail } from "@/lib/query/hooks";
interface VerificationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VerificationModal({ open, onOpenChange }: VerificationModalProps) {
const { refreshUser, setEmailVerified } = useSession();
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [resendSuccess, setResendSuccess] = useState(false);
const verifyMutation = useVerifyEmail();
const resendMutation = useResendVerification();
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setResendSuccess(false);
try {
await verifyMutation.mutateAsync({ code: code.trim() });
setEmailVerified(true);
onOpenChange(false);
await refreshUser();
} catch (err) {
setError(err instanceof Error ? err.message : "Verification failed");
}
};
const handleResend = async () => {
setError(null);
setResendSuccess(false);
try {
await resendMutation.mutateAsync();
setResendSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to resend code");
}
};
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Verify your email</DialogTitle>
<DialogDescription>
We've sent a 6-digit verification code to your email. Enter it below to complete your
registration.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleVerify} className="space-y-4">
<div className="space-y-2">
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={code}
onChange={setCode}
disabled={verifyMutation.isPending}
autoFocus
className="gap-2"
>
<InputOTPGroup className="gap-2">
<InputOTPSlot index={0} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={1} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={2} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={3} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={4} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={5} className="w-14 h-16 text-2xl" />
</InputOTPGroup>
</InputOTP>
</div>
{error && <p className="text-sm text-destructive text-center">{error}</p>}
{resendSuccess && <p className="text-sm text-green-600 text-center">Verification code sent!</p>}
</div>
<div className="flex flex-col gap-2">
<Button type="submit" disabled={code.length !== 6 || verifyMutation.isPending} className="w-full">
{verifyMutation.isPending ? "Verifying..." : "Verify email"}
</Button>
<Button
type="button"
variant="ghost"
onClick={handleResend}
disabled={resendMutation.isPending}
className="w-full"
>
{resendMutation.isPending ? "Sending..." : "Resend code"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -7,3 +7,4 @@ export * from "@/lib/query/hooks/sprints";
export * from "@/lib/query/hooks/subscriptions";
export * from "@/lib/query/hooks/timers";
export * from "@/lib/query/hooks/users";
export * from "@/lib/query/hooks/verification";

View File

@@ -0,0 +1,22 @@
import { useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/server";
export function useVerifyEmail() {
return useMutation<void, Error, { code: string }>({
mutationKey: ["verification", "verify"],
mutationFn: async ({ code }) => {
const { error } = await apiClient.authVerifyEmail({ body: { code } });
if (error) throw new Error(error);
},
});
}
export function useResendVerification() {
return useMutation<void, Error>({
mutationKey: ["verification", "resend"],
mutationFn: async () => {
const { error } = await apiClient.authResendVerification({ body: {} });
if (error) throw new Error(error);
},
});
}

View File

@@ -60,12 +60,21 @@ export const AuthResponseSchema = z.object({
username: z.string(),
avatarURL: z.string().nullable(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
emailVerified: z.boolean(),
}),
csrfToken: z.string(),
});
export type AuthResponse = z.infer<typeof AuthResponseSchema>;
// email verification schemas
export const VerifyEmailRequestSchema = z.object({
code: z.string().length(6, "Verification code must be 6 digits"),
});
export type VerifyEmailRequest = z.infer<typeof VerifyEmailRequestSchema>;
// issue schemas
export const IssueCreateRequestSchema = z.object({

View File

@@ -649,6 +649,30 @@ export const apiContract = c.router({
500: ApiErrorSchema,
},
},
authVerifyEmail: {
method: "POST",
path: "/auth/verify-email",
body: z.object({ code: z.string() }),
responses: {
200: SuccessResponseSchema,
400: ApiErrorSchema,
401: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
authResendVerification: {
method: "POST",
path: "/auth/resend-verification",
body: emptyBodySchema,
responses: {
200: SuccessResponseSchema,
400: ApiErrorSchema,
401: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
});
export type ApiContract = typeof apiContract;

View File

@@ -63,6 +63,7 @@ export type {
UserByUsernameQuery,
UserResponse,
UserUpdateRequest,
VerifyEmailRequest,
} from "./api-schemas";
// API schemas
export {
@@ -133,6 +134,7 @@ export {
UserByUsernameQuerySchema,
UserResponseSchema,
UserUpdateRequestSchema,
VerifyEmailRequestSchema,
} from "./api-schemas";
export {
ISSUE_COMMENT_MAX_LENGTH,
@@ -153,6 +155,10 @@ export {
export type { ApiContract } from "./contract";
export { apiContract } from "./contract";
export type {
EmailJobInsert,
EmailJobRecord,
EmailVerificationInsert,
EmailVerificationRecord,
IconStyle,
IssueAssigneeInsert,
IssueAssigneeRecord,
@@ -191,6 +197,12 @@ export {
DEFAULT_SPRINT_COLOUR,
DEFAULT_STATUS_COLOUR,
DEFAULT_STATUS_COLOURS,
EmailJob,
EmailJobInsertSchema,
EmailJobSelectSchema,
EmailVerification,
EmailVerificationInsertSchema,
EmailVerificationSelectSchema,
Issue,
IssueAssignee,
IssueAssigneeInsertSchema,

View File

@@ -1,4 +1,4 @@
import { boolean, integer, json, pgTable, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core";
import { boolean, integer, json, pgTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import type { z } from "zod";
import {
@@ -62,6 +62,8 @@ export const User = pgTable("User", {
avatarURL: varchar({ length: 512 }),
iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type<IconStyle>(),
plan: varchar({ length: 32 }).notNull().default("free"),
emailVerified: boolean().notNull().default(false),
emailVerifiedAt: timestamp({ withTimezone: false }),
createdAt: timestamp({ withTimezone: false }).defaultNow(),
updatedAt: timestamp({ withTimezone: false }).defaultNow(),
});
@@ -195,7 +197,6 @@ export const IssueComment = pgTable("IssueComment", {
updatedAt: timestamp({ withTimezone: false }).defaultNow(),
});
// Zod schemas
export const UserSelectSchema = createSelectSchema(User);
export const UserInsertSchema = createInsertSchema(User);
@@ -226,7 +227,6 @@ export const SessionInsertSchema = createInsertSchema(Session);
export const TimedSessionSelectSchema = createSelectSchema(TimedSession);
export const TimedSessionInsertSchema = createInsertSchema(TimedSession);
// Types
export type UserRecord = z.infer<typeof UserSelectSchema>;
export type UserInsert = z.infer<typeof UserInsertSchema>;
@@ -260,8 +260,6 @@ export type SessionInsert = z.infer<typeof SessionInsertSchema>;
export type TimedSessionRecord = z.infer<typeof TimedSessionSelectSchema>;
export type TimedSessionInsert = z.infer<typeof TimedSessionInsertSchema>;
// Responses
export type IssueResponse = {
Issue: IssueRecord;
Creator: UserRecord;
@@ -299,7 +297,6 @@ export type TimerState = {
endedAt: string | null;
} | null;
// Subscription table - tracks user subscriptions
export const Subscription = pgTable("Subscription", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer()
@@ -319,7 +316,6 @@ export const Subscription = pgTable("Subscription", {
updatedAt: timestamp({ withTimezone: false }).defaultNow(),
});
// Payment history table
export const Payment = pgTable("Payment", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
subscriptionId: integer()
@@ -332,16 +328,53 @@ export const Payment = pgTable("Payment", {
createdAt: timestamp({ withTimezone: false }).defaultNow(),
});
// Zod schemas for Subscription and Payment
export const SubscriptionSelectSchema = createSelectSchema(Subscription);
export const SubscriptionInsertSchema = createInsertSchema(Subscription);
export const PaymentSelectSchema = createSelectSchema(Payment);
export const PaymentInsertSchema = createInsertSchema(Payment);
// Types for Subscription and Payment
export type SubscriptionRecord = z.infer<typeof SubscriptionSelectSchema>;
export type SubscriptionInsert = z.infer<typeof SubscriptionInsertSchema>;
export type PaymentRecord = z.infer<typeof PaymentSelectSchema>;
export type PaymentInsert = z.infer<typeof PaymentInsertSchema>;
export const EmailVerification = pgTable("EmailVerification", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer()
.notNull()
.references(() => User.id, { onDelete: "cascade" }),
code: varchar({ length: 6 }).notNull(),
attempts: integer().notNull().default(0),
maxAttempts: integer().notNull().default(5),
expiresAt: timestamp({ withTimezone: false }).notNull(),
verifiedAt: timestamp({ withTimezone: false }),
createdAt: timestamp({ withTimezone: false }).defaultNow(),
});
export const EmailVerificationSelectSchema = createSelectSchema(EmailVerification);
export const EmailVerificationInsertSchema = createInsertSchema(EmailVerification);
export type EmailVerificationRecord = z.infer<typeof EmailVerificationSelectSchema>;
export type EmailVerificationInsert = z.infer<typeof EmailVerificationInsertSchema>;
export const EmailJob = pgTable("EmailJob", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer()
.notNull()
.references(() => User.id, { onDelete: "cascade" }),
type: varchar({ length: 64 }).notNull(),
scheduledFor: timestamp({ withTimezone: false }).notNull(),
sentAt: timestamp({ withTimezone: false }),
failedAt: timestamp({ withTimezone: false }),
errorMessage: text(),
metadata: json("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp({ withTimezone: false }).defaultNow(),
});
export const EmailJobSelectSchema = createSelectSchema(EmailJob);
export const EmailJobInsertSchema = createInsertSchema(EmailJob);
export type EmailJobRecord = z.infer<typeof EmailJobSelectSchema>;
export type EmailJobInsert = z.infer<typeof EmailJobInsertSchema>;