SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
15.8 KB · 278 lines typescript
Raw Blame History
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