Files
Investidor-V2/resources/js/pages/profiles/administration.tsx
T

989 lines
36 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>
Seleccione a organização responsável pelo portal. As permissões
dos utilizadores são definidas separadamente.
</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}
list="staff-accounts"
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,
technicalSupportAvailable = false,
}: {
types: string[];
organizations: { id: string; name: string }[];
technicalSupportAvailable?: boolean;
}) {
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
list="staff-accounts"
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',
...(technicalSupportAvailable
? ['technical_support']
: []),
].map((v) => (
<option key={v} value={v}>
{
(
{
validator: 'Validador',
publisher: 'Publicador',
auditor: 'Auditor',
technical_support:
'Suporte técnico',
} as Record<string, string>
)[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
list="staff-accounts"
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,
staffAccounts,
technicalSupportAvailable = false,
}: {
technicalSupportAvailable?: boolean;
operatorOrganizations: OperatorOrganization[];
internalTypes: string[];
staffAccounts: {
id: number;
name: string;
email: string;
verified: boolean;
mfa: boolean;
}[];
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 />
<datalist id="staff-accounts">
{staffAccounts.map((account) => (
<option key={account.id} value={account.id}>
{account.name} ({account.email})
</option>
))}
</datalist>
<InternalStaffGrant
types={internalTypes}
organizations={organizations}
operators={operatorOrganizations}
/>
<Grant
types={types}
organizations={organizations}
technicalSupportAvailable={technicalSupportAvailable}
/>
<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>
);
}