commit21
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user