Files
compras/resources/js/components/two-factor-management.tsx
2026-11-03 11:51:44 +02:00

272 lines
10 KiB
TypeScript

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
/** Secrets stay in this mounted component, never in page props or remembered state. */
export default function TwoFactorManagement({
enabled: initialEnabled,
pending: initialPending,
}: {
enabled: boolean;
pending: boolean;
}) {
const [enabled, setEnabled] = useState(initialEnabled);
const [pending, setPending] = useState(initialPending);
const [secret, setSecret] = useState('');
const [code, setCode] = useState('');
const [codes, setCodes] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [confirm, setConfirm] = useState<'disable' | 'regenerate' | null>(
null,
);
async function request(
path: string,
method = 'GET',
data?: Record<string, string>,
) {
const token = document.cookie
.split('; ')
.find((cookie) => cookie.startsWith('XSRF-TOKEN='))
?.slice(11);
const response = await fetch(path, {
method,
credentials: 'same-origin',
cache: 'no-store',
headers: {
Accept: 'application/json',
...(method !== 'GET'
? {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(token ?? ''),
}
: {}),
},
...(data ? { body: JSON.stringify(data) } : {}),
});
if (response.status === 423)
throw new Error(
'Confirme novamente a palavra-passe, abrindo esta página outra vez.',
);
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(
response.status === 422
? (body?.errors?.code?.[0] ??
'Confirme os dados introduzidos.')
: 'Não foi possível concluir. Actualize a página e tente novamente.',
);
}
return response.status === 204 ? null : response.json();
}
async function run(action: () => Promise<void>) {
setBusy(true);
setError('');
try {
await action();
} catch (failure) {
setError(
failure instanceof Error
? failure.message
: 'Não foi possível concluir.',
);
} finally {
setBusy(false);
}
}
const loadSecret = async () => {
const data = await request('/user/two-factor-secret-key');
setSecret(data.secretKey);
};
const loadCodes = async () => {
setCodes(await request('/user/two-factor-recovery-codes'));
};
return (
<section
className="space-y-4 rounded border p-4"
aria-labelledby="mfa-heading"
>
<h2 id="mfa-heading" className="font-semibold">
Autenticação de dois factores
</h2>
<p className="text-sm">
{enabled
? 'Activa. Nos próximos acessos será pedido o código da aplicação ou um código de recuperação.'
: pending
? 'Adesão por confirmar. O segundo factor só será activado depois de confirmar um código válido.'
: 'Acrescente um código da aplicação autenticadora à palavra-passe desta conta.'}
</p>
{error && (
<p role="alert" className="text-sm">
{error}{' '}
<a className="underline" href="/settings/security">
Reabrir segurança da conta
</a>
</p>
)}
{!enabled && !pending && (
<Button
disabled={busy}
onClick={() =>
void run(async () => {
await request(
'/user/two-factor-authentication',
'POST',
);
setPending(true);
await loadSecret();
})
}
>
Iniciar adesão
</Button>
)}
{pending && !secret && (
<Button disabled={busy} onClick={() => void run(loadSecret)}>
Continuar adesão
</Button>
)}
{pending && secret && (
<>
<p className="text-sm">
Na aplicação autenticadora, adicione uma conta com esta
chave manual. Não partilhe esta chave.
</p>
<code className="block rounded bg-muted p-3 break-all">
{secret}
</code>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void run(async () => {
await request(
'/user/confirmed-two-factor-authentication',
'POST',
{ code },
);
setEnabled(true);
setPending(false);
setSecret('');
setCode('');
await loadCodes();
});
}}
>
<label className="grid gap-2 text-sm">
Código da aplicação
<Input
value={code}
onChange={(event) =>
setCode(event.target.value)
}
autoComplete="one-time-code"
inputMode="numeric"
required
/>
</label>
<Button disabled={busy}>
Confirmar e mostrar códigos de recuperação
</Button>
</form>
</>
)}
{enabled && (
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
disabled={busy}
onClick={() => {
if (codes.length) setCodes([]);
else void run(loadCodes);
}}
>
{codes.length
? 'Ocultar códigos'
: 'Mostrar códigos de recuperação'}
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() => setConfirm('regenerate')}
>
Renovar códigos
</Button>
</div>
)}
{codes.length > 0 && (
<div className="space-y-2">
<p className="text-sm">
Guarde estes códigos num local privado. Cada um permite
recuperar um acesso e só pode ser usado uma vez.
</p>
<ul className="grid gap-2 font-mono sm:grid-cols-2">
{codes.map((value) => (
<li key={value}>{value}</li>
))}
</ul>
</div>
)}
{(enabled || pending) && (
<Button
variant="outline"
disabled={busy}
onClick={() => setConfirm('disable')}
>
{pending ? 'Cancelar adesão' : 'Desactivar segundo factor'}
</Button>
)}
{confirm && (
<div
className="space-y-3 rounded border p-3"
role="group"
aria-label="Confirmar alteração de segurança"
>
<p className="text-sm">
{confirm === 'disable'
? 'Esta conta deixará de exigir o segundo factor. A palavra-passe permanece necessária.'
: 'Os códigos anteriores deixarão de funcionar. Guarde os novos códigos depois de confirmar.'}
</p>
<Button
disabled={busy}
onClick={() =>
void run(async () => {
if (confirm === 'disable') {
await request(
'/user/two-factor-authentication',
'DELETE',
);
setEnabled(false);
setPending(false);
setSecret('');
setCode('');
setCodes([]);
} else {
setCodes([]);
await request(
'/user/two-factor-recovery-codes',
'POST',
);
await loadCodes();
}
setConfirm(null);
})
}
>
Confirmar{' '}
{confirm === 'disable' ? 'desactivação' : 'renovação'}
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() => setConfirm(null)}
>
Voltar
</Button>
</div>
)}
</section>
);
}