diff --git a/drizzle/0005_email_verification.sql b/drizzle/0005_email_verification.sql new file mode 100644 index 0000000..2fbe335 --- /dev/null +++ b/drizzle/0005_email_verification.sql @@ -0,0 +1,21 @@ +-- Email verification tokens (single-use, 24 h). +-- Mirrors the password_resets shape already in prod. +-- Backfill grandfathers all existing users as verified so the Task C +-- sign-in gate does not lock out accounts created before this migration. + +CREATE TABLE IF NOT EXISTS email_verifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS email_verifications_token_idx + ON email_verifications(token); +--> statement-breakpoint +-- Grandfather every existing user as verified. +UPDATE users SET email_verified = now() WHERE email_verified IS NULL; +--> statement-breakpoint +GRANT ALL ON email_verifications TO tia_app; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b20a66f..784b314 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1748221200000, "tag": "0004_circle_invite_email", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1748307600000, + "tag": "0005_email_verification", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 4509466..3a86fd8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@aws-sdk/client-s3": "^3.1045.0", "@aws-sdk/s3-request-presigner": "^3.1045.0", "@react-pdf/renderer": "^4.5.1", + "arctic": "^3.7.0", "bcryptjs": "^3.0.3", "chart.js": "^4.5.1", "date-fns": "^4.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b41714..5cd6f1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@react-pdf/renderer': specifier: ^4.5.1 version: 4.5.1(react@19.2.4) + arctic: + specifier: ^3.7.0 + version: 3.7.0 bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -1523,6 +1526,24 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + + '@oslojs/encoding@0.4.1': + resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oslojs/jwt@0.2.0': + resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} @@ -2093,6 +2114,9 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + arctic@3.7.0: + resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -5511,6 +5535,25 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@0.4.1': {} + + '@oslojs/encoding@1.1.0': {} + + '@oslojs/jwt@0.2.0': + dependencies: + '@oslojs/encoding': 0.4.1 + '@panva/hkdf@1.2.1': {} '@react-pdf/fns@3.1.3': {} @@ -6272,6 +6315,12 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + arctic@3.7.0: + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/jwt': 0.2.0 + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts new file mode 100644 index 0000000..d721a39 --- /dev/null +++ b/src/app/api/auth/google/callback/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { sql } from "@/db"; +import { google } from "@/lib/oauth"; +import { logAudit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + + const cookieStore = await cookies(); + const storedState = cookieStore.get("g_state")?.value; + const verifier = cookieStore.get("g_verifier")?.value; + + // 1. CSRF / integrity check — returned state must match what we sent. + if (!code || !state || !storedState || state !== storedState || !verifier) { + return NextResponse.redirect(new URL("/login?error=oauth", request.url)); + } + + try { + // 2. Exchange the code for tokens (arctic POSTs to Google). + const tokens = await google.validateAuthorizationCode(code, verifier); + + // 3. Fetch the verified profile. Calling userinfo directly over TLS + // means we don't need to verify an ID-token JWT signature ourselves. + const profileRes = await fetch( + "https://openidconnect.googleapis.com/v1/userinfo", + { headers: { Authorization: `Bearer ${tokens.accessToken()}` } }, + ); + if (!profileRes.ok) { + return NextResponse.redirect(new URL("/login?error=oauth", request.url)); + } + const profile = (await profileRes.json()) as { + sub: string; email: string; email_verified: boolean; + name?: string; picture?: string; + }; + + // 4. HARD requirement: Google must confirm the email is verified. + if (!profile.email || !profile.email_verified) { + return NextResponse.redirect(new URL("/login?error=unverified", request.url)); + } + + // 5. Find or create the user. + const existing = await sql` + SELECT id, password_hash, email_verified FROM users + WHERE email = ${profile.email} LIMIT 1 + `; + let userId: string; + let isNewUser = false; + + if (existing.length === 0) { + userId = crypto.randomUUID(); + isNewUser = true; + await sql` + INSERT INTO users (id, email, name, image, email_verified, created_at, updated_at) + VALUES (${userId}, ${profile.email}, ${profile.name ?? null}, + ${profile.picture ?? null}, NOW(), NOW(), NOW()) + `; + } else { + userId = existing[0].id; + // LINKING GUARD: a password set on an account that was never + // email-verified was never proven to belong to this person. + // Google just proved ownership -> drop that unverified password + // (kills any pre-registration takeover) and mark verified. + if (existing[0].password_hash && !existing[0].email_verified) { + await sql`UPDATE users SET password_hash = NULL, email_verified = NOW() WHERE id = ${userId}`; + } else if (!existing[0].email_verified) { + await sql`UPDATE users SET email_verified = NOW() WHERE id = ${userId}`; + } + } + + // 6. Record the Google link (identity only — Tia never calls Google + // APIs after login, so there are no tokens worth storing). + // Relies on the unique index on (provider, provider_account_id). + await sql` + INSERT INTO accounts (user_id, type, provider, provider_account_id) + VALUES (${userId}, 'oidc', 'google', ${profile.sub}) + ON CONFLICT (provider, provider_account_id) DO NOTHING + `; + + // 7. Mint a tia_session — identical to signin/route.ts. + const sessionToken = crypto.randomUUID(); + const expires = new Date(); + expires.setDate(expires.getDate() + 30); + await sql` + INSERT INTO sessions (session_token, user_id, expires) + VALUES (${sessionToken}, ${userId}, ${expires.toISOString()}) + `; + await logAudit({ userId, action: "login", request, metadata: { method: "google" } }); + + const response = NextResponse.redirect( + new URL(isNewUser ? "/onboarding" : "/", request.url), + ); + response.cookies.set("tia_session", sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30, + path: "/", + }); + response.cookies.delete("g_state"); + response.cookies.delete("g_verifier"); + return response; + } catch (e) { + console.error("Google callback error:", e); + return NextResponse.redirect(new URL("/login?error=oauth", request.url)); + } +} diff --git a/src/app/api/auth/google/route.ts b/src/app/api/auth/google/route.ts new file mode 100644 index 0000000..d4e9016 --- /dev/null +++ b/src/app/api/auth/google/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { generateState, generateCodeVerifier } from "arctic"; +import { google } from "@/lib/oauth"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const state = generateState(); // anti-CSRF nonce + const codeVerifier = generateCodeVerifier(); // PKCE secret + + const authUrl = google.createAuthorizationURL(state, codeVerifier, [ + "openid", "email", "profile", + ]); + + const res = NextResponse.redirect(authUrl); + // Short-lived httpOnly cookies so the callback can verify the round-trip. + const opts = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + maxAge: 600, // 10 min + path: "/", + }; + res.cookies.set("g_state", state, opts); + res.cookies.set("g_verifier", codeVerifier, opts); + return res; +} diff --git a/src/app/api/auth/resend-verification/route.ts b/src/app/api/auth/resend-verification/route.ts new file mode 100644 index 0000000..465cfdb --- /dev/null +++ b/src/app/api/auth/resend-verification/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { rateLimit, getClientIp, getRateLimitKey } from "@/lib/rate-limit"; +import { randomBytes } from "crypto"; +import { sendVerificationEmail } from "@/lib/email"; + +export async function POST(request: Request) { + const ip = getClientIp(request); + const rl = await rateLimit(getRateLimitKey("resend-verify", ip), { max: 3, windowSec: 3600 }); + if (!rl.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + try { + const { email } = await request.json(); + if (!email) return NextResponse.json({ error: "Email required" }, { status: 400 }); + + const users = await sql`SELECT id, email_verified FROM users WHERE email = ${email} LIMIT 1`; + + // Generic success — never reveal whether the email exists or is verified. + if (users.length === 0 || users[0].email_verified) { + return NextResponse.json({ success: true }); + } + + const userId = users[0].id; + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 24); + await sql` + INSERT INTO email_verifications (user_id, token, expires_at) + VALUES (${userId}, ${token}, ${expiresAt.toISOString()}) + `; + await sendVerificationEmail(email, token, userId); + return NextResponse.json({ success: true }); + } catch (e) { + console.error("Resend verification error:", e); + return NextResponse.json({ success: true }); + } +} diff --git a/src/app/api/auth/signin/route.ts b/src/app/api/auth/signin/route.ts index 1753094..b1329b5 100644 --- a/src/app/api/auth/signin/route.ts +++ b/src/app/api/auth/signin/route.ts @@ -2,8 +2,10 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; import { cookies } from "next/headers"; import bcrypt from "bcryptjs"; +import { randomBytes } from "crypto"; import { rateLimit, getClientIp, getRateLimitKey } from "@/lib/rate-limit"; import { logAudit } from "@/lib/audit"; +import { sendVerificationEmail } from "@/lib/email"; export const dynamic = "force-dynamic"; @@ -94,7 +96,7 @@ export async function POST(request: Request) { try { // Find user const users = await sql` - SELECT u.id, u.email, u.password_hash, fm.family_id as family_id + SELECT u.id, u.email, u.password_hash, u.email_verified, fm.family_id as family_id FROM users u LEFT JOIN family_members fm ON fm.user_id::text = u.id::text WHERE u.email = ${email} @@ -110,41 +112,26 @@ export async function POST(request: Request) { } const newUserId = crypto.randomUUID(); const passwordHash = await hashPassword(password); + + // Create the user UNVERIFIED. No tia_session is set here anymore — + // the user is logged in only after clicking the verification link. await sql` INSERT INTO users (id, email, password_hash, password_updated_at, created_at, updated_at) VALUES (${newUserId}, ${email}, ${passwordHash}, NOW(), NOW(), NOW()) `; + await logAudit({ userId: newUserId, action: "signup", request }); - // Audit log - await logAudit({ - userId: newUserId, - action: "signup", - request, - }); - - // Create session - const sessionToken = crypto.randomUUID(); - const expires = new Date(); - expires.setDate(expires.getDate() + 30); + // Single-use verification token, 24h validity. + const verifyToken = randomBytes(32).toString("hex"); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 24); await sql` - INSERT INTO sessions (session_token, user_id, expires) - VALUES (${sessionToken}, ${newUserId}, ${expires.toISOString()}) + INSERT INTO email_verifications (user_id, token, expires_at) + VALUES (${newUserId}, ${verifyToken}, ${expiresAt.toISOString()}) `; + await sendVerificationEmail(email, verifyToken, newUserId); - const response = NextResponse.json({ - success: true, - userId: newUserId, - email, - isNewUser: true, - }); - response.cookies.set("tia_session", sessionToken, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 60 * 60 * 24 * 30, // 30 days - path: "/", - }); - return response; + return NextResponse.json({ success: true, needsVerification: true, email }); } // Sign in - require existing user with password @@ -169,6 +156,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Invalid password" }, { status: 401 }); } + // Gate: unverified accounts cannot sign in. + if (!user.email_verified) { + return NextResponse.json( + { error: "Please verify your email before signing in.", needsVerification: true }, + { status: 403 }, + ); + } + // Create session const sessionToken = crypto.randomUUID(); const expires = new Date(); diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..9d62883 --- /dev/null +++ b/src/app/api/auth/verify-email/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { logAudit } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +// GET — the link target from the verification email. +export async function GET(request: Request) { + const token = new URL(request.url).searchParams.get("token"); + if (!token) { + return NextResponse.redirect(new URL("/login?error=verify", request.url)); + } + + try { + // Single-use, unexpired lookup (mirrors reset-confirm). + const rows = await sql` + SELECT id, user_id FROM email_verifications + WHERE token = ${token} AND expires_at > NOW() AND used_at IS NULL + LIMIT 1 + `; + if (rows.length === 0) { + return NextResponse.redirect(new URL("/login?error=verify", request.url)); + } + + const verifyId = rows[0].id; + const userId = rows[0].user_id; + + // Mark verified and burn the token. + await sql`UPDATE users SET email_verified = NOW() WHERE id = ${userId}`; + await sql`UPDATE email_verifications SET used_at = NOW() WHERE id = ${verifyId}`; + + // Clicking the link proves email ownership -> log them in now. + const sessionToken = crypto.randomUUID(); + const expires = new Date(); + expires.setDate(expires.getDate() + 30); + await sql` + INSERT INTO sessions (session_token, user_id, expires) + VALUES (${sessionToken}, ${userId}, ${expires.toISOString()}) + `; + await logAudit({ userId, action: "email_verified", request }); + + const response = NextResponse.redirect(new URL("/onboarding", request.url)); + response.cookies.set("tia_session", sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30, + path: "/", + }); + return response; + } catch (e) { + console.error("Verify email error:", e); + return NextResponse.redirect(new URL("/login?error=verify", request.url)); + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 59ae5b7..8df43ce 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -11,13 +11,17 @@ export default function LoginPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [mode, setMode] = useState<"login" | "signup">("login"); + // Shown after signup — user must verify before they can sign in. + const [emailSent, setEmailSent] = useState(false); + // Set when sign-in is blocked because the account isn't verified. + const [needsVerification, setNeedsVerification] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!email || !password) return; - setLoading(true); setError(""); + setNeedsVerification(false); try { const res = await fetch("/api/auth/signin", { @@ -25,21 +29,27 @@ export default function LoginPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, action: mode }), }); - const data = await res.json(); if (!res.ok) { - setError(data.error || "Failed to login"); + if (data.needsVerification) { + // Sign-in blocked: account exists but email not verified. + setNeedsVerification(true); + setError(data.error || "Please verify your email."); + } else { + setError(data.error || "Failed to sign in"); + } return; } - if (data.isNewUser) { - router.push("/onboarding"); - } else if (data.familyId) { - router.push("/"); - } else { - router.push("/onboarding"); + // Signup success -> no session yet, must verify email. + if (data.needsVerification) { + setEmailSent(true); + return; } + + // Sign-in success. + router.push(data.familyId ? "/" : "/onboarding"); } catch { setError("Something went wrong"); } finally { @@ -47,6 +57,39 @@ export default function LoginPage() { } }; + const handleResend = async () => { + setLoading(true); + try { + await fetch("/api/auth/resend-verification", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + setEmailSent(true); + setNeedsVerification(false); + } finally { + setLoading(false); + } + }; + + // ----- "Check your email" screen ----- + if (emailSent) { + return ( +
+ +
✉️
+

Check your email

+

+ We sent a verification link to {email}. Click it to + activate your account. +

+

The link expires in 24 hours.

+
+
+ ); + } + + // ----- Login / Signup form ----- return (
@@ -54,37 +97,48 @@ export default function LoginPage() { {mode === "login" ? "Welcome Back" : "Create Account"} + {/* Google — full server-side OAuth round trip. */} + + +
+
+ or +
+
+
- setEmail(e.target.value)} - required - /> - - setPassword(e.target.value)} - required - minLength={4} - error={error || undefined} - /> - + setEmail(e.target.value)} required /> + setPassword(e.target.value)} + required minLength={8} error={error || undefined} />
+ {needsVerification && ( + + )} +

