695 lines
30 KiB
TypeScript
695 lines
30 KiB
TypeScript
import { Head, Link, router } from '@inertiajs/react';
|
|
import { useState } from 'react';
|
|
import ProfileNavigation from '@/components/profile-navigation';
|
|
import { createOperationKey } from '@/lib/operation-key';
|
|
import { profileJson } from '@/lib/profile-json';
|
|
import { button, control } from '@/pages/management/shared';
|
|
|
|
type Entry = {
|
|
id: string;
|
|
user_id: number;
|
|
status: string;
|
|
version: number;
|
|
opportunity_types: string;
|
|
document_ids: string;
|
|
reason: string;
|
|
};
|
|
type SecurityEntry = {
|
|
id: string;
|
|
kind: string;
|
|
requested_by: number;
|
|
subject_user_id: number;
|
|
scope: string;
|
|
reason: string;
|
|
approved_by: number | null;
|
|
consumed_at: string | null;
|
|
expires_at: string;
|
|
};
|
|
type Pair = { first_user_id: number; second_user_id: number; version: number };
|
|
type OperatorOrganization = { id: string; name: string; nuit: string; contact: string; director: string };
|
|
|
|
function OperatorOrganizationForm() {
|
|
const [operationKey] = useState(createOperationKey);
|
|
const [name, setName] = useState('Tempo Lda');
|
|
const [nuit, setNuit] = useState('400993114');
|
|
const [contact, setContact] = useState('+258868010880');
|
|
const [director, setDirector] = useState('Osmane Nala');
|
|
const [source, setSource] = useState('Dados e designação interna confirmados pelo responsável da Tempo em 11/10/2026.');
|
|
const [busy, setBusy] = useState(false);
|
|
const [message, setMessage] = useState('');
|
|
async function save() {
|
|
setBusy(true);
|
|
try {
|
|
const result = await profileJson('/perfis/organizacao-operadora', { operation_key: operationKey, name, nuit, contact, director, function: 'portal_owner_operator', source_reference: source });
|
|
setMessage(result.message); router.reload();
|
|
} catch (error) { setMessage(error instanceof Error ? error.message : 'Resposta indisponível.'); }
|
|
finally { setBusy(false); }
|
|
}
|
|
return <section className="space-y-3" aria-label="Organização operadora">
|
|
<h2 className="text-xl font-semibold">Organização proprietária e operadora</h2>
|
|
<p>Reutiliza o cadastro mestre e conserva o histórico. Estes dados não atribuem representação empresarial nem papéis às contas.</p>
|
|
<form className="max-w-2xl space-y-3" onSubmit={e => { e.preventDefault(); void save(); }}>
|
|
<label className="block">Denominação<input required className={control} value={name} onChange={e => setName(e.target.value)} /></label>
|
|
<label className="block">NUIT<input required className={control} value={nuit} onChange={e => setNuit(e.target.value)} pattern="[0-9]{9}" /></label>
|
|
<label className="block">Contacto<input required type="tel" className={control} value={contact} onChange={e => setContact(e.target.value)} /></label>
|
|
<label className="block">Director Geral<input required className={control} value={director} onChange={e => setDirector(e.target.value)} /></label>
|
|
<label className="block">Origem dos dados<textarea aria-label="Origem dos dados" required minLength={20} maxLength={1000} className={control} value={source} onChange={e => setSource(e.target.value)} /></label>
|
|
<button className={button} disabled={busy}>Guardar organização operadora</button>
|
|
<p role="status">{message}</p>
|
|
</form>
|
|
</section>;
|
|
}
|
|
|
|
function InternalStaffGrant({ types, organizations, operators }: { types: string[]; organizations: { id: string; name: string }[]; operators: OperatorOrganization[] }) {
|
|
const [operationKey, setOperationKey] = useState(createOperationKey);
|
|
const [subject, setSubject] = useState('');
|
|
const [role, setRole] = useState('promoter');
|
|
const [operator, setOperator] = useState('');
|
|
const [scope, setScope] = useState('');
|
|
const [selected, setSelected] = useState<string[]>([]);
|
|
const [confirmed, setConfirmed] = useState(false);
|
|
const [reason, setReason] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [message, setMessage] = useState('');
|
|
async function save() {
|
|
setBusy(true);
|
|
try {
|
|
await profileJson('/perfis/atribuicoes', { internal_designation: true, operation_key: operationKey,
|
|
user_id: Number(subject), role, operator_organization_id: operator,
|
|
organization_id: role === 'promoter' ? operator : scope || null,
|
|
opportunity_types: selected, responsibilities: [], reason, confirmed_internal_designation: confirmed });
|
|
setMessage('Designação interna atribuída e auditada.'); router.reload();
|
|
} catch (error) { setMessage(error instanceof Error ? error.message : 'Resposta indisponível.'); }
|
|
finally { setBusy(false); }
|
|
}
|
|
return <section className="space-y-3" aria-label="Designação interna">
|
|
<h2 className="text-xl font-semibold">Atribuir função interna do portal</h2>
|
|
<p>A proprietária define as funções da sua equipa. Esta atribuição não exige comprovativos de Promotor externo nem concede gestão empresarial. Cada oportunidade conserva o promotor real e a autorização de divulgação.</p>
|
|
<form className="max-w-2xl space-y-3" onSubmit={e => { e.preventDefault(); void save(); }}>
|
|
<label className="block">Conta da pessoa designada<input required type="number" min={1} className={control} value={subject} onChange={e => setSubject(e.target.value)} /></label>
|
|
<label className="block">Função interna<select aria-label="Função interna" required className={control} value={role} onChange={e => setRole(e.target.value)}>{['promoter', 'validator', 'publisher'].map(v => <option key={v} value={v}>{v === 'promoter' ? 'Promotor' : v === 'validator' ? 'Validador' : 'Publicador'}</option>)}</select></label>
|
|
<label className="block">Vínculo à organização operadora<select aria-label="Vínculo à organização operadora" required className={control} value={operator} onChange={e => setOperator(e.target.value)}><option value="">Selecionar organização</option>{operators.map(o => <option key={o.id} value={o.id}>{o.name} — NUIT {o.nuit}</option>)}</select></label>
|
|
{role !== 'promoter' && <label className="block">Âmbito da função interna<select aria-label="Âmbito da função interna" className={control} value={scope} onChange={e => setScope(e.target.value)}><option value="">Todas as organizações para as categorias selecionadas</option>{organizations.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}</select></label>}
|
|
<fieldset><legend>Categorias da função interna</legend>{types.map(v => <label className="block" key={v}><input type="checkbox" checked={selected.includes(v)} onChange={() => setSelected(old => old.includes(v) ? old.filter(x => x !== v) : [...old, v])} /> {v}</label>)}</fieldset>
|
|
<label className="block"><input type="checkbox" required checked={confirmed} onChange={e => setConfirmed(e.target.checked)} /> Confirmo a designação interna expressa pelo responsável da organização operadora.</label>
|
|
<label className="block">Fundamento da designação interna<textarea aria-label="Fundamento da designação interna" required minLength={20} maxLength={1000} className={control} value={reason} onChange={e => setReason(e.target.value)} /></label>
|
|
<button className={button} disabled={busy}>Atribuir função interna com auditoria</button>
|
|
<button type="button" className={button} disabled={busy} onClick={() => { setOperationKey(createOperationKey()); setSubject(''); setConfirmed(false); setMessage('Nova atribuição preparada.'); }}>Preparar outra atribuição</button>
|
|
<p role="status">{message}</p>
|
|
</form>
|
|
</section>;
|
|
}
|
|
|
|
function Grant({
|
|
types,
|
|
organizations,
|
|
}: {
|
|
types: string[];
|
|
organizations: { id: string; name: string }[];
|
|
}) {
|
|
const [user, setUser] = useState('');
|
|
const [role, setRole] = useState('validator');
|
|
const [organization, setOrganization] = useState('');
|
|
const [selected, setSelected] = useState<string[]>([]);
|
|
const [responsibilities, setResponsibilities] = useState<string[]>([]);
|
|
const [reason, setReason] = useState('');
|
|
const [expires, setExpires] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const available =
|
|
role === 'validator'
|
|
? [
|
|
'validation_coordinator',
|
|
'technical_reviewer',
|
|
'legal_reviewer',
|
|
'appeal_reviewer',
|
|
]
|
|
: role === 'publisher'
|
|
? ['publication_coordinator']
|
|
: [];
|
|
const toggle = (values: string[], value: string) =>
|
|
values.includes(value)
|
|
? values.filter((v) => v !== value)
|
|
: [...values, value];
|
|
async function send() {
|
|
setBusy(true);
|
|
try {
|
|
await profileJson('/perfis/atribuicoes', {
|
|
user_id: Number(user),
|
|
role,
|
|
organization_id: organization || null,
|
|
opportunity_types: selected,
|
|
responsibilities,
|
|
expires_at: expires || null,
|
|
reason,
|
|
});
|
|
setMessage('Atribuição registada.');
|
|
router.reload();
|
|
} catch (error) {
|
|
setMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Resposta indisponível.',
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
return (
|
|
<section className="space-y-3">
|
|
<h2 className="text-xl font-semibold">
|
|
Atribuir papel operacional
|
|
</h2>
|
|
<p>
|
|
Promotor externo usa o pedido documental. Funções internas usam a designação da proprietária. Superutilizador é
|
|
gerido pela dupla, sem terceira atribuição.
|
|
</p>
|
|
<form
|
|
className="max-w-2xl space-y-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void send();
|
|
}}
|
|
>
|
|
<label className="block">
|
|
ID da conta existente
|
|
<input
|
|
className={control}
|
|
type="number"
|
|
min={1}
|
|
required
|
|
value={user}
|
|
onChange={(e) => setUser(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
Papel
|
|
<select
|
|
aria-label="Papel"
|
|
className={control}
|
|
value={role}
|
|
onChange={(e) => {
|
|
setRole(e.target.value);
|
|
setResponsibilities([]);
|
|
}}
|
|
>
|
|
{['validator', 'publisher', 'auditor'].map((v) => (
|
|
<option key={v}>{v}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
Âmbito organizacional
|
|
<select
|
|
aria-label="Âmbito organizacional"
|
|
className={control}
|
|
value={organization}
|
|
onChange={(e) => setOrganization(e.target.value)}
|
|
>
|
|
<option value="">
|
|
Todos os âmbitos para os tipos selecionados
|
|
</option>
|
|
{organizations.map((o) => (
|
|
<option key={o.id} value={o.id}>
|
|
{o.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<fieldset>
|
|
<legend>Tipos autorizados</legend>
|
|
{types.map((v) => (
|
|
<label className="block" key={v}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.includes(v)}
|
|
onChange={() =>
|
|
setSelected(toggle(selected, v))
|
|
}
|
|
/>{' '}
|
|
{v}
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
<fieldset>
|
|
<legend>Responsabilidades adicionais</legend>
|
|
{available.map((v) => (
|
|
<label className="block" key={v}>
|
|
<input
|
|
type="checkbox"
|
|
checked={responsibilities.includes(v)}
|
|
onChange={() =>
|
|
setResponsibilities(
|
|
toggle(responsibilities, v),
|
|
)
|
|
}
|
|
/>{' '}
|
|
{v}
|
|
</label>
|
|
))}
|
|
<p>
|
|
Atribuir a responsabilidade não conclui filas, recursos
|
|
ou escalamentos ainda em desenvolvimento.
|
|
</p>
|
|
</fieldset>
|
|
<label className="block">
|
|
Validade opcional
|
|
<input
|
|
className={control}
|
|
type="datetime-local"
|
|
value={expires}
|
|
onChange={(e) => setExpires(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
Fundamento
|
|
<textarea
|
|
required
|
|
className={control}
|
|
minLength={20}
|
|
maxLength={1000}
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
/>
|
|
</label>
|
|
<button className={button} disabled={busy}>
|
|
Atribuir com auditoria
|
|
</button>
|
|
<p role="status">{message}</p>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
function Security({
|
|
pair,
|
|
entries,
|
|
actorId,
|
|
}: {
|
|
pair: Pair;
|
|
entries: SecurityEntry[];
|
|
actorId: number;
|
|
}) {
|
|
const [old, setOld] = useState(String(pair.second_user_id));
|
|
const [candidate, setCandidate] = useState('');
|
|
const [reason, setReason] = useState('');
|
|
const [action, setAction] = useState('edit');
|
|
const [record, setRecord] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
async function send(path: string, data: object) {
|
|
setBusy(true);
|
|
try {
|
|
const result = await profileJson(path, data);
|
|
setMessage(result.message ?? `Pedido registado: ${result.id}`);
|
|
router.reload();
|
|
} catch (error) {
|
|
setMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Resposta indisponível.',
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
return (
|
|
<section className="space-y-3">
|
|
<h2 className="text-xl font-semibold">Operações críticas</h2>
|
|
<label className="block">
|
|
Fundamento
|
|
<textarea
|
|
className={control}
|
|
minLength={20}
|
|
maxLength={1000}
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
/>
|
|
</label>
|
|
<form
|
|
className="max-w-2xl space-y-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void send('/perfis/substituicoes', {
|
|
old_user_id: Number(old),
|
|
new_user_id: Number(candidate),
|
|
reason,
|
|
});
|
|
}}
|
|
>
|
|
<label className="block">
|
|
Titular a substituir
|
|
<select
|
|
className={control}
|
|
value={old}
|
|
onChange={(e) => setOld(e.target.value)}
|
|
>
|
|
{[pair.first_user_id, pair.second_user_id].map((v) => (
|
|
<option key={v}>{v}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
ID do candidato com MFA confirmado
|
|
<input
|
|
className={control}
|
|
type="number"
|
|
min={1}
|
|
required
|
|
value={candidate}
|
|
onChange={(e) => setCandidate(e.target.value)}
|
|
/>
|
|
</label>
|
|
<button className={button} disabled={busy}>
|
|
Preparar substituição atómica
|
|
</button>
|
|
</form>
|
|
<form
|
|
className="max-w-2xl space-y-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void send('/perfis/elevacoes', {
|
|
action,
|
|
record_id: record ? Number(record) : null,
|
|
reason,
|
|
});
|
|
}}
|
|
>
|
|
<label className="block">
|
|
Operação a elevar
|
|
<select
|
|
className={control}
|
|
value={action}
|
|
onChange={(e) => setAction(e.target.value)}
|
|
>
|
|
{[
|
|
'view',
|
|
'create',
|
|
'edit',
|
|
'validate',
|
|
'publish',
|
|
'archive',
|
|
].map((v) => (
|
|
<option key={v} value={v}>{v === 'view' ? 'Consultar dossier e anexos' : v}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
ID da oportunidade
|
|
<input
|
|
className={control}
|
|
type="number"
|
|
min={1}
|
|
required={action !== 'create'}
|
|
value={record}
|
|
onChange={(e) => setRecord(e.target.value)}
|
|
/>
|
|
</label>
|
|
<button className={button} disabled={busy}>
|
|
Pedir elevação temporária
|
|
</button>
|
|
<p>
|
|
Exige aprovação do outro titular e papel operacional válido.
|
|
Não remove impedimentos de autoria ou Auditor.
|
|
</p>
|
|
</form>
|
|
{entries.map((entry) => (
|
|
<article key={entry.id} className="space-y-2 border p-3">
|
|
<p>
|
|
{entry.kind} — {entry.id} — solicitante{' '}
|
|
{entry.requested_by}, destinatário{' '}
|
|
{entry.subject_user_id}
|
|
</p>
|
|
<p>
|
|
{entry.reason}; validade: {entry.expires_at}
|
|
</p>
|
|
<p>Âmbito: {entry.scope}</p>
|
|
{!entry.consumed_at &&
|
|
!entry.approved_by &&
|
|
entry.requested_by !== actorId &&
|
|
entry.subject_user_id !== actorId && (
|
|
<button
|
|
disabled={busy}
|
|
className={button}
|
|
onClick={() =>
|
|
void send(
|
|
`/perfis/seguranca/${entry.id}/aprovar`,
|
|
{},
|
|
)
|
|
}
|
|
>
|
|
Aprovar independentemente
|
|
</button>
|
|
)}
|
|
{!entry.consumed_at &&
|
|
entry.approved_by &&
|
|
entry.requested_by === actorId &&
|
|
entry.kind === 'replacement' && (
|
|
<button
|
|
disabled={busy}
|
|
className={button}
|
|
onClick={() =>
|
|
void send(
|
|
`/perfis/substituicoes/${entry.id}/aplicar`,
|
|
{},
|
|
)
|
|
}
|
|
>
|
|
Aplicar substituição aprovada
|
|
</button>
|
|
)}
|
|
<p>
|
|
{entry.consumed_at
|
|
? 'Aplicado'
|
|
: entry.approved_by
|
|
? `Aprovado pela conta ${entry.approved_by}`
|
|
: 'Aguarda aprovação independente'}
|
|
</p>
|
|
</article>
|
|
))}
|
|
<p role="status">{message}</p>
|
|
</section>
|
|
);
|
|
}
|
|
function Decision({ entry }: { entry: Entry }) {
|
|
const [reason, setReason] = useState('');
|
|
const [verified, setVerified] = useState(false);
|
|
const [message, setMessage] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const types: string[] = JSON.parse(entry.opportunity_types);
|
|
const [approved, setApproved] = useState<string[]>([]);
|
|
async function send(decision: string) {
|
|
setBusy(true);
|
|
try {
|
|
await profileJson(`/perfis/pedidos/${entry.id}/decisao`, {
|
|
version: entry.version,
|
|
decision,
|
|
reason,
|
|
approved_types: approved,
|
|
verified_identity: verified,
|
|
verified_representation: verified,
|
|
verified_documents: verified,
|
|
});
|
|
router.reload();
|
|
} catch (error) {
|
|
setMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Resposta indisponível.',
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
return (
|
|
<article className="space-y-2 rounded border p-4">
|
|
<h3>
|
|
Pedido {entry.id} — conta {entry.user_id} — {entry.status}
|
|
</h3>
|
|
<p>{entry.reason}</p>
|
|
<p>
|
|
Comprovativos canónicos:{' '}
|
|
{(JSON.parse(entry.document_ids) as string[]).map((id) => (
|
|
<Link
|
|
className="mr-3 underline"
|
|
key={id}
|
|
href={`/perfis/pedidos/${entry.id}/documentos/${id}`}
|
|
target="_blank"
|
|
>
|
|
Consultar {id}
|
|
</Link>
|
|
))}
|
|
</p>
|
|
{entry.status === 'pending' && (
|
|
<>
|
|
<fieldset>
|
|
<legend>Tipos efetivamente aprovados</legend>
|
|
{types.map((type) => (
|
|
<label className="block" key={type}>
|
|
<input
|
|
type="checkbox"
|
|
checked={approved.includes(type)}
|
|
onChange={() =>
|
|
setApproved((v) =>
|
|
v.includes(type)
|
|
? v.filter((x) => x !== type)
|
|
: [...v, type],
|
|
)
|
|
}
|
|
/>{' '}
|
|
{type}
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
<label className="block">
|
|
<input
|
|
type="checkbox"
|
|
checked={verified}
|
|
onChange={(e) => setVerified(e.target.checked)}
|
|
/>{' '}
|
|
Conferi identidade, representação e comprovativos; esta
|
|
declaração fica auditada.
|
|
</label>
|
|
<label className="block">
|
|
Fundamento
|
|
<textarea
|
|
className={control}
|
|
minLength={20}
|
|
maxLength={1000}
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
/>
|
|
</label>
|
|
<button
|
|
className={button}
|
|
disabled={busy}
|
|
onClick={() => void send('approved')}
|
|
>
|
|
Aprovar tipos selecionados
|
|
</button>{' '}
|
|
<button
|
|
className={button}
|
|
disabled={busy}
|
|
onClick={() => void send('rejected')}
|
|
>
|
|
Rejeitar pedido
|
|
</button>
|
|
</>
|
|
)}
|
|
<p role="status">{message}</p>
|
|
</article>
|
|
);
|
|
}
|
|
export default function Administration({
|
|
requests,
|
|
pair,
|
|
assignments,
|
|
securityAlerts,
|
|
actorId,
|
|
types,
|
|
organizations,
|
|
securityRequests,
|
|
operatorOrganizations,
|
|
internalTypes,
|
|
}: {
|
|
operatorOrganizations: OperatorOrganization[];
|
|
internalTypes: string[];
|
|
actorId: number;
|
|
types: string[];
|
|
organizations: { id: string; name: string }[];
|
|
securityRequests: SecurityEntry[];
|
|
securityAlerts: {
|
|
id: string;
|
|
action: string;
|
|
actor_user_id: number;
|
|
occurred_at: string;
|
|
}[];
|
|
requests: Entry[];
|
|
pair: Pair;
|
|
assignments: {
|
|
id: string;
|
|
user_id: number;
|
|
role: string;
|
|
revoked_at: string | null;
|
|
}[];
|
|
}) {
|
|
const [message, setMessage] = useState('');
|
|
const [reason, setReason] = useState('');
|
|
async function revoke(id: string) {
|
|
try {
|
|
await profileJson(`/perfis/atribuicoes/${id}/revogar`, { reason });
|
|
router.reload();
|
|
} catch (error) {
|
|
setMessage(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Resposta indisponível.',
|
|
);
|
|
}
|
|
}
|
|
return (
|
|
<main className="space-y-6 p-6">
|
|
<Head title="Gestão de perfis" />
|
|
<ProfileNavigation />
|
|
<h1 className="text-2xl font-semibold">Gestão de perfis</h1>
|
|
<p>
|
|
Dupla de superutilizadores: contas {pair.first_user_id} e{' '}
|
|
{pair.second_user_id}; versão {pair.version}. Substituição exige
|
|
operação atómica e aprovação independente. Não há remoção
|
|
isolada.
|
|
</p>
|
|
<section aria-label="Alertas de segurança">
|
|
<h2 className="text-xl font-semibold">Alertas de segurança</h2>
|
|
{securityAlerts.map((a) => (
|
|
<p key={a.id}>
|
|
{a.occurred_at}: {a.action} — conta {a.actor_user_id}
|
|
</p>
|
|
))}
|
|
</section>
|
|
<section>
|
|
<h2 className="text-xl font-semibold">Pedidos de Promotor</h2>
|
|
{requests.map((r) => (
|
|
<Decision key={r.id} entry={r} />
|
|
))}
|
|
</section>
|
|
<OperatorOrganizationForm />
|
|
<InternalStaffGrant types={internalTypes} organizations={organizations} operators={operatorOrganizations} />
|
|
<Grant types={types} organizations={organizations} />
|
|
<Security
|
|
pair={pair}
|
|
entries={securityRequests}
|
|
actorId={actorId}
|
|
/>
|
|
<section>
|
|
<h2 className="text-xl font-semibold">
|
|
Atribuições e revogação
|
|
</h2>
|
|
<label>
|
|
Motivo de revogação
|
|
<textarea
|
|
className={control}
|
|
minLength={20}
|
|
maxLength={1000}
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
/>
|
|
</label>
|
|
{assignments.map((a) => (
|
|
<p key={a.id}>
|
|
Conta {a.user_id}: {a.role} —{' '}
|
|
{a.revoked_at ? 'revogado' : 'atribuído'}{' '}
|
|
{!a.revoked_at && (
|
|
<button
|
|
className={button}
|
|
onClick={() => void revoke(a.id)}
|
|
>
|
|
Revogar e invalidar sessões
|
|
</button>
|
|
)}
|
|
</p>
|
|
))}
|
|
<p role="status">{message}</p>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|