feat: email verification + Google OAuth
- Signup now creates unverified users and sends a verification email (Resend); dev falls back to [VERIFY-LINK] console log - /api/auth/verify-email: single-use token handler, mints tia_session on success, redirects to /onboarding - /api/auth/resend-verification: rate-limited (3/hr), enumeration-safe - Sign-in gated on email_verified — unverified accounts get 403 with needsVerification flag so the UI can show the resend button - Google OAuth via arctic v3: PKCE + state anti-CSRF, find-or-create user, writes accounts row, mints tia_session - Login page: Google button, check-email screen, resend link on 403 - drizzle/0005_email_verification.sql: creates email_verifications table + backfills all existing users as verified (runs automatically on container start before app boots) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
23a365309b
commit
6bdaade777
14 changed files with 485 additions and 75 deletions
21
drizzle/0005_email_verification.sql
Normal file
21
drizzle/0005_email_verification.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
111
src/app/api/auth/google/callback/route.ts
Normal file
111
src/app/api/auth/google/callback/route.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
27
src/app/api/auth/google/route.ts
Normal file
27
src/app/api/auth/google/route.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
39
src/app/api/auth/resend-verification/route.ts
Normal file
39
src/app/api/auth/resend-verification/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
55
src/app/api/auth/verify-email/route.ts
Normal file
55
src/app/api/auth/verify-email/route.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full text-center" padding="lg">
|
||||
<div className="text-4xl mb-4">✉️</div>
|
||||
<h1 className="text-2xl font-bold mb-2 dark:text-white">Check your email</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
We sent a verification link to <strong>{email}</strong>. Click it to
|
||||
activate your account.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">The link expires in 24 hours.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Login / Signup form -----
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full" padding="lg">
|
||||
|
|
@ -54,37 +97,48 @@ export default function LoginPage() {
|
|||
{mode === "login" ? "Welcome Back" : "Create Account"}
|
||||
</h1>
|
||||
|
||||
{/* Google — full server-side OAuth round trip. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = "/api/auth/google"; }}
|
||||
className="w-full flex items-center justify-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg py-3 mb-4 font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<img src="https://www.google.com/favicon.ico" alt="" className="w-5 h-5" />
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<div className="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="text-xs text-gray-400">or</span>
|
||||
<div className="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
<Input label="Email" type="email" value={email}
|
||||
onChange={(e) => setEmail(e.target.value)} required />
|
||||
<Input label="Password" type="password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={4}
|
||||
error={error || undefined}
|
||||
/>
|
||||
|
||||
required minLength={8} error={error || undefined} />
|
||||
<Button type="submit" fullWidth size="lg" loading={loading}>
|
||||
{mode === "login" ? "Sign In" : "Sign Up"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{needsVerification && (
|
||||
<button type="button" onClick={handleResend}
|
||||
className="w-full text-sm text-rose-500 font-medium mt-3">
|
||||
Resend verification email
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p className="text-center mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{mode === "login" ? "No account? " : "Already have an account? "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode(mode === "login" ? "signup" : "login"); setError(""); }}
|
||||
className="text-rose-500 font-medium"
|
||||
>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
setMode(mode === "login" ? "signup" : "login");
|
||||
setError(""); setNeedsVerification(false);
|
||||
}}
|
||||
className="text-rose-500 font-medium">
|
||||
{mode === "login" ? "Sign Up" : "Sign In"}
|
||||
</button>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-amber-50">
|
||||
<div className="w-full max-w-md p-8 text-center">
|
||||
<div className="text-4xl mb-4">✉️</div>
|
||||
<h1 className="text-2xl font-bold mb-2">Check your email</h1>
|
||||
<p className="text-gray-600 mb-4">
|
||||
We sent you a magic link to sign in.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Click the link in the email to sign in to Tia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const router = useRouter();
|
||||
useEffect(() => { router.replace("/login"); }, [router]);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
36
src/lib/email.ts
Normal file
36
src/lib/email.ts
Normal file
|
|
@ -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 <noreply@tia.manohargupta.com>";
|
||||
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: `
|
||||
<div style="font-family:system-ui,sans-serif;max-width:500px;margin:0 auto;">
|
||||
<h2>Welcome to Tia</h2>
|
||||
<p>Confirm this email to activate your account. This link expires in 24 hours.</p>
|
||||
<a href="${link}" style="display:inline-block;background:#e11d48;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;margin:16px 0;">Verify Email</a>
|
||||
<p style="color:#6b7280;font-size:14px;">If you didn't create a Tia account, ignore this email.</p>
|
||||
</div>`,
|
||||
});
|
||||
console.log(`[VERIFY-EMAIL-SENT] user=${userId} email=${email}`);
|
||||
} catch (e) {
|
||||
console.error("[VERIFY-EMAIL-FAILED]", e);
|
||||
}
|
||||
}
|
||||
9
src/lib/oauth.ts
Normal file
9
src/lib/oauth.ts
Normal file
|
|
@ -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!,
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue