TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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