'use server'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { revalidatePath } from 'next/cache'; import { and, eq, isNull, ne } from '@/lib/db'; import QRCode from 'qrcode'; import { z } from 'zod'; import { newId } from '@rareindex/shared'; import { sendMail, changeEmailEmail, accountDeletionEmail } from '@rareindex/notify'; import { db, users, sessions, recoveryCodes, trustedDevices, apiKeys } from '@/lib/db'; import { hashPassword, verifyPassword, encryptSecret, decryptSecret, hmacToken, recoveryCode, randomToken, signPayload, verifyPayload, sha256Hex } from './crypto'; import { issueCode, verifyCode } from './codes'; import { CURRENCY_COOKIE, DISPLAY_CURRENCIES, currentSessionId, destroySession, requireUser, revokeAllSessions } from './session'; import { revokeAllDevices, revokeDevice } from './devices'; import { enforce, RateLimited } from './rate-limit'; import { newTotpSecret, totpUri, verifyTotp } from './totp'; import { emailSchema, handleSchema, isReservedHandle, passwordSchema } from './validation'; import { fieldErrorsFrom, type ActionState } from './state'; function fail(error: string, extra: Partial = {}): ActionState { return { ok: false, error, ...extra }; } async function guarded(fn: () => Promise): Promise { try { return await fn(); } catch (err) { if (err instanceof RateLimited) return fail(err.message); throw err; } } // ---------------------------------------------------------------- profile & preferences const profileSchema = z.object({ name: z.string().trim().max(80).optional().or(z.literal('')), handle: z.union([z.literal(''), handleSchema]).optional(), bio: z.string().trim().max(280).optional().or(z.literal('')), displayCurrency: z.enum(DISPLAY_CURRENCIES), }); export async function updateProfileAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/account/settings'); 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 }); if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) }); const handle = parsed.data.handle ? parsed.data.handle : null; if (handle) { if (isReservedHandle(handle)) return fail('That handle is reserved.', { fieldErrors: { handle: 'Reserved' } }); const taken = await db().select({ id: users.id }).from(users).where(and(eq(users.handle, handle), ne(users.id, u.id))).limit(1); if (taken[0]) return fail('That handle is taken.', { fieldErrors: { handle: 'Already taken' } }); } 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)); (await cookies()).set(CURRENCY_COOKIE, parsed.data.displayCurrency, { path: '/', maxAge: 365 * 86400, sameSite: 'lax' }); revalidatePath('/account/settings'); return { ok: true, message: 'Profile saved.' }; } export async function setCurrencyAction(currency: string): Promise { if (!(DISPLAY_CURRENCIES as readonly string[]).includes(currency)) return; (await cookies()).set(CURRENCY_COOKIE, currency, { path: '/', maxAge: 365 * 86400, sameSite: 'lax' }); const u = await (await import('./session')).getCurrentUser(); if (u) await db().update(users).set({ displayCurrency: currency }).where(eq(users.id, u.id)); } const prefsSchema = z.object({ emailAlerts: z.boolean(), newLoginEmails: z.boolean(), digest: z.enum(['off', 'daily', 'weekly']), digestWeekday: z.coerce.number().int().min(0).max(6), quietStart: z.coerce.number().int().min(0).max(23).nullable(), quietEnd: z.coerce.number().int().min(0).max(23).nullable(), marketMoves: z.boolean(), }); export async function updateNotificationPrefsAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/account/notifications'); const quiet = formData.get('quiet') === 'on'; const parsed = prefsSchema.safeParse({ emailAlerts: formData.get('emailAlerts') === 'on', newLoginEmails: formData.get('newLoginEmails') === 'on', digest: formData.get('digest') ?? 'weekly', digestWeekday: formData.get('digestWeekday') ?? 1, quietStart: quiet ? formData.get('quietStart') : null, quietEnd: quiet ? formData.get('quietEnd') : null, marketMoves: formData.get('marketMoves') === 'on', }); if (!parsed.success) return fail('Check the fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) }); await db().update(users).set({ preferences: { ...(u.preferences ?? {}), ...parsed.data } }).where(eq(users.id, u.id)); revalidatePath('/account/notifications'); return { ok: true, message: 'Notification preferences saved.' }; } // ---------------------------------------------------------------- password & e-mail export async function changePasswordAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const u = await requireUser('/account/security'); await enforce(`chpw:${u.id}`, 10, 3600); const current = String(formData.get('current') ?? ''); const next = passwordSchema.safeParse(formData.get('password')); if (!next.success) return fail('Choose a stronger password.', { fieldErrors: { password: next.error.issues[0]?.message ?? 'Invalid' } }); if (!(await verifyPassword(current, u.passwordHash))) return fail('Current password is incorrect.', { fieldErrors: { current: 'Incorrect' } }); await db().update(users).set({ passwordHash: await hashPassword(next.data), passwordChangedAt: new Date() }).where(eq(users.id, u.id)); const n = await revokeAllSessions(u.id, true); revalidatePath('/account/security'); return { ok: true, message: `Password changed. ${n} other session${n === 1 ? '' : 's'} signed out.` }; }); } export async function startChangeEmailAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const u = await requireUser('/account/settings'); await enforce(`chmail:${u.id}`, 5, 3600); const email = emailSchema.safeParse(formData.get('newEmail')); if (!email.success) return fail('Enter a valid e-mail.', { fieldErrors: { newEmail: 'Invalid e-mail' } }); if (email.data === u.email) return fail('That is already your e-mail.'); if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.', { fieldErrors: { password: 'Incorrect' } }); const taken = await db().select({ id: users.id }).from(users).where(eq(users.email, email.data)).limit(1); if (taken[0]) return fail('That e-mail is already in use.', { fieldErrors: { newEmail: 'In use' } }); const issued = await issueCode({ email: email.data, purpose: 'change_email', userId: u.id, payload: { newEmail: email.data } }); if ('cooldownSeconds' in issued) return fail(`Wait ${issued.cooldownSeconds}s before requesting another code.`); await sendMail({ to: email.data, ...changeEmailEmail({ code: issued.code, minutes: issued.minutes, newEmail: email.data }) }); await db().update(users).set({ pendingEmail: email.data }).where(eq(users.id, u.id)); revalidatePath('/account/settings'); return { ok: true, message: `We sent a confirmation code to ${email.data}.`, data: { pendingEmail: email.data } }; }); } export async function confirmChangeEmailAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const u = await requireUser('/account/settings'); if (!u.pendingEmail) return fail('No e-mail change in progress.'); await enforce(`chmail-confirm:${u.id}`, 10, 900); const res = await verifyCode({ email: u.pendingEmail, purpose: 'change_email', code: String(formData.get('code') ?? '') }); if (!res.ok) return fail('That code is not valid or expired.'); await db().update(users).set({ email: u.pendingEmail, pendingEmail: null, emailVerifiedAt: new Date() }).where(eq(users.id, u.id)); revalidatePath('/account/settings'); return { ok: true, message: 'E-mail updated.' }; }); } export async function cancelChangeEmailAction(): Promise { const u = await requireUser('/account/settings'); await db().update(users).set({ pendingEmail: null }).where(eq(users.id, u.id)); revalidatePath('/account/settings'); } // ---------------------------------------------------------------- MFA const SETUP_COOKIE = 'ri_mfa_setup'; export async function startMfaSetupAction(_prev: ActionState): Promise { const u = await requireUser('/account/security'); if (u.mfaEnabled) return fail('Authenticator is already enabled.'); const secret = newTotpSecret(); const uri = totpUri(secret, u.email); const qr = await QRCode.toDataURL(uri, { margin: 1, width: 220, color: { dark: '#0b0b0c', light: '#ffffff' } }); (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 }); return { ok: true, data: { qr, secret, uri } }; } async function issueRecoveryCodes(userId: string): Promise { await db().delete(recoveryCodes).where(eq(recoveryCodes.userId, userId)); const codes = Array.from({ length: 10 }, () => recoveryCode()); await db().insert(recoveryCodes).values(codes.map((c) => ({ id: newId('event'), userId, codeHash: hmacToken(c, 'recovery') }))); return codes; } export async function confirmMfaSetupAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const u = await requireUser('/account/security'); await enforce(`mfa-setup:${u.id}`, 10, 900); const raw = (await cookies()).get(SETUP_COOKIE)?.value; const p = verifyPayload<{ uid: string; enc: string }>(raw); if (!p || p.uid !== u.id) return fail('Setup expired. Start again.'); const secret = decryptSecret(p.enc); if (verifyTotp(secret, String(formData.get('code') ?? '')) === null) return fail('That code does not match. Check the time on your device and try again.'); await db().update(users).set({ mfaEnabled: true, totpSecretEnc: p.enc }).where(eq(users.id, u.id)); const codes = await issueRecoveryCodes(u.id); (await cookies()).delete(SETUP_COOKIE); revalidatePath('/account/security'); return { ok: true, message: 'Authenticator enabled. Save your recovery codes now — they are shown once.', data: { recoveryCodes: codes } }; }); } export async function disableMfaAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const u = await requireUser('/account/security'); await enforce(`mfa-disable:${u.id}`, 10, 900); if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.'); if (!u.totpSecretEnc || verifyTotp(decryptSecret(u.totpSecretEnc), String(formData.get('code') ?? '')) === null) return fail('Authenticator code is not valid.'); await db().update(users).set({ mfaEnabled: false, totpSecretEnc: null }).where(eq(users.id, u.id)); await db().delete(recoveryCodes).where(eq(recoveryCodes.userId, u.id)); revalidatePath('/account/security'); return { ok: true, message: 'Authenticator disabled. E-mail codes remain active for new devices.' }; }); } export async function regenerateRecoveryCodesAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const u = await requireUser('/account/security'); await enforce(`recov:${u.id}`, 5, 3600); if (!u.mfaEnabled) return fail('Enable the authenticator first.'); if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.'); const codes = await issueRecoveryCodes(u.id); return { ok: true, message: 'New recovery codes generated. Previous codes no longer work.', data: { recoveryCodes: codes } }; }); } export async function toggleAlwaysAskCodeAction(formData: FormData): Promise { const u = await requireUser('/account/security'); await db().update(users).set({ alwaysAskCode: formData.get('alwaysAskCode') === 'on' }).where(eq(users.id, u.id)); revalidatePath('/account/security'); } export async function revokeSessionAction(formData: FormData): Promise { const u = await requireUser('/account/security'); const id = String(formData.get('sessionId') ?? ''); const current = await currentSessionId(); if (id && id !== current) await db().update(sessions).set({ revokedAt: new Date() }).where(and(eq(sessions.id, id), eq(sessions.userId, u.id))); revalidatePath('/account/security'); } export async function revokeDeviceAction(formData: FormData): Promise { const u = await requireUser('/account/security'); await revokeDevice(u.id, String(formData.get('deviceId') ?? '')); revalidatePath('/account/security'); } export async function signOutEverywhereAction(): Promise { const u = await requireUser('/account/security'); await revokeAllSessions(u.id, true); await revokeAllDevices(u.id); revalidatePath('/account/security'); } // ---------------------------------------------------------------- API keys export async function createApiKeyAction(_prev: ActionState, formData: FormData): Promise { const u = await requireUser('/account/api-keys'); const name = z.string().trim().min(1).max(60).safeParse(formData.get('name')); if (!name.success) return fail('Give the key a name.', { fieldErrors: { name: 'Required' } }); const existing = await db().select({ id: apiKeys.id }).from(apiKeys).where(and(eq(apiKeys.userId, u.id), isNull(apiKeys.revokedAt))); if (existing.length >= 10) return fail('You can hold at most 10 active keys.'); const secret = `ri_${u.role === 'admin' || u.role === 'pro' ? 'live' : 'free'}_${randomToken(24)}`; const prefix = secret.slice(0, 14); const tier = u.role === 'admin' ? 'enterprise' : u.role === 'pro' ? 'professional' : 'free'; const limits = tier === 'enterprise' ? { rpm: 600, daily: 100_000 } : tier === 'professional' ? { rpm: 120, daily: 10_000 } : { rpm: 30, daily: 1_000 }; 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 }); revalidatePath('/account/api-keys'); return { ok: true, message: 'Key created. Copy it now — it will not be shown again.', data: { secret } }; } export async function revokeApiKeyAction(formData: FormData): Promise { const u = await requireUser('/account/api-keys'); await db().update(apiKeys).set({ revokedAt: new Date() }).where(and(eq(apiKeys.id, String(formData.get('keyId') ?? '')), eq(apiKeys.userId, u.id))); revalidatePath('/account/api-keys'); } // ---------------------------------------------------------------- deletion export async function deleteAccountAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const u = await requireUser('/account/data'); await enforce(`delete:${u.id}`, 5, 3600); if (!(await verifyPassword(String(formData.get('password') ?? ''), u.passwordHash))) return fail('Password is incorrect.'); if (String(formData.get('confirm') ?? '').trim().toUpperCase() !== 'DELETE') return fail('Type DELETE to confirm.'); const purgeAfter = new Date(Date.now() + 30 * 86_400_000); await db().update(users).set({ deletedAt: new Date(), purgeAfter }).where(eq(users.id, u.id)); await revokeAllSessions(u.id, false); await revokeAllDevices(u.id); await db().update(trustedDevices).set({ revokedAt: new Date() }).where(eq(trustedDevices.userId, u.id)); void sendMail({ to: u.email, ...accountDeletionEmail({ purgeAt: purgeAfter }) }).catch(() => {}); await destroySession(); redirect('/?deleted=1'); }); }