TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use server';23import { cookies } from 'next/headers';4import { redirect } from 'next/navigation';5import { revalidatePath } from 'next/cache';6import { and, eq, isNull, ne } from '@/lib/db';7import QRCode from 'qrcode';8import { z } from 'zod';9import { newId } from '@rareindex/shared';10import { sendMail, changeEmailEmail, accountDeletionEmail } from '@rareindex/notify';11import { db, users, sessions, recoveryCodes, trustedDevices, apiKeys } from '@/lib/db';12import { hashPassword, verifyPassword, encryptSecret, decryptSecret, hmacToken, recoveryCode, randomToken, signPayload, verifyPayload, sha256Hex } from './crypto';13import { issueCode, verifyCode } from './codes';14import { CURRENCY_COOKIE, DISPLAY_CURRENCIES, currentSessionId, destroySession, requireUser, revokeAllSessions } from './session';15import { revokeAllDevices, revokeDevice } from './devices';16import { enforce, RateLimited } from './rate-limit';17import { newTotpSecret, totpUri, verifyTotp } from './totp';18import { emailSchema, handleSchema, isReservedHandle, passwordSchema } from './validation';19import { fieldErrorsFrom, type ActionState } from './state';2021function fail(error: string, extra: Partial<ActionState> = {}): ActionState {22 return { ok: false, error, ...extra };23}24async function guarded<T>(fn: () => Promise<T>): Promise<T | ActionState> {25 try {26 return await fn();27 } catch (err) {28 if (err instanceof RateLimited) return fail(err.message);29 throw err;30 }31}3233// ---------------------------------------------------------------- profile & preferences3435const profileSchema = z.object({36 name: z.string().trim().max(80).optional().or(z.literal('')),37 handle: z.union([z.literal(''), handleSchema]).optional(),38 bio: z.string().trim().max(280).optional().or(z.literal('')),39 displayCurrency: z.enum(DISPLAY_CURRENCIES),40});4142export async function updateProfileAction(_prev: ActionState, formData: FormData): Promise<ActionState> {43 const u = await requireUser('/account/settings');44 const parsed = profileSchema.safeParse({ name: formData.get('name') ?? '', handle: String(formData.get('handle') ?? '').trim().toLowerCase(), bio: formData.get('bio') ?? '', displayCurrency: formData.get('displayCurrency') ?? u.displayCurrency });45 if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });46 const handle = parsed.data.handle ? parsed.data.handle : null;47 if (handle) {48 if (isReservedHandle(handle)) return fail('That handle is reserved.', { fieldErrors: { handle: 'Reserved' } });49 const taken = await db().select({ id: users.id }).from(users).where(and(eq(users.handle, handle), ne(users.id, u.id))).limit(1);50 if (taken[0]) return fail('That handle is taken.', { fieldErrors: { handle: 'Already taken' } });51 }52 await db().update(users).set({ name: parsed.data.name || null, handle, bio: parsed.data.bio || null, displayCurrency: parsed.data.displayCurrency }).where(eq(users.id, u.id));53 (await cookies()).set(CURRENCY_COOKIE, parsed.data.displayCurrency, { path: '/', maxAge: 365 * 86400, sameSite: 'lax' });54 revalidatePath('/account/settings');55 return { ok: true, message: 'Profile saved.' };56}5758export async function setCurrencyAction(currency: string): Promise<void> {59 if (!(DISPLAY_CURRENCIES as readonly string[]).includes(currency)) return;60 (await cookies()).set(CURRENCY_COOKIE, currency, { path: '/', maxAge: 365 * 86400, sameSite: 'lax' });61 const u = await (await import('./session')).getCurrentUser();62 if (u) await db().update(users).set({ displayCurrency: currency }).where(eq(users.id, u.id));63}6465const prefsSchema = z.object({66 emailAlerts: z.boolean(),67 newLoginEmails: z.boolean(),68 digest: z.enum(['off', 'daily', 'weekly']),69 digestWeekday: z.coerce.number().int().min(0).max(6),70 quietStart: z.coerce.number().int().min(0).max(23).nullable(),71 quietEnd: z.coerce.number().int().min(0).max(23).nullable(),72 marketMoves: z.boolean(),73});7475export async function updateNotificationPrefsAction(_prev: ActionState, formData: FormData): Promise<ActionState> {76 const u = await requireUser('/account/notifications');77 const quiet = formData.get('quiet') === 'on';78 const parsed = prefsSchema.safeParse({79 emailAlerts: formData.get('emailAlerts') === 'on',80 newLoginEmails: formData.get('newLoginEmails') === 'on',81 digest: formData.get('digest') ?? 'weekly',82 digestWeekday: formData.get('digestWeekday') ?? 1,83 quietStart: quiet ? formData.get('quietStart') : null,84 quietEnd: quiet ? formData.get('quietEnd') : null,85 marketMoves: formData.get('marketMoves') === 'on',86 });87 if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });88 await db().update(users).set({ preferences: { ...(u.preferences ?? {}), ...parsed.data } }).where(eq(users.id, u.id));89 revalidatePath('/account/notifications');90 return { ok: true, message: 'Notification preferences saved.' };91}9293// ---------------------------------------------------------------- password & e-mail9495export async function changePasswordAction(_prev: ActionState, formData: FormData): Promise<ActionState> {96 return guarded(async () => {97 const u = await requireUser('/account/security');98 await enforce(`chpw:${u.id}`, 10, 3600);99 const current = String(formData.get('current') ?? '');100 const next = passwordSchema.safeParse(formData.get('password'));101 if (!next.success) return fail('Choose a stronger password.', { fieldErrors: { password: next.error.issues[0]?.message ?? 'Invalid' } });102 if (!(await verifyPassword(current, u.passwordHash))) return fail('Current password is incorrect.', { fieldErrors: { current: 'Incorrect' } });103 await db().update(users).set({ passwordHash: await hashPassword(next.data), passwordChangedAt: new Date() }).where(eq(users.id, u.id));104 const n = await revokeAllSessions(u.id, true);105 revalidatePath('/account/security');106 return { ok: true, message: `Password changed. ${n} other session${n === 1 ? '' : 's'} signed out.` };107 });108}109110export async function startChangeEmailAction(_prev: ActionState, formData: FormData): Promise<ActionState> {111 return guarded(async () => {112 const u = await requireUser('/account/settings');113 await enforce(`chmail:${u.id}`, 5, 3600);114 const email = emailSchema.safeParse(formData.get('newEmail'));115 if (!email.success) return fail('Enter a valid e-mail.', { fieldErrors: { newEmail: 'Invalid e-mail' } });116 if (email.data === u.email) return fail('That is already your e-mail.');117 if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.', { fieldErrors: { password: 'Incorrect' } });118 const taken = await db().select({ id: users.id }).from(users).where(eq(users.email, email.data)).limit(1);119 if (taken[0]) return fail('That e-mail is already in use.', { fieldErrors: { newEmail: 'In use' } });120 const issued = await issueCode({ email: email.data, purpose: 'change_email', userId: u.id, payload: { newEmail: email.data } });121 if ('cooldownSeconds' in issued) return fail(`Wait ${issued.cooldownSeconds}s before requesting another code.`);122 await sendMail({ to: email.data, ...changeEmailEmail({ code: issued.code, minutes: issued.minutes, newEmail: email.data }) });123 await db().update(users).set({ pendingEmail: email.data }).where(eq(users.id, u.id));124 revalidatePath('/account/settings');125 return { ok: true, message: `We sent a confirmation code to ${email.data}.`, data: { pendingEmail: email.data } };126 });127}128129export async function confirmChangeEmailAction(_prev: ActionState, formData: FormData): Promise<ActionState> {130 return guarded(async () => {131 const u = await requireUser('/account/settings');132 if (!u.pendingEmail) return fail('No e-mail change in progress.');133 await enforce(`chmail-confirm:${u.id}`, 10, 900);134 const res = await verifyCode({ email: u.pendingEmail, purpose: 'change_email', code: String(formData.get('code') ?? '') });135 if (!res.ok) return fail('That code is not valid or expired.');136 await db().update(users).set({ email: u.pendingEmail, pendingEmail: null, emailVerifiedAt: new Date() }).where(eq(users.id, u.id));137 revalidatePath('/account/settings');138 return { ok: true, message: 'E-mail updated.' };139 });140}141142export async function cancelChangeEmailAction(): Promise<void> {143 const u = await requireUser('/account/settings');144 await db().update(users).set({ pendingEmail: null }).where(eq(users.id, u.id));145 revalidatePath('/account/settings');146}147148// ---------------------------------------------------------------- MFA149150const SETUP_COOKIE = 'ri_mfa_setup';151152export async function startMfaSetupAction(_prev: ActionState): Promise<ActionState> {153 const u = await requireUser('/account/security');154 if (u.mfaEnabled) return fail('Authenticator is already enabled.');155 const secret = newTotpSecret();156 const uri = totpUri(secret, u.email);157 const qr = await QRCode.toDataURL(uri, { margin: 1, width: 220, color: { dark: '#0b0b0c', light: '#ffffff' } });158 (await cookies()).set(SETUP_COOKIE, signPayload({ uid: u.id, enc: encryptSecret(secret) }, 15 * 60), { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 15 * 60 });159 return { ok: true, data: { qr, secret, uri } };160}161162async function issueRecoveryCodes(userId: string): Promise<string[]> {163 await db().delete(recoveryCodes).where(eq(recoveryCodes.userId, userId));164 const codes = Array.from({ length: 10 }, () => recoveryCode());165 await db().insert(recoveryCodes).values(codes.map((c) => ({ id: newId('event'), userId, codeHash: hmacToken(c, 'recovery') })));166 return codes;167}168169export async function confirmMfaSetupAction(_prev: ActionState, formData: FormData): Promise<ActionState> {170 return guarded(async () => {171 const u = await requireUser('/account/security');172 await enforce(`mfa-setup:${u.id}`, 10, 900);173 const raw = (await cookies()).get(SETUP_COOKIE)?.value;174 const p = verifyPayload<{ uid: string; enc: string }>(raw);175 if (!p || p.uid !== u.id) return fail('Setup expired. Start again.');176 const secret = decryptSecret(p.enc);177 if (verifyTotp(secret, String(formData.get('code') ?? '')) === null) return fail('That code does not match. Check the time on your device and try again.');178 await db().update(users).set({ mfaEnabled: true, totpSecretEnc: p.enc }).where(eq(users.id, u.id));179 const codes = await issueRecoveryCodes(u.id);180 (await cookies()).delete(SETUP_COOKIE);181 revalidatePath('/account/security');182 return { ok: true, message: 'Authenticator enabled. Save your recovery codes now — they are shown once.', data: { recoveryCodes: codes } };183 });184}185186export async function disableMfaAction(_prev: ActionState, formData: FormData): Promise<ActionState> {187 return guarded(async () => {188 const u = await requireUser('/account/security');189 await enforce(`mfa-disable:${u.id}`, 10, 900);190 if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.');191 if (!u.totpSecretEnc || verifyTotp(decryptSecret(u.totpSecretEnc), String(formData.get('code') ?? '')) === null) return fail('Authenticator code is not valid.');192 await db().update(users).set({ mfaEnabled: false, totpSecretEnc: null }).where(eq(users.id, u.id));193 await db().delete(recoveryCodes).where(eq(recoveryCodes.userId, u.id));194 revalidatePath('/account/security');195 return { ok: true, message: 'Authenticator disabled. E-mail codes remain active for new devices.' };196 });197}198199export async function regenerateRecoveryCodesAction(_prev: ActionState, formData: FormData): Promise<ActionState> {200 return guarded(async () => {201 const u = await requireUser('/account/security');202 await enforce(`recov:${u.id}`, 5, 3600);203 if (!u.mfaEnabled) return fail('Enable the authenticator first.');204 if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.');205 const codes = await issueRecoveryCodes(u.id);206 return { ok: true, message: 'New recovery codes generated. Previous codes no longer work.', data: { recoveryCodes: codes } };207 });208}209210export async function toggleAlwaysAskCodeAction(formData: FormData): Promise<void> {211 const u = await requireUser('/account/security');212 await db().update(users).set({ alwaysAskCode: formData.get('alwaysAskCode') === 'on' }).where(eq(users.id, u.id));213 revalidatePath('/account/security');214}215216export async function revokeSessionAction(formData: FormData): Promise<void> {217 const u = await requireUser('/account/security');218 const id = String(formData.get('sessionId') ?? '');219 const current = await currentSessionId();220 if (id && id !== current) await db().update(sessions).set({ revokedAt: new Date() }).where(and(eq(sessions.id, id), eq(sessions.userId, u.id)));221 revalidatePath('/account/security');222}223224export async function revokeDeviceAction(formData: FormData): Promise<void> {225 const u = await requireUser('/account/security');226 await revokeDevice(u.id, String(formData.get('deviceId') ?? ''));227 revalidatePath('/account/security');228}229230export async function signOutEverywhereAction(): Promise<void> {231 const u = await requireUser('/account/security');232 await revokeAllSessions(u.id, true);233 await revokeAllDevices(u.id);234 revalidatePath('/account/security');235}236237// ---------------------------------------------------------------- API keys238239export async function createApiKeyAction(_prev: ActionState, formData: FormData): Promise<ActionState> {240 const u = await requireUser('/account/api-keys');241 const name = z.string().trim().min(1).max(60).safeParse(formData.get('name'));242 if (!name.success) return fail('Give the key a name.', { fieldErrors: { name: 'Required' } });243 const existing = await db().select({ id: apiKeys.id }).from(apiKeys).where(and(eq(apiKeys.userId, u.id), isNull(apiKeys.revokedAt)));244 if (existing.length >= 10) return fail('You can hold at most 10 active keys.');245 const secret = `ri_${u.role === 'admin' || u.role === 'pro' ? 'live' : 'free'}_${randomToken(24)}`;246 const prefix = secret.slice(0, 14);247 const tier = u.role === 'admin' ? 'enterprise' : u.role === 'pro' ? 'professional' : 'free';248 const limits = tier === 'enterprise' ? { rpm: 600, daily: 100_000 } : tier === 'professional' ? { rpm: 120, daily: 10_000 } : { rpm: 30, daily: 1_000 };249 await db().insert(apiKeys).values({ id: newId('apiKey'), userId: u.id, name: name.data, prefix, keyHash: sha256Hex(secret), tier, rateLimitPerMinute: limits.rpm, dailyQuota: limits.daily });250 revalidatePath('/account/api-keys');251 return { ok: true, message: 'Key created. Copy it now — it will not be shown again.', data: { secret } };252}253254export async function revokeApiKeyAction(formData: FormData): Promise<void> {255 const u = await requireUser('/account/api-keys');256 await db().update(apiKeys).set({ revokedAt: new Date() }).where(and(eq(apiKeys.id, String(formData.get('keyId') ?? '')), eq(apiKeys.userId, u.id)));257 revalidatePath('/account/api-keys');258}259260// ---------------------------------------------------------------- deletion261262export async function deleteAccountAction(_prev: ActionState, formData: FormData): Promise<ActionState> {263 return guarded(async () => {264 const u = await requireUser('/account/data');265 await enforce(`delete:${u.id}`, 5, 3600);266 if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.');267 if (String(formData.get('confirm') ?? '').trim().toUpperCase() !== 'DELETE') return fail('Type DELETE to confirm.');268 const purgeAfter = new Date(Date.now() + 30 * 86_400_000);269 await db().update(users).set({ deletedAt: new Date(), purgeAfter }).where(eq(users.id, u.id));270 await revokeAllSessions(u.id, false);271 await revokeAllDevices(u.id);272 await db().update(trustedDevices).set({ revokedAt: new Date() }).where(eq(trustedDevices.userId, u.id));273 void sendMail({ to: u.email, ...accountDeletionEmail({ purgeAt: purgeAfter }) }).catch(() => {});274 await destroySession();275 redirect('/?deleted=1');276 });277}278