407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { portalRequest, PortalApiError } from './portal-api';
|
|
import { createOperationKey } from './operation-key';
|
|
import { usePagePreparationGuard } from './page-preparation-guard';
|
|
export type SupportPlan = {
|
|
actorId: number;
|
|
target: { database: string; port: number };
|
|
hash: string;
|
|
code_hash: string;
|
|
installed: boolean;
|
|
consistent: boolean;
|
|
repairable: boolean;
|
|
ready: boolean;
|
|
action: 'none' | 'install' | 'repair_known_base' | 'blocked';
|
|
schema: { state: string; ledger: boolean };
|
|
dependencies: Record<string, boolean>;
|
|
changes: string[];
|
|
backupDate: string | null;
|
|
rollback: string;
|
|
commands: { key: string; code_hash: string }[];
|
|
mfa_confirmed: boolean;
|
|
};
|
|
type Attempt = {
|
|
body: {
|
|
hash: string;
|
|
reason: string;
|
|
confirm: true;
|
|
idempotency_key: string;
|
|
};
|
|
code: string;
|
|
generation: number;
|
|
};
|
|
const endpoint = '/management/profile-install/technical-support';
|
|
const object = (v: unknown): v is Record<string, unknown> =>
|
|
!!v && typeof v === 'object' && !Array.isArray(v);
|
|
const hex = (v: unknown): v is string =>
|
|
typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
const target = (v: unknown): v is SupportPlan['target'] =>
|
|
object(v) &&
|
|
typeof v.database === 'string' &&
|
|
Number.isSafeInteger(v.port) &&
|
|
Number(v.port) > 0;
|
|
const validPlan = (v: unknown): v is SupportPlan =>
|
|
object(v) &&
|
|
typeof v.actorId === 'number' &&
|
|
Number.isSafeInteger(v.actorId) &&
|
|
v.actorId > 0 &&
|
|
target(v.target) &&
|
|
hex(v.hash) &&
|
|
hex(v.code_hash) &&
|
|
typeof v.installed === 'boolean' &&
|
|
typeof v.consistent === 'boolean' &&
|
|
typeof v.repairable === 'boolean' &&
|
|
typeof v.ready === 'boolean' &&
|
|
['none', 'install', 'repair_known_base', 'blocked'].includes(
|
|
String(v.action),
|
|
) &&
|
|
object(v.schema) &&
|
|
typeof v.schema.state === 'string' &&
|
|
typeof v.schema.ledger === 'boolean' &&
|
|
object(v.dependencies) &&
|
|
Object.values(v.dependencies).every((x) => typeof x === 'boolean') &&
|
|
Array.isArray(v.changes) &&
|
|
v.changes.every((x) => typeof x === 'string') &&
|
|
(v.backupDate === null || typeof v.backupDate === 'string') &&
|
|
typeof v.rollback === 'string' &&
|
|
typeof v.mfa_confirmed === 'boolean' &&
|
|
Array.isArray(v.commands) &&
|
|
v.commands.every(
|
|
(x) => object(x) && typeof x.key === 'string' && hex(x.code_hash),
|
|
);
|
|
const sameTarget = (a: SupportPlan['target'], b: SupportPlan['target']) =>
|
|
a.database === b.database && a.port === b.port;
|
|
export function useProfileSupportInstallation(initial: SupportPlan) {
|
|
const [plan, setPlan] = useState(initial),
|
|
[base, setBase] = useState(initial),
|
|
[reason, setReason] = useState(''),
|
|
[confirmed, setConfirmed] = useState(false),
|
|
[busy, setBusy] = useState(false),
|
|
[refreshing, setRefreshing] = useState(false),
|
|
[uncertain, setUncertain] = useState(false),
|
|
[blocked, setBlocked] = useState(false),
|
|
[needsRefresh, setNeedsRefresh] = useState(false),
|
|
[message, setMessage] = useState(''),
|
|
[error, setError] = useState('');
|
|
const origin = useRef({ actor: initial.actorId, target: initial.target }),
|
|
epoch = useRef({ actor: initial.actorId, target: initial.target }),
|
|
alive = useRef(true),
|
|
pending = useRef<Attempt | null>(null),
|
|
active = useRef<Attempt | null>(null),
|
|
reading = useRef<object | null>(null),
|
|
generation = useRef(0),
|
|
text = useRef(reason),
|
|
committed = useRef<Attempt | null>(null);
|
|
if (
|
|
epoch.current.actor !== initial.actorId ||
|
|
!sameTarget(epoch.current.target, initial.target)
|
|
)
|
|
epoch.current = { actor: initial.actorId, target: initial.target };
|
|
text.current = reason;
|
|
const valid =
|
|
validPlan(plan) &&
|
|
plan.actorId === origin.current.actor &&
|
|
sameTarget(plan.target, origin.current.target) &&
|
|
initial.actorId === origin.current.actor &&
|
|
sameTarget(initial.target, origin.current.target);
|
|
const stale = base.hash !== plan.hash;
|
|
useEffect(() => {
|
|
alive.current = true;
|
|
return () => {
|
|
alive.current = false;
|
|
};
|
|
}, []);
|
|
function change(value: string) {
|
|
generation.current++;
|
|
text.current = value;
|
|
setReason(value);
|
|
setConfirmed(false);
|
|
setError('');
|
|
}
|
|
function acknowledge(attempt: Attempt) {
|
|
if (pending.current !== attempt) return;
|
|
pending.current = null;
|
|
active.current = null;
|
|
committed.current = attempt;
|
|
setBusy(false);
|
|
setUncertain(false);
|
|
setNeedsRefresh(true);
|
|
setConfirmed(false);
|
|
setError('');
|
|
setMessage(
|
|
'Resultado da actualização confirmado. Confira a prévia actual.',
|
|
);
|
|
if (generation.current === attempt.generation) {
|
|
text.current = '';
|
|
setReason('');
|
|
}
|
|
}
|
|
function read(value: unknown) {
|
|
if (
|
|
!validPlan(value) ||
|
|
value.actorId !== origin.current.actor ||
|
|
!sameTarget(value.target, origin.current.target)
|
|
)
|
|
return false;
|
|
setPlan(value);
|
|
setBlocked(false);
|
|
setError('');
|
|
const attempt = pending.current;
|
|
if (
|
|
attempt &&
|
|
value.commands.some(
|
|
(c) =>
|
|
c.key === attempt.body.idempotency_key &&
|
|
c.code_hash === attempt.code,
|
|
)
|
|
)
|
|
acknowledge(attempt);
|
|
const known = committed.current;
|
|
if (known) {
|
|
if (
|
|
!value.commands.some(
|
|
(c) =>
|
|
c.key === known.body.idempotency_key &&
|
|
c.code_hash === known.code,
|
|
)
|
|
) {
|
|
setError(
|
|
'Esta consulta ainda não confirma o resultado já recebido. Actualize novamente antes de outra acção.',
|
|
);
|
|
return true;
|
|
}
|
|
if (
|
|
value.code_hash === known.code &&
|
|
value.installed &&
|
|
value.consistent
|
|
)
|
|
setBase(value);
|
|
committed.current = null;
|
|
setNeedsRefresh(false);
|
|
} else setNeedsRefresh(false);
|
|
return true;
|
|
}
|
|
useEffect(() => {
|
|
if (validPlan(initial)) read(initial);
|
|
}, [initial]);
|
|
async function refresh() {
|
|
if (reading.current) return;
|
|
const request = {},
|
|
scope = epoch.current;
|
|
reading.current = request;
|
|
setRefreshing(true);
|
|
try {
|
|
const value = await portalRequest<unknown>(endpoint);
|
|
if (alive.current && epoch.current === scope && !read(value))
|
|
setError(
|
|
'A consulta recebida não corresponde a esta conta e destino. A preparação foi conservada.',
|
|
);
|
|
} catch (e) {
|
|
if (alive.current && epoch.current === scope) {
|
|
if (
|
|
e instanceof PortalApiError &&
|
|
[401, 403, 419].includes(e.status)
|
|
)
|
|
setBlocked(true);
|
|
setError(
|
|
'Não foi possível actualizar a prévia. O fundamento e a tentativa original foram conservados.',
|
|
);
|
|
}
|
|
} finally {
|
|
if (
|
|
alive.current &&
|
|
epoch.current === scope &&
|
|
reading.current === request
|
|
) {
|
|
reading.current = null;
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
}
|
|
async function send(retry = false) {
|
|
if (
|
|
!valid ||
|
|
blocked ||
|
|
!plan.mfa_confirmed ||
|
|
active.current ||
|
|
reading.current
|
|
)
|
|
return;
|
|
if (
|
|
!retry &&
|
|
(pending.current ||
|
|
stale ||
|
|
needsRefresh ||
|
|
!confirmed ||
|
|
!plan.ready ||
|
|
plan.installed ||
|
|
!plan.mfa_confirmed ||
|
|
(!plan.consistent && !plan.repairable))
|
|
)
|
|
return;
|
|
let attempt = pending.current;
|
|
if (!attempt) {
|
|
if (text.current.trim().length < 20 || text.current.length > 1000) {
|
|
setError('Preencha o fundamento com 20 a 1000 caracteres.');
|
|
return;
|
|
}
|
|
try {
|
|
attempt = {
|
|
body: {
|
|
hash: base.hash,
|
|
reason: text.current,
|
|
confirm: true,
|
|
idempotency_key: createOperationKey().toLowerCase(),
|
|
},
|
|
code: base.code_hash,
|
|
generation: generation.current,
|
|
};
|
|
} catch (e) {
|
|
setError(
|
|
e instanceof Error
|
|
? e.message
|
|
: 'Não foi possível preparar a operação.',
|
|
);
|
|
return;
|
|
}
|
|
pending.current = attempt;
|
|
}
|
|
const scope = epoch.current;
|
|
active.current = attempt;
|
|
setBusy(true);
|
|
setUncertain(true);
|
|
setError('');
|
|
try {
|
|
const result = await portalRequest<unknown>(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(attempt.body),
|
|
});
|
|
if (
|
|
!alive.current ||
|
|
epoch.current !== scope ||
|
|
pending.current !== attempt
|
|
)
|
|
return;
|
|
if (
|
|
!object(result) ||
|
|
result.actor_id !== origin.current.actor ||
|
|
result.command_key !== attempt.body.idempotency_key ||
|
|
result.migration !==
|
|
'2026_11_03_080000_add_technical_support_profile' ||
|
|
result.code_hash !== attempt.code ||
|
|
result.installed !== true ||
|
|
!target(result.target) ||
|
|
!sameTarget(result.target, origin.current.target)
|
|
)
|
|
throw new Error(
|
|
'A confirmação não corresponde à tentativa preparada. Consulte a prévia antes de continuar.',
|
|
);
|
|
acknowledge(attempt);
|
|
void refresh();
|
|
} catch (e) {
|
|
if (
|
|
!alive.current ||
|
|
epoch.current !== scope ||
|
|
pending.current !== attempt
|
|
)
|
|
return;
|
|
if (e instanceof PortalApiError && [409, 422].includes(e.status)) {
|
|
pending.current = null;
|
|
setUncertain(false);
|
|
setConfirmed(false);
|
|
setError(
|
|
e.status === 409
|
|
? 'A tentativa foi recusada porque o plano mudou. Actualize e compare a prévia; o fundamento foi conservado.'
|
|
: e.errors.reason?.join(' ') ||
|
|
'A operação foi recusada. Confira as dependências, as provas e o fundamento.',
|
|
);
|
|
if (e.status === 409) {
|
|
setNeedsRefresh(true);
|
|
void refresh();
|
|
}
|
|
} else if (
|
|
e instanceof PortalApiError &&
|
|
[401, 403, 419].includes(e.status)
|
|
) {
|
|
setBlocked(true);
|
|
setError(
|
|
'Confirme a conta e renove a autenticação MFA antes de repetir. A preparação foi conservada.',
|
|
);
|
|
} else
|
|
setError(
|
|
e instanceof Error
|
|
? e.message
|
|
: 'Resultado sem confirmação. Conserve a tentativa original.',
|
|
);
|
|
} finally {
|
|
if (
|
|
alive.current &&
|
|
epoch.current === scope &&
|
|
active.current === attempt
|
|
) {
|
|
active.current = null;
|
|
setBusy(false);
|
|
}
|
|
}
|
|
}
|
|
function adopt() {
|
|
if (!valid || busy || refreshing || pending.current || blocked) return;
|
|
setBase(plan);
|
|
setConfirmed(false);
|
|
setNeedsRefresh(false);
|
|
setMessage(
|
|
'Prévia actual confirmada. Reveja o fundamento e confirme esta operação.',
|
|
);
|
|
setError('');
|
|
}
|
|
usePagePreparationGuard(
|
|
!!reason || uncertain,
|
|
'Existe um fundamento preparado ou uma instalação sem confirmação. Sair desta página?',
|
|
);
|
|
return {
|
|
plan,
|
|
base,
|
|
reason,
|
|
change,
|
|
confirmed,
|
|
setConfirmed,
|
|
busy,
|
|
refreshing,
|
|
uncertain,
|
|
message,
|
|
error,
|
|
stale,
|
|
refresh,
|
|
send,
|
|
adopt,
|
|
canAdopt:
|
|
valid &&
|
|
!busy &&
|
|
!refreshing &&
|
|
!pending.current &&
|
|
!blocked &&
|
|
stale,
|
|
canRetry:
|
|
valid &&
|
|
plan.mfa_confirmed &&
|
|
!busy &&
|
|
!refreshing &&
|
|
!blocked &&
|
|
!!pending.current,
|
|
canSend:
|
|
valid &&
|
|
!blocked &&
|
|
!busy &&
|
|
!refreshing &&
|
|
!needsRefresh &&
|
|
!stale &&
|
|
!uncertain &&
|
|
confirmed &&
|
|
plan.ready &&
|
|
!plan.installed &&
|
|
plan.mfa_confirmed &&
|
|
(plan.consistent || plan.repairable),
|
|
};
|
|
}
|