272 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|