commit21
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export type CoordinationFilters = {
|
||||
record_search: string;
|
||||
record_state: string;
|
||||
};
|
||||
|
||||
export default function CoordinationRecordFilters({
|
||||
view,
|
||||
filters,
|
||||
states,
|
||||
}: {
|
||||
view: string;
|
||||
filters: CoordinationFilters;
|
||||
states: Record<string, string>;
|
||||
}) {
|
||||
const form = useForm({ view, ...filters });
|
||||
return (
|
||||
<details
|
||||
className="rounded border p-4"
|
||||
open={Boolean(filters.record_search || filters.record_state)}
|
||||
>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Localizar registos por artigo, local, referência ou estado
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.get('/compras/fornecimento', {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Artigo, local ou referência
|
||||
<input
|
||||
className={controlClass}
|
||||
maxLength={100}
|
||||
value={form.data.record_search}
|
||||
onChange={(event) =>
|
||||
form.setData('record_search', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Estado
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.record_state}
|
||||
onChange={(event) =>
|
||||
form.setData('record_state', event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(states).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Filtrar</Button>
|
||||
<Link
|
||||
className="text-sm underline"
|
||||
href={`/compras/fornecimento?view=${view}`}
|
||||
>
|
||||
Limpar filtros
|
||||
</Link>
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p role="alert" key={key}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function CorrectionSourceSearch({
|
||||
initial = '',
|
||||
search,
|
||||
title = 'Procurar origem pela referência',
|
||||
label = 'Número da recepção ou referência da operação',
|
||||
disabled = false,
|
||||
}: {
|
||||
initial?: string;
|
||||
search: (value: string) => void;
|
||||
title?: string;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<details className="rounded border p-3 md:col-span-2">
|
||||
<summary className="cursor-pointer text-sm">{title}</summary>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1 text-sm">
|
||||
{label}
|
||||
<input
|
||||
className={controlClass}
|
||||
maxLength={100}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!disabled) search(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
onClick={() => search(value)}
|
||||
>
|
||||
Procurar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
As origens elegíveis já seleccionadas e os dados introduzidos
|
||||
são conservados.
|
||||
</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export type CorrectionFilters = { search: string; state: string };
|
||||
|
||||
export default function InventoryCorrectionFilters({
|
||||
path,
|
||||
filters,
|
||||
}: {
|
||||
path: string;
|
||||
filters: CorrectionFilters;
|
||||
}) {
|
||||
const form = useForm({ search: filters.search, state: filters.state });
|
||||
return (
|
||||
<details
|
||||
className="rounded border p-4"
|
||||
open={Boolean(filters.search || filters.state)}
|
||||
>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Filtrar por referência ou estado
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.get(path, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Referência
|
||||
<input
|
||||
className={controlClass}
|
||||
maxLength={100}
|
||||
value={form.data.search}
|
||||
onChange={(event) =>
|
||||
form.setData('search', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Estado
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.state}
|
||||
onChange={(event) =>
|
||||
form.setData('state', event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="draft">Em preparação</option>
|
||||
<option value="returned">
|
||||
Devolvido para correcção
|
||||
</option>
|
||||
<option value="approved">Autorizado</option>
|
||||
<option value="executed">Executado</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button disabled={form.processing}>Filtrar</Button>
|
||||
<Link className="text-sm underline" href={path}>
|
||||
Limpar filtros
|
||||
</Link>
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p role="alert" key={key}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,14 @@ export type ProcurementInsights = {
|
||||
|
||||
export function ProcurementOperationalInsights({
|
||||
data,
|
||||
neededPastHref,
|
||||
withoutQuotesHref,
|
||||
withoutOrderHref,
|
||||
}: {
|
||||
data: ProcurementInsights;
|
||||
neededPastHref?: string;
|
||||
withoutQuotesHref?: string;
|
||||
withoutOrderHref?: string;
|
||||
}) {
|
||||
return (
|
||||
<Panel title="Leitura operacional de Compras">
|
||||
@@ -51,6 +57,16 @@ export function ProcurementOperationalInsights({
|
||||
precisa do bem ou serviço; não comprova atraso de
|
||||
entrega do fornecedor.
|
||||
</p>
|
||||
{neededPastHref &&
|
||||
data.unapproved_past_needed_date > 0 && (
|
||||
<Link
|
||||
className="inline-block underline"
|
||||
href={neededPastHref}
|
||||
>
|
||||
Consultar pedidos por aprovar com data de
|
||||
necessidade ultrapassada
|
||||
</Link>
|
||||
)}
|
||||
{data.pending.oldest.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
@@ -91,6 +107,27 @@ export function ProcurementOperationalInsights({
|
||||
comparáveis; ausência de ordem visível não comprova
|
||||
incumprimento.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{withoutQuotesHref &&
|
||||
data.approved.without_registered_quotes > 0 && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={withoutQuotesHref}
|
||||
>
|
||||
Consultar aprovadas sem propostas
|
||||
registadas
|
||||
</Link>
|
||||
)}
|
||||
{withoutOrderHref &&
|
||||
data.approved.without_visible_order > 0 && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={withoutOrderHref}
|
||||
>
|
||||
Consultar aprovadas sem ordem visível
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useForm } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
import { Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { date } from '@/types/procurement';
|
||||
|
||||
export type ReceiptDocument = {
|
||||
id: number;
|
||||
document_type: string;
|
||||
original_name: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
uploaded_by: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export default function ReceiptDocuments({
|
||||
receiptId,
|
||||
ready,
|
||||
canAdd,
|
||||
documents,
|
||||
}: {
|
||||
receiptId: number;
|
||||
ready: boolean;
|
||||
canAdd: boolean;
|
||||
documents: ReceiptDocument[];
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const form = useForm<{
|
||||
operation_key: string;
|
||||
document_type: string;
|
||||
file: File | null;
|
||||
}>({
|
||||
operation_key: crypto.randomUUID(),
|
||||
document_type: 'delivery_note',
|
||||
file: null,
|
||||
});
|
||||
const labels: Record<string, string> = {
|
||||
delivery_note: 'Guia de remessa',
|
||||
quality: 'Evidência de qualidade',
|
||||
other: 'Outro documento',
|
||||
};
|
||||
return (
|
||||
<Panel
|
||||
title="Anexos da recepção"
|
||||
description="Evidências ligadas à confirmação preservada. Anexar não altera quantidades, qualidade ou entradas de stock."
|
||||
>
|
||||
<div id="receipt-documents" />
|
||||
{!ready ? (
|
||||
<p className="text-sm">
|
||||
Anexos ainda indisponíveis nesta instalação.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{documents.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sem anexos nesta recepção.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-3">
|
||||
{documents.map((document) => (
|
||||
<li
|
||||
key={document.id}
|
||||
className="rounded border p-3 text-sm"
|
||||
>
|
||||
<a
|
||||
className="font-medium underline"
|
||||
href={`/recepcoes/${receiptId}/documentos/${document.id}`}
|
||||
>
|
||||
{document.original_name}
|
||||
</a>
|
||||
<p>
|
||||
{labels[document.document_type]} ·{' '}
|
||||
{Math.ceil(document.size / 1024)} KB ·
|
||||
Registado por #{document.uploaded_by} ·{' '}
|
||||
{date(document.created_at)}
|
||||
</p>
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer">
|
||||
Identificação do ficheiro preservado
|
||||
</summary>
|
||||
<code className="break-all">
|
||||
SHA-256 {document.sha256}
|
||||
</code>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canAdd && (
|
||||
<details className="mt-4 rounded border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Acrescentar evidência
|
||||
</summary>
|
||||
<form
|
||||
className="mt-3 grid gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.post(
|
||||
`/recepcoes/${receiptId}/documentos`,
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.setData({
|
||||
operation_key:
|
||||
crypto.randomUUID(),
|
||||
document_type:
|
||||
'delivery_note',
|
||||
file: null,
|
||||
});
|
||||
if (input.current)
|
||||
input.current.value = '';
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Tipo de documento
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.document_type}
|
||||
onChange={(event) =>
|
||||
form.setData(
|
||||
'document_type',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
{Object.entries(labels).map(
|
||||
([value, label]) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Ficheiro PDF, JPG ou PNG — até 2 MB
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
required
|
||||
onChange={(event) =>
|
||||
form.setData(
|
||||
'file',
|
||||
event.target.files?.[0] ?? null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ficheiros idênticos do mesmo tipo são
|
||||
reutilizados. Os documentos anteriores
|
||||
permanecem preservados.
|
||||
</p>
|
||||
{Object.entries(form.errors).map(
|
||||
([key, error]) => (
|
||||
<p role="alert" key={key}>
|
||||
{error}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
<Button disabled={form.processing}>
|
||||
Anexar à recepção
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
|
||||
export type SupplyPageInfo = {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
|
||||
export default function SupplyPagination({
|
||||
page,
|
||||
label = 'Paginação dos registos de fornecimento',
|
||||
}: {
|
||||
page?: SupplyPageInfo | null;
|
||||
label?: string;
|
||||
}) {
|
||||
if (!page) return null;
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
className="my-4 flex flex-wrap items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<span>
|
||||
{page.total} registos · Página {page.current_page} de{' '}
|
||||
{page.last_page}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
{page.prev_page_url && (
|
||||
<Link
|
||||
preserveScroll
|
||||
className="underline"
|
||||
href={page.prev_page_url}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{page.next_page_url && (
|
||||
<Link
|
||||
preserveScroll
|
||||
className="underline"
|
||||
href={page.next_page_url}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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