TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use server';23import { redirect } from 'next/navigation';4import { and, eq, isNull } from '@/lib/db';5import { newId } from '@rareindex/shared';6import { sendMail, verificationEmail, mfaCodeEmail, newLoginEmail, passwordResetEmail } from '@rareindex/notify';7import { db, users, loginEvents, recoveryCodes, watchlists } from '@/lib/db';8import { hashPassword, verifyPassword, decryptSecret, hmacToken, normalizeRecoveryCode, signPayload, verifyPayload } from './crypto';9import { issueCode, verifyCode } from './codes';10import { createSession, getCurrentUser, destroySession, revokeAllSessions } from './session';11import { isTrustedDevice, trustThisDevice, revokeAllDevices } from './devices';12import { clearPending, getPending, safeNext, setPending, type PendingStage } from './pending';13import { enforce, RateLimited } from './rate-limit';14import { clientInfo } from './request';15import { verifyTotp } from './totp';16import { codeSchema, emailSchema, loginSchema, passwordSchema, signupSchema } from './validation';17import { fieldErrorsFrom, type ActionState } from './state';1819const SITE = () => (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io').replace(/\/$/, '');2021async function logLogin(input: { userId?: string | null; email?: string | null; outcome: string; method?: string | null }) {22 const { ip, userAgent } = await clientInfo();23 await db().insert(loginEvents).values({ id: newId('event'), userId: input.userId ?? null, email: input.email ?? null, ip, userAgent, outcome: input.outcome, method: input.method ?? null });24}2526function fail(error: string, extra: Partial<ActionState> = {}): ActionState {27 return { ok: false, error, ...extra };28}2930async function guarded<T>(fn: () => Promise<T>): Promise<T | ActionState> {31 try {32 return await fn();33 } catch (err) {34 if (err instanceof RateLimited) return fail(err.message, { data: { retryAfter: err.retryAfterSeconds } });35 throw err;36 }37}3839// ---------------------------------------------------------------- signup4041export async function signupAction(_prev: ActionState, formData: FormData): Promise<ActionState> {42 return guarded(async () => {43 const parsed = signupSchema.safeParse({ email: formData.get('email'), password: formData.get('password'), name: formData.get('name') ?? '' });44 if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });45 const { email, password, name } = parsed.data;46 const { ip } = await clientInfo();47 await enforce(`signup:ip:${ip ?? 'x'}`, 10, 3600);48 await enforce(`signup:email:${email}`, 5, 3600);4950 const existing = await db().select({ id: users.id, verified: users.emailVerifiedAt, deletedAt: users.deletedAt }).from(users).where(eq(users.email, email)).limit(1);51 let userId: string;52 if (existing[0]) {53 if (existing[0].verified && !existing[0].deletedAt) return fail('An account with this e-mail already exists.', { fieldErrors: { email: 'Already registered — sign in instead.' } });54 // unverified (or soft-deleted) account: allow re-registration by resetting credentials55 userId = existing[0].id;56 await db().update(users).set({ passwordHash: await hashPassword(password), name: name || null, deletedAt: null, purgeAfter: null }).where(eq(users.id, userId));57 } else {58 userId = newId('user');59 await db().insert(users).values({ id: userId, email, passwordHash: await hashPassword(password), name: name || null, preferences: { emailAlerts: true, digest: 'weekly', digestWeekday: 1, newLoginEmails: true } });60 await db().insert(watchlists).values({ id: newId('watchlist'), userId, name: 'Watchlist' });61 }62 const issued = await issueCode({ email, purpose: 'verify_email', userId });63 if ('code' in issued) {64 const mail = verificationEmail({ code: issued.code, minutes: issued.minutes });65 await sendMail({ to: email, ...mail, tags: [{ name: 'kind', value: 'verify' }] });66 }67 await setPending({ uid: userId, email, stage: 'verify', next: safeNext(String(formData.get('next') ?? ''), '/collections?welcome=1'), newDevice: true });68 redirect('/verify');69 });70}7172export async function resendCodeAction(_prev: ActionState): Promise<ActionState> {73 return guarded(async () => {74 const p = await getPending();75 if (!p) return fail('Your session expired. Start again.');76 const { ip } = await clientInfo();77 await enforce(`resend:${p.email}`, 6, 3600);78 await enforce(`resend:ip:${ip ?? 'x'}`, 20, 3600);79 const purpose = p.stage === 'verify' ? 'verify_email' : 'mfa_email';80 const issued = await issueCode({ email: p.email, purpose, userId: p.uid });81 if ('cooldownSeconds' in issued) return fail(`Please wait ${issued.cooldownSeconds}s before requesting another code.`, { data: { retryAfter: issued.cooldownSeconds } });82 const { userAgent } = await clientInfo();83 const mail = purpose === 'verify_email' ? verificationEmail({ code: issued.code, minutes: issued.minutes }) : mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent });84 await sendMail({ to: p.email, ...mail });85 return { ok: true, message: 'A new code is on its way.' };86 });87}8889export async function verifyEmailAction(_prev: ActionState, formData: FormData): Promise<ActionState> {90 return guarded(async () => {91 const p = await getPending();92 if (!p || p.stage !== 'verify') return fail('Your session expired. Sign in to get a new code.');93 const code = codeSchema.safeParse(formData.get('code'));94 if (!code.success) return fail('Enter the 6-digit code.', { fieldErrors: { code: code.error.issues[0]?.message ?? 'Invalid' } });95 await enforce(`verify:${p.email}`, 12, 900);96 const res = await verifyCode({ email: p.email, purpose: 'verify_email', code: code.data });97 if (!res.ok) return fail(res.reason === 'locked' ? 'Too many wrong codes. Request a new one.' : res.reason === 'expired' ? 'That code expired. Request a new one.' : res.reason === 'missing' ? 'No active code. Request a new one.' : 'That code is not right.');98 await db().update(users).set({ emailVerifiedAt: new Date(), lastLoginAt: new Date() }).where(eq(users.id, p.uid));99 await createSession(p.uid);100 await trustThisDevice(p.uid);101 await logLogin({ userId: p.uid, email: p.email, outcome: 'success', method: 'email_code' });102 await clearPending();103 redirect(safeNext(p.next, '/collections?welcome=1'));104 });105}106107// ---------------------------------------------------------------- login108109export async function loginAction(_prev: ActionState, formData: FormData): Promise<ActionState> {110 return guarded(async () => {111 const parsed = loginSchema.safeParse({ email: formData.get('email'), password: formData.get('password') });112 if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });113 const { email, password } = parsed.data;114 const next = safeNext(String(formData.get('next') ?? ''));115 const { ip, userAgent } = await clientInfo();116 await enforce(`login:ip:${ip ?? 'x'}`, 30, 900);117 await enforce(`login:email:${email}`, 10, 900);118119 const rows = await db().select().from(users).where(eq(users.email, email)).limit(1);120 const user = rows[0];121 // constant-ish time: always run a hash verification122 const good = await verifyPassword(password, user?.passwordHash ?? 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');123 if (!user || !good) {124 await logLogin({ userId: user?.id ?? null, email, outcome: user ? 'bad_password' : 'unknown_user', method: 'password' });125 return fail('E-mail or password is incorrect.');126 }127 if (user.deletedAt && user.purgeAfter && user.purgeAfter.getTime() < Date.now()) return fail('This account has been deleted.');128129 if (!user.emailVerifiedAt) {130 const issued = await issueCode({ email, purpose: 'verify_email', userId: user.id });131 if ('code' in issued) await sendMail({ to: email, ...verificationEmail({ code: issued.code, minutes: issued.minutes }) });132 await setPending({ uid: user.id, email, stage: 'verify', next, newDevice: true });133 redirect('/verify');134 }135136 const trusted = await isTrustedDevice(user.id);137 let stage: PendingStage | null = null;138 if (user.mfaEnabled && !trusted) stage = 'totp';139 else if (!user.mfaEnabled && user.alwaysAskCode && !trusted) stage = 'email_code';140141 if (stage) {142 if (stage === 'email_code') {143 const issued = await issueCode({ email, purpose: 'mfa_email', userId: user.id });144 if ('code' in issued) await sendMail({ to: email, ...mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent }) });145 }146 await logLogin({ userId: user.id, email, outcome: 'mfa_required', method: 'password' });147 await setPending({ uid: user.id, email, stage, next, newDevice: !trusted });148 redirect('/mfa');149 }150151 await completeLogin(user.id, email, 'password', !trusted, false);152 redirect(next);153 });154}155156async function completeLogin(userId: string, email: string, method: string, newDevice: boolean, trust: boolean) {157 const rows = await db().select({ deletedAt: users.deletedAt, prefs: users.preferences, name: users.name }).from(users).where(eq(users.id, userId)).limit(1);158 const u = rows[0];159 await db().update(users).set({ lastLoginAt: new Date(), ...(u?.deletedAt ? { deletedAt: null, purgeAfter: null } : {}) }).where(eq(users.id, userId));160 await createSession(userId);161 if (trust) await trustThisDevice(userId);162 await logLogin({ userId, email, outcome: 'success', method });163 await clearPending();164 const prefs = (u?.prefs ?? {}) as { newLoginEmails?: boolean };165 if (newDevice && prefs.newLoginEmails !== false) {166 const { ip, userAgent } = await clientInfo();167 void sendMail({ to: email, ...newLoginEmail({ when: new Date(), ip, userAgent, method }) }).catch(() => {});168 }169}170171export async function mfaVerifyAction(_prev: ActionState, formData: FormData): Promise<ActionState> {172 return guarded(async () => {173 const p = await getPending();174 if (!p || (p.stage !== 'totp' && p.stage !== 'email_code')) return fail('Your sign-in session expired. Sign in again.');175 const method = String(formData.get('method') ?? (p.stage === 'totp' ? 'totp' : 'email_code'));176 const trust = formData.get('trust') === 'on';177 const raw = String(formData.get('code') ?? '');178 await enforce(`mfa:${p.uid}`, 12, 900);179 const rows = await db().select().from(users).where(eq(users.id, p.uid)).limit(1);180 const user = rows[0];181 if (!user) return fail('Account not found.');182183 if (method === 'totp') {184 if (!user.mfaEnabled || !user.totpSecretEnc) return fail('Authenticator is not enabled on this account.');185 const delta = verifyTotp(decryptSecret(user.totpSecretEnc), raw);186 if (delta === null) {187 await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'totp' });188 return fail('That authenticator code is not valid.');189 }190 } else if (method === 'email_code') {191 const code = codeSchema.safeParse(raw);192 if (!code.success) return fail('Enter the 6-digit code.');193 const res = await verifyCode({ email: p.email, purpose: 'mfa_email', code: code.data });194 if (!res.ok) {195 await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'email_code' });196 return fail(res.reason === 'locked' ? 'Too many wrong codes. Request a new one.' : res.reason === 'expired' || res.reason === 'missing' ? 'That code expired or was not issued. Request a new one.' : 'That code is not right.');197 }198 } else if (method === 'recovery') {199 const norm = normalizeRecoveryCode(raw);200 const hash = hmacToken(norm, 'recovery');201 const rc = await db().select({ id: recoveryCodes.id }).from(recoveryCodes).where(and(eq(recoveryCodes.userId, user.id), eq(recoveryCodes.codeHash, hash), isNull(recoveryCodes.usedAt))).limit(1);202 if (!rc[0]) {203 await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'recovery_code' });204 return fail('That recovery code is not valid or was already used.');205 }206 await db().update(recoveryCodes).set({ usedAt: new Date() }).where(eq(recoveryCodes.id, rc[0].id));207 } else {208 return fail('Unknown method.');209 }210 await completeLogin(user.id, p.email, method === 'recovery' ? 'recovery_code' : method, p.newDevice, trust);211 redirect(safeNext(p.next));212 });213}214215/** Switch a TOTP challenge to an e-mailed code (fallback). */216export async function mfaUseEmailAction(_prev: ActionState): Promise<ActionState> {217 return guarded(async () => {218 const p = await getPending();219 if (!p || p.stage !== 'totp') return fail('Your sign-in session expired.');220 await enforce(`mfa-email:${p.uid}`, 5, 3600);221 const issued = await issueCode({ email: p.email, purpose: 'mfa_email', userId: p.uid });222 if ('cooldownSeconds' in issued) return fail(`Please wait ${issued.cooldownSeconds}s before requesting another code.`);223 const { ip, userAgent } = await clientInfo();224 await sendMail({ to: p.email, ...mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent }) });225 await setPending({ ...p, stage: 'email_code' });226 return { ok: true, message: 'We e-mailed you a sign-in code.' };227 });228}229230export async function logoutAction(): Promise<void> {231 await destroySession();232 redirect('/');233}234235export async function logoutEverywhereAction(): Promise<ActionState> {236 const u = await getCurrentUser();237 if (!u) return fail('Not signed in.');238 const n = await revokeAllSessions(u.id, true);239 await revokeAllDevices(u.id);240 return { ok: true, message: `Signed out of ${n} other session${n === 1 ? '' : 's'} and forgot all trusted devices.` };241}242243// ---------------------------------------------------------------- password reset244245export async function forgotAction(_prev: ActionState, formData: FormData): Promise<ActionState> {246 return guarded(async () => {247 const email = emailSchema.safeParse(formData.get('email'));248 if (!email.success) return fail('Enter a valid e-mail address.');249 const { ip } = await clientInfo();250 await enforce(`forgot:ip:${ip ?? 'x'}`, 10, 3600);251 await enforce(`forgot:${email.data}`, 4, 3600);252 const rows = await db().select({ id: users.id }).from(users).where(eq(users.email, email.data)).limit(1);253 if (rows[0]) {254 const issued = await issueCode({ email: email.data, purpose: 'password_reset', userId: rows[0].id });255 if ('code' in issued) {256 const t = signPayload({ email: email.data, code: issued.code }, issued.minutes * 60);257 await sendMail({ to: email.data, ...passwordResetEmail({ code: issued.code, link: `${SITE()}/reset?t=${encodeURIComponent(t)}`, minutes: issued.minutes }) });258 }259 }260 // Same response whether or not the account exists.261 return { ok: true, message: 'If an account exists for that address, a reset code is on its way.' };262 });263}264265export async function resetAction(_prev: ActionState, formData: FormData): Promise<ActionState> {266 return guarded(async () => {267 let email = String(formData.get('email') ?? '');268 let code = String(formData.get('code') ?? '');269 const t = String(formData.get('t') ?? '');270 if (t) {271 const p = verifyPayload<{ email: string; code: string }>(t);272 if (!p) return fail('This reset link expired. Request a new one.');273 email = p.email;274 code = p.code;275 }276 const e = emailSchema.safeParse(email);277 const c = codeSchema.safeParse(code);278 const pw = passwordSchema.safeParse(formData.get('password'));279 if (!e.success || !c.success || !pw.success) return fail('Check the highlighted fields.', { fieldErrors: { ...(e.success ? {} : { email: 'Invalid e-mail' }), ...(c.success ? {} : { code: 'Enter the 6-digit code' }), ...(pw.success ? {} : { password: pw.error.issues[0]?.message ?? 'Invalid password' }) } });280 await enforce(`reset:${e.data}`, 10, 900);281 const res = await verifyCode({ email: e.data, purpose: 'password_reset', code: c.data });282 if (!res.ok) return fail(res.reason === 'locked' ? 'Too many attempts. Request a new code.' : 'That code is not valid or expired.');283 const rows = await db().select({ id: users.id }).from(users).where(eq(users.email, e.data)).limit(1);284 if (!rows[0]) return fail('Account not found.');285 await db().update(users).set({ passwordHash: await hashPassword(pw.data), passwordChangedAt: new Date(), emailVerifiedAt: new Date() }).where(eq(users.id, rows[0].id));286 await revokeAllSessions(rows[0].id, false);287 await revokeAllDevices(rows[0].id);288 await logLogin({ userId: rows[0].id, email: e.data, outcome: 'success', method: 'password_reset' });289 redirect('/login?reset=1');290 });291}292