{mode === "login" ? "No account? " : "Already have an account? "} -

diff --git a/src/app/verify/page.tsx b/src/app/verify/page.tsx index b4b0d35..8424873 100644 --- a/src/app/verify/page.tsx +++ b/src/app/verify/page.tsx @@ -1,18 +1,13 @@ "use client"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +// This page is no longer used — email verification is handled by +// /api/auth/verify-email (GET), which redirects to /onboarding on success. +// Redirect legacy traffic to login. export default function VerifyPage() { - return ( -
-
-
✉️
-

Check your email

-

- We sent you a magic link to sign in. -

-

- Click the link in the email to sign in to Tia. -

-
-
- ); -} \ No newline at end of file + const router = useRouter(); + useEffect(() => { router.replace("/login"); }, [router]); + return null; +} diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts index b2a4307..14f750e 100644 --- a/src/db/schema/auth.ts +++ b/src/db/schema/auth.ts @@ -51,6 +51,17 @@ export const verificationTokens = pgTable("verification_tokens", { expires: timestamp("expires").notNull(), }, (table) => [uniqueIndex("verification_tokens_idx").on(table.identifier, table.token)]); +// Email verification tokens (single-use, 24h) +export const emailVerifications = pgTable("email_verifications", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").notNull(), + token: text("token").notNull().unique(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + usedAt: timestamp("used_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}); +export type EmailVerification = typeof emailVerifications.$inferSelect; + // Type exports export type User = typeof users.$inferSelect; export type Account = typeof accounts.$inferSelect; diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..2b1c6f0 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,36 @@ +import { Resend } from "resend"; + +const RESEND_API_KEY = process.env.RESEND_API_KEY; +const EMAIL_FROM = process.env.EMAIL_FROM || "Tia "; +const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com"; + +/** Sends an account-verification email, or logs the link in dev. */ +export async function sendVerificationEmail(email: string, token: string, userId: string) { + const link = `${APP_URL}/api/auth/verify-email?token=${token}`; + + if (!RESEND_API_KEY) { + // Dev fallback: no Resend key -> print the link so test accounts + // can be verified straight from the server log. + console.log(`[VERIFY-LINK] user=${userId} email=${email} link=${link}`); + return; + } + + try { + const resend = new Resend(RESEND_API_KEY); + await resend.emails.send({ + from: EMAIL_FROM, + to: email, + subject: "Verify your Tia email", + html: ` +
+

Welcome to Tia

+

Confirm this email to activate your account. This link expires in 24 hours.

+ Verify Email +

If you didn't create a Tia account, ignore this email.

+
`, + }); + console.log(`[VERIFY-EMAIL-SENT] user=${userId} email=${email}`); + } catch (e) { + console.error("[VERIFY-EMAIL-FAILED]", e); + } +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts new file mode 100644 index 0000000..7aee5a2 --- /dev/null +++ b/src/lib/oauth.ts @@ -0,0 +1,9 @@ +import { Google } from "arctic"; + +// Hand-rolled OAuth. arctic only builds URLs and exchanges the code — +// it does NOT manage sessions. We mint our own tia_session in the callback. +export const google = new Google( + process.env.AUTH_GOOGLE_ID!, + process.env.AUTH_GOOGLE_SECRET!, + process.env.AUTH_GOOGLE_REDIRECT_URI!, +);