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%
6.4 KB · 142 lines tsx
Raw Blame History
1'use client';23import { useActionState, useState } from 'react';4import { changePasswordAction, confirmMfaSetupAction, disableMfaAction, regenerateRecoveryCodesAction, startMfaSetupAction } from '@/lib/auth/account-actions';5import { idle } from '@/lib/auth/state';6import { CodeInput, Field, FormMessage, PasswordField, SubmitButton, TextInput } from '@/components/account/form';78export function ChangePasswordForm() {9  const [state, action] = useActionState(changePasswordAction, idle);10  return (11    <form action={action} className="space-y-4" noValidate>12      <FormMessage state={state} />13      <Field label="Current password" name="current" error={state.fieldErrors?.current}>14        <TextInput name="current" type="password" autoComplete="current-password" required />15      </Field>16      <PasswordField name="password" label="New password" autoComplete="new-password" error={state.fieldErrors?.password} />17      <SubmitButton variant="secondary" pendingText="Updating…">18        Change password19      </SubmitButton>20      <p className="text-xs text-subtle">Changing your password signs out every other session.</p>21    </form>22  );23}2425function RecoveryCodes({ codes }: { codes: string[] }) {26  const [copied, setCopied] = useState(false);27  return (28    <div className="rounded-md border border-alert/40 bg-alert-bg p-3">29      <p className="text-xs font-medium text-alert">Recovery codes — shown once. Store them somewhere safe.</p>30      <ul className="mono-num mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-sm">31        {codes.map((c) => (32          <li key={c}>{c}</li>33        ))}34      </ul>35      <div className="mt-3 flex gap-2">36        <button37          type="button"38          className="h-8 rounded-md border border-border bg-elevated px-2.5 text-xs font-medium"39          onClick={async () => {40            await navigator.clipboard.writeText(codes.join('\n'));41            setCopied(true);42          }}43        >44          {copied ? 'Copied' : 'Copy'}45        </button>46        <a className="h-8 rounded-md border border-border bg-elevated px-2.5 text-xs font-medium leading-8" href={`data:text/plain;charset=utf-8,${encodeURIComponent(`RareIndex recovery codes\n\n${codes.join('\n')}\n`)}`} download="rareindex-recovery-codes.txt">47          Download .txt48        </a>49      </div>50    </div>51  );52}5354export function MfaSection({ enabled }: { enabled: boolean }) {55  const [start, startAction] = useActionState(startMfaSetupAction, idle);56  const [confirm, confirmAction] = useActionState(confirmMfaSetupAction, idle);57  const [disable, disableAction] = useActionState(disableMfaAction, idle);58  const [regen, regenAction] = useActionState(regenerateRecoveryCodesAction, idle);59  const [showDisable, setShowDisable] = useState(false);60  const [showRegen, setShowRegen] = useState(false);61  const codes = (confirm.data?.recoveryCodes as string[] | undefined) ?? (regen.data?.recoveryCodes as string[] | undefined);6263  if (confirm.ok && codes) {64    return (65      <div className="space-y-3">66        <FormMessage state={confirm} />67        <RecoveryCodes codes={codes} />68      </div>69    );70  }7172  if (!enabled) {73    if (start.ok && start.data) {74      return (75        <form action={confirmAction} className="space-y-4" noValidate>76          <FormMessage state={confirm} />77          <div className="flex flex-col gap-4 sm:flex-row">78            {/* eslint-disable-next-line @next/next/no-img-element */}79            <img src={String(start.data.qr)} alt="Authenticator QR code" width={180} height={180} className="rounded-md border border-border bg-white p-1" />80            <div className="text-xs text-muted">81              <p>1. Open your authenticator (1Password, Google Authenticator, Authy, iCloud Keychain…).</p>82              <p className="mt-1">2. Scan the QR code, or enter this key manually:</p>83              <p className="mono-num mt-1 break-all rounded-sm bg-inset px-2 py-1 text-[11px] text-fg">{String(start.data.secret)}</p>84              <p className="mt-1">3. Enter the 6-digit code it shows.</p>85            </div>86          </div>87          <CodeInput name="code" autoSubmit={false} label="Code from the app" />88          <SubmitButton pendingText="Verifying…">Enable authenticator</SubmitButton>89        </form>90      );91    }92    return (93      <form action={startAction} className="space-y-3">94        <FormMessage state={start} />95        <p className="text-sm text-muted">Time-based codes from an authenticator app, plus 10 single-use recovery codes. E-mail codes remain available as a fallback.</p>96        <SubmitButton pendingText="Preparing…">Set up authenticator</SubmitButton>97      </form>98    );99  }100101  return (102    <div className="space-y-4">103      <FormMessage state={regen} />104      {regen.ok && codes ? <RecoveryCodes codes={codes} /> : null}105      <div className="flex flex-wrap gap-2">106        <button type="button" className="h-8 rounded-md border border-border bg-elevated px-2.5 text-xs font-medium" onClick={() => setShowRegen((s) => !s)}>107          New recovery codes108        </button>109        <button type="button" className="h-8 rounded-md px-2.5 text-xs font-medium text-loss hover:bg-loss-bg" onClick={() => setShowDisable((s) => !s)}>110          Disable authenticator111        </button>112      </div>113      {showRegen ? (114        <form action={regenAction} className="space-y-3 rounded-md border border-border p-3" noValidate>115          <Field label="Confirm with your password" name="password">116            <TextInput name="password" type="password" autoComplete="current-password" required />117          </Field>118          <SubmitButton variant="secondary" pendingText="Generating…">119            Generate 10 new codes120          </SubmitButton>121        </form>122      ) : null}123      {showDisable ? (124        <form action={disableAction} className="space-y-3 rounded-md border border-loss/30 p-3" noValidate>125          <FormMessage state={disable} />126          <div className="grid gap-3 sm:grid-cols-2">127            <Field label="Password" name="password">128              <TextInput name="password" type="password" autoComplete="current-password" required />129            </Field>130            <Field label="Authenticator code" name="code">131              <TextInput name="code" inputMode="numeric" autoComplete="one-time-code" maxLength={6} required className="mono-num" />132            </Field>133          </div>134          <SubmitButton variant="danger" pendingText="Disabling…">135            Disable two-step authenticator136          </SubmitButton>137        </form>138      ) : null}139    </div>140  );141}142