Files
2026-05-18 13:58:40 +01:00

2235 lines
92 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, Fragment } from 'react';
import {
Monitor,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Server,
ShieldCheck,
Trash2,
X,
Router,
LockKeyhole,
Network,
Eye,
EyeOff,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Select } from '@/components/ui/Select';
import { vpsApi } from '@/services/vpsApi';
import { Button } from '@/components/ui/Button';
import type { ControllerClient } from '@/services/vpsApi';
import type { RouterFirewallRule } from '@/services/vpsApi';
const scrollClass =
'[scrollbar-width:thin] [scrollbar-color:rgba(59,130,246,0.55)_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-blue-500/45 hover:[&::-webkit-scrollbar-thumb]:bg-blue-500/65';
const controllerFilterOptions: {
value: 'all' | 'with' | 'without';
label: string;
}[] = [
{ value: 'all', label: 'Todos' },
{ value: 'with', label: 'Com controladores' },
{ value: 'without', label: 'Sem controladores' },
];
const pageSizeOptions: {
value: '10' | '25' | '50';
label: string;
}[] = [
{ value: '10', label: '10 por página' },
{ value: '25', label: '25 por página' },
{ value: '50', label: '50 por página' },
];
export function ControllersRoute() {
const [search, setSearch] = useState('');
const [clients, setClients] = useState<ControllerClient[]>([]);
const [selectedId, setSelectedId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<'10' | '25' | '50'>('10');
const [controllerFilter, setControllerFilter] =
useState<'all' | 'with' | 'without'>('all');
const [editingClient, setEditingClient] =
useState<ControllerClient | null>(null);
const [creatingClient, setCreatingClient] = useState(false);
const [deletingClientId, setDeletingClientId] = useState('');
const [clientToDelete, setClientToDelete] = useState<ControllerClient | null>(null);
const [selectedRouterIp, setSelectedRouterIp] = useState('');
const [selectedClientFileContent, setSelectedClientFileContent] = useState('');
const [loadingRouterData, setLoadingRouterData] = useState(false);
const [routerDataError, setRouterDataError] = useState('');
const [routerRules, setRouterRules] = useState<RouterFirewallRule[]>([]);
const [routerPassword, setRouterPassword] = useState('litoralr');
const [showRouterPassword, setShowRouterPassword] = useState(false);
const [creatingRule, setCreatingRule] = useState(false);
const [ruleToUpdate, setRuleToUpdate] = useState<RouterFirewallRule | null>(null);
const [ruleToDelete, setRuleToDelete] = useState<RouterFirewallRule | null>(null);
const [savingRule, setSavingRule] = useState(false);
async function loadClients(selectClientId?: string) {
try {
setLoading(true);
setError('');
const response = await vpsApi.listControllerClients();
setClients(response.clients);
setSelectedId((current) =>
selectClientId ||
current ||
response.clients[0]?.id ||
'',
);
} catch (err) {
setError(String(err));
setClients([]);
setSelectedId('');
} finally {
setLoading(false);
}
}
useEffect(() => {
loadClients();
}, []);
const filteredClients = useMemo(() => {
return clients.filter((client) => {
const matchesSearch = client.name
.toLowerCase()
.includes(search.toLowerCase());
const matchesControllerFilter =
controllerFilter === 'all' ||
(controllerFilter === 'with' && client.controller_count > 0) ||
(controllerFilter === 'without' && client.controller_count === 0);
return matchesSearch && matchesControllerFilter;
});
}, [clients, search, controllerFilter]);
useEffect(() => {
setPage(1);
}, [search, controllerFilter, pageSize]);
const pageSizeNumber = Number(pageSize);
const totalPages = Math.max(
1,
Math.ceil(filteredClients.length / pageSizeNumber),
);
const pagedClients = useMemo(() => {
const start = (page - 1) * pageSizeNumber;
return filteredClients.slice(start, start + pageSizeNumber);
}, [filteredClients, page, pageSizeNumber]);
const selectedClient =
filteredClients.find((client) => client.id === selectedId) ??
filteredClients[0];
useEffect(() => {
if (!selectedClient) {
setSelectedClientFileContent('');
setSelectedRouterIp('');
setRouterDataError('');
setRouterRules([]);
return;
}
let cancelled = false;
async function loadSelectedClientFile() {
try {
setLoadingRouterData(true);
setRouterDataError('');
const response = await vpsApi.readControllerClient(selectedClient.id);
if (cancelled) return;
setSelectedClientFileContent(response.content);
} catch (err) {
if (cancelled) return;
setSelectedClientFileContent('');
setSelectedRouterIp('');
setRouterDataError(String(err));
} finally {
if (!cancelled) {
setLoadingRouterData(false);
}
}
}
loadSelectedClientFile();
return () => {
cancelled = true;
};
}, [selectedClient?.id]);
const routerGroups = useMemo(() => {
if (!selectedClientFileContent) return [];
const parsed = parseClientTxt(selectedClientFileContent);
const groups = new Map<string, EditableController[]>();
parsed.controllers.forEach((controller) => {
const router = controller.routerVpnIp.trim();
if (!router) return;
groups.set(router, [...(groups.get(router) ?? []), controller]);
});
return Array.from(groups.entries()).map(([routerVpnIp, controllers]) => ({
routerVpnIp,
controllers,
}));
}, [selectedClientFileContent]);
useEffect(() => {
if (routerGroups.length === 0) {
setSelectedRouterIp('');
return;
}
setSelectedRouterIp((current) => {
const currentStillExists = routerGroups.some(
(group) => group.routerVpnIp === current,
);
return currentStillExists ? current : routerGroups[0].routerVpnIp;
});
}, [routerGroups]);
const selectedRouterGroup = routerGroups.find(
(group) => group.routerVpnIp === selectedRouterIp,
);
const routerRulesLoaded = routerRules.length > 0;
useEffect(() => {
setRouterRules([]);
setRouterDataError('');
}, [selectedRouterIp]);
async function deleteClient(client: ControllerClient) {
try {
setDeletingClientId(client.id);
setError('');
await vpsApi.deleteControllerClient(client.id);
await loadClients();
setSelectedId((current) => (current === client.id ? '' : current));
setClientToDelete(null);
} catch (err) {
setError(String(err));
} finally {
setDeletingClientId('');
}
}
async function loadRouterRules() {
if (!selectedRouterIp || !routerPassword.trim()) return;
try {
setLoadingRouterData(true);
setRouterDataError('');
const response = await vpsApi.getRouterFirewallRules(
selectedRouterIp,
routerPassword.trim(),
);
setRouterRules(response.rules);
} catch (err) {
setRouterDataError(String(err));
setRouterRules([]);
} finally {
setLoadingRouterData(false);
}
}
async function refreshRouterPanel() {
if (!selectedClient) return;
try {
setLoadingRouterData(true);
setRouterDataError('');
const clientResponse = await vpsApi.readControllerClient(selectedClient.id);
setSelectedClientFileContent(clientResponse.content);
if (selectedRouterIp && routerPassword.trim()) {
const rulesResponse = await vpsApi.getRouterFirewallRules(
selectedRouterIp,
routerPassword.trim(),
);
setRouterRules(rulesResponse.rules);
}
} catch (err) {
setRouterDataError(String(err));
setRouterRules([]);
} finally {
setLoadingRouterData(false);
}
}
return (
<div className={`flex min-h-full flex-col overflow-y-auto overflow-x-hidden pr-2 ${scrollClass}`}>
<div className="mb-4 flex shrink-0 items-start justify-between gap-4 rounded-3xl border border-white/10 bg-white/[0.025] px-5 py-4">
<div className="min-w-0">
<h1 className="truncate text-3xl font-black tracking-tight text-white">
Controladores
</h1>
<p className="mt-1 text-sm text-slate-400">
Gestão de clientes, controladores, ficheiros VPS e regras do router.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() => loadClients()}
disabled={loading}
className="shrink-0 rounded-2xl px-4 py-3 font-bold"
>
<RefreshCw size={17} className={loading ? 'animate-spin' : ''} />
{loading ? 'A atualizar...' : 'Atualizar'}
</Button>
</div>
{error && (
<div className="mb-4 shrink-0 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-300">
Erro ao carregar clientes: {error}
</div>
)}
<div className="grid gap-4 2xl:grid-cols-12">
<Card className="flex flex-col 2xl:col-span-5">
<div className="mb-5 flex shrink-0 items-start justify-between gap-4">
<div>
<h3 className="font-bold text-white">Clientes</h3>
<p className="text-xs text-slate-500">
{clients.length} ficheiros em /var/litoral_regas_app/maquinas
</p>
</div>
<Button
type="button"
onClick={() => setCreatingClient(true)}
variant="secondary"
className="h-11 rounded-2xl border-blue-500/20 bg-blue-500/10 px-4 font-black text-blue-100 hover:bg-blue-500/15"
>
<Plus size={17} />
Novo Cliente
</Button>
</div>
<div className="mb-4 shrink-0">
<label className="mb-2 block text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
Pesquisa
</label>
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-slate-950 px-3 py-3">
<Search size={17} className="text-slate-500" />
<input
value={search}
onChange={(event) =>
setSearch(event.target.value)
}
placeholder="Pesquisar cliente..."
className="w-full bg-transparent text-sm text-white outline-none placeholder:text-slate-600"
/>
</div>
</div>
<div className="mb-4 grid shrink-0 grid-cols-2 gap-3">
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
Estado
</label>
<Select
value={controllerFilter}
options={controllerFilterOptions}
onChange={setControllerFilter}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
Paginação
</label>
<Select
value={pageSize}
options={pageSizeOptions}
onChange={setPageSize}
/>
</div>
</div>
<div
className={`h-[420px] space-y-2 overflow-y-auto pr-2 lg:h-[500px] 2xl:h-[620px] ${scrollClass}`}
>
{loading && clients.length === 0 && (
<div className="rounded-2xl border border-white/10 bg-white/[0.025] p-4 text-sm text-slate-400">
A carregar clientes da VPS...
</div>
)}
{!loading && filteredClients.length === 0 && (
<div className="rounded-2xl border border-white/10 bg-white/[0.025] p-4 text-sm text-slate-400">
Nenhum cliente encontrado.
</div>
)}
{pagedClients.map((client) => {
const active = selectedClient?.id === client.id;
return (
<button
key={client.id}
type="button"
onClick={() => setSelectedId(client.id)}
className={`group w-full rounded-2xl border p-4 text-left transition-all duration-200 ${active
? 'border-blue-500/25 bg-blue-500/[0.08]'
: 'border-white/10 bg-white/[0.025] hover:border-white/15 hover:bg-white/[0.045]'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-[15px] font-bold tracking-tight text-white">
{client.name}
</p>
<div
className={`h-2 w-2 rounded-full ${client.controller_count > 0
? 'bg-blue-400'
: 'bg-slate-600'
}`}
/>
</div>
<p className="mt-1 truncate font-mono text-[11px] text-slate-500">
{client.file}
</p>
<div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
<ShieldCheck
size={13}
className={
client.controller_count > 0
? 'text-blue-300'
: 'text-slate-600'
}
/>
<span>
{client.controller_count > 0
? 'Com controladores'
: 'Sem controladores'}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div
className={`flex h-8 min-w-8 items-center justify-center rounded-full px-2 text-xs font-black ${client.controller_count > 0
? 'bg-blue-500/10 text-blue-100'
: 'bg-white/[0.04] text-slate-400'
}`}
>
{client.controller_count}
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setClientToDelete(client);
}}
className="flex h-8 w-8 items-center justify-center rounded-xl text-slate-500 transition hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 size={16} />
</button>
</div>
</div>
</button>
);
})}
</div>
<div className="mt-4 flex shrink-0 items-center justify-between gap-3 border-t border-white/10 pt-4 text-xs text-slate-400">
<span>
Página {page} de {totalPages} ·{' '}
{filteredClients.length} resultados
</span>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
disabled={page <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
className="rounded-xl px-3 py-2 text-xs font-bold"
>
Anterior
</Button>
<Button
type="button"
variant="secondary"
disabled={page >= totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
className="rounded-xl px-3 py-2 text-xs font-bold"
>
Seguinte
</Button>
</div>
</div>
</Card>
<div className="flex min-h-0 flex-col gap-4 xl:col-span-7">
<Card className="shrink-0 overflow-hidden">
{selectedClient ? (
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-blue-300/70">
Cliente selecionado
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
<h3 className="truncate text-2xl font-black text-white">
{selectedClient.name}
</h3>
<span className="rounded-xl bg-blue-500/10 px-3 py-1 text-xs font-black text-blue-100">
{selectedClient.controller_count} controlador(es)
</span>
<span className="truncate font-mono text-xs text-slate-500">
{selectedClient.file}
</span>
</div>
<p className="mt-2 truncate font-mono text-xs text-slate-600">
{selectedClient.path}
</p>
</div>
<Button
type="button"
onClick={() => setEditingClient(selectedClient)}
className="shrink-0 rounded-2xl px-4 py-3 font-black"
>
<Pencil size={17} />
Editar Cliente
</Button>
</div>
) : (
<div className="flex min-h-[90px] items-center justify-center text-slate-500">
Nenhum cliente selecionado.
</div>
)}
</Card>
<Card className="flex flex-col">
{selectedClient ? (
<>
<div className="mb-4 flex shrink-0 items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-blue-300/70">
Router / Firewall
</p>
<h3 className="mt-2 text-xl font-black text-white">
Gestão de DNAT
</h3>
<p className="mt-1 text-sm text-slate-500">
Seleciona o router, confirma a password e carrega as regras UCI.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={refreshRouterPanel}
disabled={!selectedRouterIp || !routerPassword.trim() || loadingRouterData}
className="shrink-0 rounded-2xl px-4 py-3 font-bold"
>
<RefreshCw size={16} className={loadingRouterData ? 'animate-spin' : ''} />
{loadingRouterData ? 'A carregar...' : 'Carregar regras'}
</Button>
</div>
<div className="mb-4 shrink-0 rounded-2xl border border-white/10 bg-white/[0.025] p-4">
<div className="grid gap-3 xl:grid-cols-2">
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
Router alvo
</label>
<Select
value={selectedRouterIp}
onChange={setSelectedRouterIp}
options={[
{
value: '',
label: routerGroups.length === 0
? 'Nenhum router detetado'
: 'Selecionar router...',
},
...routerGroups.map((group) => ({
value: group.routerVpnIp,
label: `${group.routerVpnIp} · ${group.controllers.length} controlador(es)`,
})),
]}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
Palavra-passe SSH
</label>
<div className="relative">
<input
type={showRouterPassword ? 'text' : 'password'}
value={routerPassword}
onChange={(event) => setRouterPassword(event.target.value)}
placeholder="Password do router"
className="w-full rounded-xl border border-white/10 bg-slate-950 px-3 py-3 pr-12 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 focus:border-blue-500/40"
/>
<button
type="button"
onClick={() => setShowRouterPassword((current) => !current)}
className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center justify-center rounded-lg p-1.5 text-slate-500 transition hover:bg-white/[0.06] hover:text-white"
>
{showRouterPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-slate-950 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="shrink-0 rounded-xl bg-blue-500/10 p-2 text-blue-300">
<Router size={17} />
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500">Estado</p>
<p className="truncate font-bold text-white">
{routerDataError
? 'Falha ao comunicar com o router'
: loadingRouterData
? 'A analisar router...'
: routerRules.length > 0
? `${routerRules.length} regra(s) carregada(s)`
: selectedRouterIp
? 'Pronto para carregar regras'
: 'Nenhum router selecionado'}
</p>
</div>
</div>
<div
className={`shrink-0 rounded-xl px-3 py-1.5 text-xs font-bold transition ${routerDataError
? 'border border-red-500/20 bg-red-500/10 text-red-300'
: loadingRouterData
? 'border border-blue-500/20 bg-blue-500/10 text-blue-200'
: routerRules.length > 0
? 'border border-emerald-500/20 bg-emerald-500/10 text-emerald-200'
: 'border border-white/10 bg-white/[0.03] text-slate-400'
}`}
>
{routerDataError
? 'Router inacessível'
: loadingRouterData
? 'A ligar...'
: routerRules.length > 0
? 'Ligado'
: 'Não carregado'}
</div>
</div>
{routerDataError && (
<div className="mt-3 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3">
<p className="text-sm font-bold text-red-200">
Não foi possível carregar as regras do router.
</p>
<p className="mt-1 break-all font-mono text-xs text-red-300/80">
{routerDataError}
</p>
</div>
)}
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-hidden xl:grid-cols-12">
<div className="flex min-h-0 flex-col rounded-2xl border border-white/10 bg-white/[0.025] p-4 xl:col-span-4">
<div className="shrink-0">
<h4 className="font-bold text-white">Controladores</h4>
<p className="mt-1 text-xs text-slate-500">
Associados ao router selecionado.
</p>
</div>
<div className={`mt-4 min-h-0 flex-1 space-y-2 overflow-y-auto pr-2 ${scrollClass}`}>
{!selectedRouterGroup ? (
<div className="flex min-h-[140px] items-center justify-center rounded-2xl border border-dashed border-white/10 bg-black/10 text-sm text-slate-500">
Seleciona um router.
</div>
) : (
selectedRouterGroup.controllers.map((controller) => (
<div
key={controller.id}
className="rounded-2xl border border-white/10 bg-black/15 p-3"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-bold text-white">
{controller.name}
</p>
<p className="mt-1 font-mono text-xs text-slate-500">
Externa {controller.externalPort}
</p>
</div>
<span className="rounded-xl bg-blue-500/10 px-2.5 py-1 font-mono text-xs font-black text-blue-100">
{controller.password}
</span>
</div>
</div>
))
)}
</div>
</div>
<div className="flex min-h-0 flex-col rounded-2xl border border-white/10 bg-white/[0.025] p-4 xl:col-span-8">
<div className="mb-4 flex shrink-0 items-center justify-between gap-3">
<div>
<h4 className="font-bold text-white">Regras DNAT</h4>
<p className="text-xs text-slate-500">
Portas externas e destinos internos.
</p>
</div>
<Button
type="button"
disabled={
!selectedRouterGroup ||
!selectedRouterIp ||
!routerPassword.trim() ||
!routerRulesLoaded
}
onClick={() => setCreatingRule(true)}
className="rounded-2xl px-4 py-3 font-black"
>
<Plus size={16} />
Nova Regra
</Button>
</div>
<div className={`min-h-0 flex-1 space-y-2 overflow-y-auto pr-2 ${scrollClass}`}>
{routerRules.length === 0 ? (
<div className="flex min-h-[180px] flex-col items-center justify-center rounded-2xl border border-dashed border-white/10 bg-black/10 text-center text-sm text-slate-500">
<p>Nenhuma regra carregada ainda.</p>
<p className="mt-1 text-xs text-slate-600">
Carrega o router para listar as regras DNAT.
</p>
</div>
) : (
routerRules.map((rule) => (
<div
key={rule.id}
className="rounded-2xl border border-white/10 bg-black/15 p-3 transition hover:border-white/15 hover:bg-white/[0.035]"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-bold text-white">
{rule.name || rule.id}
</p>
<p className="mt-1 font-mono text-xs text-slate-500">
{rule.externalPort} {rule.internalIp}:{rule.internalPort}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="rounded-xl bg-blue-500/10 px-2.5 py-1 text-xs font-black uppercase text-blue-100">
{rule.proto}
</span>
<button
type="button"
onClick={() => setRuleToUpdate(rule)}
className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs font-black text-slate-300 transition hover:bg-white/[0.06] hover:text-white"
>
Editar
</button>
<button
type="button"
onClick={() => setRuleToDelete(rule)}
className="flex h-9 w-9 items-center justify-center rounded-xl text-slate-500 transition hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</>
) : (
<div className="flex h-full items-center justify-center text-slate-500">
Seleciona um cliente para gerir o router.
</div>
)}
</Card>
</div>
</div>
{editingClient && (
<EditClientModal
client={editingClient}
onClose={() => setEditingClient(null)}
onSaved={async () => {
await loadClients();
await refreshRouterPanel();
}}
/>
)}
{creatingClient && (
<CreateClientModal
clients={clients}
onClose={() => setCreatingClient(false)}
onSaved={(clientId) => {
setSearch(clientId);
setControllerFilter('all');
setPage(1);
return loadClients(clientId)
}}
/>
)}
{clientToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-[#050b1a] shadow-2xl">
<div className="border-b border-white/10 px-6 py-5">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-red-300/80">
Apagar Cliente
</p>
<h2 className="mt-2 text-xl font-black text-white">
Tem a certeza?
</h2>
<p className="mt-2 text-sm text-slate-400">
Vai apagar o ficheiro do cliente{' '}
<span className="font-bold text-white">
{clientToDelete.name}
</span>
.
</p>
</div>
<div className="flex justify-end gap-3 px-6 py-4">
<Button
type="button"
variant="secondary"
onClick={() => setClientToDelete(null)}
className="rounded-2xl px-4 py-3 font-bold"
>
Cancelar
</Button>
<Button
type="button"
variant="danger"
disabled={deletingClientId === clientToDelete.id}
onClick={() => deleteClient(clientToDelete)}
className="rounded-2xl px-5 py-3 font-black"
>
<Trash2 size={16} />
{deletingClientId === clientToDelete.id
? 'A apagar...'
: 'Apagar'}
</Button>
</div>
</div>
</div>
)}
{creatingRule && (
<RuleModal
title="Nova Regra"
routerHost={selectedRouterIp}
routerPassword={routerPassword}
defaultExternalPort={selectedRouterGroup?.controllers[0]?.externalPort ?? ''}
onClose={() => setCreatingRule(false)}
onSaved={async () => {
setCreatingRule(false);
await loadRouterRules();
}}
/>
)}
{ruleToUpdate && (
<RuleModal
title="Editar Regra"
routerHost={selectedRouterIp}
routerPassword={routerPassword}
rule={ruleToUpdate}
onClose={() => setRuleToUpdate(null)}
onSaved={async () => {
setRuleToUpdate(null);
await loadRouterRules();
}}
/>
)}
{ruleToDelete && (
<DeleteRuleModal
rule={ruleToDelete}
loading={savingRule}
onClose={() => setRuleToDelete(null)}
onConfirm={async () => {
try {
setSavingRule(true);
await vpsApi.deleteRouterFirewallRule(
selectedRouterIp,
routerPassword,
ruleToDelete.id,
);
setRuleToDelete(null);
await loadRouterRules();
} finally {
setSavingRule(false);
}
}}
/>
)}
</div>
);
}
function MiniStat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="mb-3 w-fit rounded-xl bg-blue-500/10 p-2 text-blue-300">
{icon}
</div>
<p className="text-xs text-slate-500">
{label}
</p>
<p className="mt-1 truncate font-black text-white">
{value}
</p>
</div>
);
}
type EditableController = {
id: string;
name: string;
routerVpnIp: string;
externalPort: string;
password: string;
};
function CreateClientModal({
clients,
onClose,
onSaved,
}: {
clients: ControllerClient[];
onClose: () => void;
onSaved: (clientId: string) => Promise<void> | void;
}) {
const [name, setName] = useState('');
const [password, setPassword] = useState(generateClientPassword());
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const cleanName = name.trim();
const nameError = useMemo(() => {
if (!cleanName) return 'O nome do cliente é obrigatório.';
if (!/^[A-Za-z0-9 _-]+$/.test(cleanName)) {
return 'Usa apenas letras, números, espaços, "_" ou "-".';
}
if (hasTxtSeparator(cleanName)) {
return 'O nome não pode conter "xxx" ou quebras de linha.';
}
const clientId = normalizeClientId(cleanName);
const duplicateClient = clients.some(
(client) => normalizeClientId(client.id) === clientId,
);
if (duplicateClient) {
return 'Já existe um cliente com esse nome.';
}
return '';
}, [cleanName, clients]);
const passwordError = useMemo(() => {
if (!password.trim()) return 'A palavra-passe é obrigatória.';
if (hasTxtSeparator(password)) {
return 'A palavra-passe não pode conter "xxx" ou quebras de linha.';
}
return '';
}, [password]);
const formIsValid = !nameError && !passwordError;
async function createClient() {
try {
setSaving(true);
setError('');
await vpsApi.createControllerClient(cleanName, password);
await onSaved(cleanName);
onClose();
} catch (err) {
setError(String(err));
} finally {
setSaving(false);
}
}
function normalizeClientId(value: string) {
return value.trim().toLowerCase().replace(/\s+/g, '_');
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm">
<div className="w-full max-w-xl overflow-hidden rounded-3xl border border-white/10 bg-[#050b1a] shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-6 py-5">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-blue-300/70">
Novo Cliente
</p>
<h2 className="mt-2 text-2xl font-black text-white">
Criar ficheiro VPS
</h2>
<p className="mt-1 text-sm text-slate-500">
Cria um novo cliente com apenas a primeira linha do ficheiro.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-2xl border border-white/10 bg-white/[0.03] p-3 text-slate-400 transition hover:bg-white/[0.06] hover:text-white"
>
<X size={18} />
</button>
</div>
<div className="space-y-5 px-6 py-6">
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs font-medium text-red-300">
Erro ao criar cliente: {error}
</div>
)}
<Field
label="Nome do cliente"
value={name}
error={nameError}
placeholder="Ex: Adoraflor"
onChange={(value) =>
setName(value.replace(/[^\w\- ]/g, ''))
}
autoFocus
/>
<div>
<Field
label="Palavra-passe"
value={password}
error={passwordError}
onChange={setPassword}
/>
<Button
type="button"
variant="secondary"
onClick={() => setPassword(generateClientPassword())}
className="mt-3 rounded-xl text-xs font-bold"
>
Gerar password
</Button>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/10 px-6 py-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="rounded-2xl px-4 py-3 font-bold"
>
Cancelar
</Button>
<Button
type="button"
onClick={createClient}
disabled={!formIsValid || saving}
className="rounded-2xl px-5 py-3 font-black disabled:bg-slate-700 disabled:text-slate-400"
>
<Plus size={16} />
{saving ? 'A criar...' : 'Criar Cliente'}
</Button>
</div>
</div>
</div>
);
}
function EditClientModal({
client,
onClose,
onSaved,
}: {
client: ControllerClient;
onClose: () => void;
onSaved: () => Promise<void> | void;
}) {
const [clientPassword, setClientPassword] = useState('');
const [controllers, setControllers] = useState<EditableController[]>([]);
const [loadingClientFile, setLoadingClientFile] = useState(true);
const [clientFileError, setClientFileError] = useState('');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
useEffect(() => {
let cancelled = false;
async function loadClientFile() {
try {
setLoadingClientFile(true);
setClientFileError('');
const response = await vpsApi.readControllerClient(client.id);
if (cancelled) return;
const parsed = parseClientTxt(response.content);
setClientPassword(parsed.password);
setControllers(parsed.controllers);
} catch (err) {
if (cancelled) return;
setClientFileError(String(err));
setClientPassword('');
setControllers([]);
} finally {
if (!cancelled) {
setLoadingClientFile(false);
}
}
}
loadClientFile();
return () => {
cancelled = true;
};
}, [client.id]);
const controllerErrors = useMemo(
() => controllers.map(validateController),
[controllers],
);
const clientPasswordError = useMemo(() => {
if (loadingClientFile) return '';
if (!clientPassword.trim()) return 'A palavra-passe é obrigatória.';
if (hasTxtSeparator(clientPassword)) {
return 'A palavra-passe não pode conter "xxx" ou quebras de linha.';
}
return '';
}, [clientPassword, loadingClientFile]);
const formIsValid =
!loadingClientFile &&
!clientFileError &&
!clientPasswordError &&
controllers.length > 0 &&
controllerErrors.every((errors) => !hasErrors(errors));
const txtPreview = useMemo(() => {
const lines = [
clientPassword,
...controllers.map(
(controller) =>
`${controller.name}xxx${controller.routerVpnIp}xxx${controller.externalPort}xxx${controller.password}`,
),
];
return `${lines.join('\n')}\n`;
}, [clientPassword, controllers]);
function updateController(
id: string,
patch: Partial<EditableController>,
) {
setControllers((current) =>
current.map((controller) =>
controller.id === id
? { ...controller, ...patch }
: controller,
),
);
}
function addController() {
setControllers((current) => [
...current,
{
id: crypto.randomUUID(),
name: `Controlador${current.length + 1}`,
routerVpnIp: '',
externalPort: getNextAvailablePort(current),
password: '111111',
},
]);
}
function removeController(id: string) {
setControllers((current) =>
current.filter((controller) => controller.id !== id),
);
}
async function saveClientFile() {
try {
setSaving(true);
setSaveError('');
await vpsApi.updateControllerClient(client.id, txtPreview);
await onSaved();
onClose();
} catch (err) {
setSaveError(String(err));
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm">
<div className="flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[#050b1a] shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-6 py-5">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-blue-300/70">
Editar Cliente
</p>
<h2 className="mt-2 text-2xl font-black text-white">
{client.name}
</h2>
<p className="mt-1 break-all font-mono text-xs text-slate-500">
{client.path}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-2xl border border-white/10 bg-white/[0.03] p-3 text-slate-400 transition hover:bg-white/[0.06] hover:text-white"
>
<X size={18} />
</button>
</div>
{saveError && (
<div className="mx-6 mt-3 rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs font-medium text-red-300">
Erro ao guardar: {saveError}
</div>
)}
{loadingClientFile ? (
<div className="flex min-h-[420px] items-center justify-center text-sm text-slate-400">
A carregar ficheiro do cliente...
</div>
) : clientFileError ? (
<div className="m-6 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-300">
Erro ao carregar ficheiro do cliente: {clientFileError}
</div>
) : (
<div className={`grid min-h-0 flex-1 gap-4 overflow-hidden px-6 pb-6 xl:grid-cols-12 ${saveError ? 'pt-4' : 'pt-6'}`}>
<div className="space-y-4 xl:col-span-7">
<div className="rounded-3xl border border-white/10 bg-white/[0.025] p-5">
<h3 className="font-bold text-white">
Dados do Cliente
</h3>
<div className="mt-4">
<Field
label="Palavra-passe / primeira linha do ficheiro"
value={clientPassword}
error={clientPasswordError}
onChange={setClientPassword}
/>
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-white/[0.025] p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="font-bold text-white">
Controladores
</h3>
<p className="text-xs text-slate-500">
Entradas carregadas do ficheiro real do cliente.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={addController}
className="rounded-2xl border-blue-500/20 bg-blue-500/15 px-4 py-3 font-bold text-blue-100 hover:bg-blue-500/20"
>
<Plus size={16} />
Adicionar
</Button>
</div>
<div className={`max-h-[360px] space-y-3 overflow-y-auto pr-2 ${scrollClass}`}>
{controllers.map((controller, index) => (
<div
key={controller.id}
className="rounded-2xl border border-white/10 bg-black/15 p-4"
>
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-bold text-white">
Controlador #{index + 1}
</p>
<button
type="button"
onClick={() =>
removeController(controller.id)
}
className="rounded-xl p-2 text-slate-500 transition hover:bg-red-500/10 hover:text-red-300"
>
<Trash2 size={16} />
</button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Field
label="Nome"
value={controller.name}
error={controllerErrors[index]?.name}
onChange={(value) =>
updateController(controller.id, {
name: value.replace(
/[^\w\- ]/g,
'',
),
})
}
/>
<SmartRouterField
label="Router / Host"
value={controller.routerVpnIp}
error={controllerErrors[index]?.routerVpnIp}
onChange={(value) =>
updateController(controller.id, {
routerVpnIp: value,
})
}
/>
<Field
label="Porta externa"
type="number"
min={5900}
max={5999}
step={1}
value={controller.externalPort}
error={
controllerErrors[index]?.externalPort
}
inputMode="numeric"
onChange={(value) =>
updateController(controller.id, {
externalPort: value,
})
}
/>
<Field
label="PIN / Password"
value={controller.password}
error={controllerErrors[index]?.password}
inputMode="numeric"
maxLength={6}
onChange={(value) =>
updateController(controller.id, {
password: value
.replace(/\D/g, '')
.slice(0, 6),
})
}
/>
</div>
</div>
))}
</div>
</div>
</div>
<div className="xl:col-span-5">
<div className="sticky top-0 rounded-3xl border border-white/10 bg-white/[0.025] p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="font-bold text-white">
Preview .txt
</h3>
<p className="text-xs text-slate-500">
Conteúdo exato a guardar no ficheiro.
</p>
</div>
<span className="rounded-2xl bg-white/[0.04] px-3 py-2 text-xs font-bold text-slate-300">
{controllers.length} entradas
</span>
</div>
<pre className={`max-h-[520px] overflow-auto whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/30 p-4 font-mono text-xs leading-6 text-slate-300 ${scrollClass}`}>
{txtPreview}
</pre>
</div>
</div>
</div>
)}
<div className="flex items-center justify-between gap-3 border-t border-white/10 px-6 py-4">
<p className="text-xs text-slate-500">
Dados carregados diretamente do ficheiro do cliente.
</p>
<div className="flex gap-3">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="rounded-2xl px-4 py-3 font-bold"
>
Cancelar
</Button>
<Button
type="button"
onClick={saveClientFile}
disabled={!formIsValid || saving}
className="rounded-2xl px-5 py-3 font-black disabled:bg-slate-700 disabled:text-slate-400"
>
<Save size={16} />
{saving ? 'A guardar...' : 'Guardar alterações'}
</Button>
</div>
</div>
</div>
</div>
);
}
function RuleModal({
title,
routerHost,
routerPassword,
rule,
defaultExternalPort = '',
onClose,
onSaved,
}: {
title: string;
routerHost: string;
routerPassword: string;
rule?: RouterFirewallRule;
defaultExternalPort?: string;
onClose: () => void;
onSaved: () => Promise<void> | void;
}) {
const editing = Boolean(rule);
const [id, setId] = useState(rule?.id ?? '');
const [name, setName] = useState(rule?.name ?? '');
const [proto, setProto] = useState(rule?.proto ?? 'tcp');
const [srcZone, setSrcZone] = useState(rule?.srcZone ?? 'vpn');
const [destZone, setDestZone] = useState(rule?.destZone ?? 'lan');
const [externalPort, setExternalPort] = useState(rule?.externalPort ?? defaultExternalPort);
const [internalIp, setInternalIp] = useState(rule?.internalIp ?? '');
const [internalPort, setInternalPort] = useState(rule?.internalPort ?? '5900');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const cleanId = id.trim();
const formIsValid =
cleanId &&
name.trim() &&
proto.trim() &&
srcZone.trim() &&
destZone.trim() &&
isValidPort(externalPort) &&
isValidPort(internalPort) &&
IPV4_REGEX.test(internalIp.trim());
async function saveRule() {
if (!formIsValid) return;
const payload: RouterFirewallRule = {
id: cleanId,
name: name.trim(),
proto: proto.trim(),
srcZone: srcZone.trim(),
destZone: destZone.trim(),
externalPort: externalPort.trim(),
internalIp: internalIp.trim(),
internalPort: internalPort.trim(),
target: 'DNAT',
};
try {
setSaving(true);
setError('');
if (editing) {
await vpsApi.updateRouterFirewallRule(routerHost, routerPassword, payload);
} else {
await vpsApi.createRouterFirewallRule(routerHost, routerPassword, payload);
}
await onSaved();
} catch (err) {
setError(String(err));
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm">
<div className="w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-[#050b1a] shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-6 py-5">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-blue-300/70">
Regra DNAT
</p>
<h2 className="mt-2 text-2xl font-black text-white">
{title}
</h2>
<p className="mt-1 font-mono text-xs text-slate-500">
Router: {routerHost}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-2xl border border-white/10 bg-white/[0.03] p-3 text-slate-400 transition hover:bg-white/[0.06] hover:text-white"
>
<X size={18} />
</button>
</div>
<div className="space-y-4 px-6 py-6">
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs font-medium text-red-300">
Erro ao guardar regra: {error}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Field
label="ID técnico"
value={id}
disabled={editing}
placeholder="dnat_hmi_1_vnc"
onChange={(value) =>
setId(value.replace(/[^\w-]/g, '').toLowerCase())
}
/>
<Field
label="Nome"
value={name}
placeholder="HMI 1 - VNC"
onChange={setName}
/>
<Field
label="Protocolo"
value={proto}
placeholder="tcp"
onChange={(value) => setProto(value.toLowerCase())}
/>
<Field
label="Porta externa"
type="number"
min={1}
max={65535}
step={1}
value={externalPort}
error={externalPort && !isValidPort(externalPort) ? 'Porta inválida.' : ''}
inputMode="numeric"
placeholder="5900"
onChange={(value) => setExternalPort(value.replace(/\D/g, ''))}
/>
<IpField
label="IP interno"
value={internalIp}
error={internalIp && !IPV4_REGEX.test(internalIp) ? 'IPv4 inválido.' : ''}
onChange={setInternalIp}
/>
<Field
label="Porta interna"
type="number"
min={1}
max={65535}
step={1}
value={internalPort}
error={internalPort && !isValidPort(internalPort) ? 'Porta inválida.' : ''}
inputMode="numeric"
placeholder="5900"
onChange={(value) => setInternalPort(value.replace(/\D/g, ''))}
/>
<Field
label="Zona origem"
value={srcZone}
placeholder="vpn"
onChange={setSrcZone}
/>
<Field
label="Zona destino"
value={destZone}
placeholder="lan"
onChange={setDestZone}
/>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<p className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">
Preview
</p>
<p className="mt-2 font-mono text-sm text-slate-300">
{externalPort || '—'} {internalIp || '—'}:{internalPort || '—'} · {proto || 'tcp'}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/10 px-6 py-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="rounded-2xl px-4 py-3 font-bold"
>
Cancelar
</Button>
<Button
type="button"
onClick={saveRule}
disabled={!formIsValid || saving}
className="rounded-2xl px-5 py-3 font-black disabled:bg-slate-700 disabled:text-slate-400"
>
<Save size={16} />
{saving ? 'A guardar...' : 'Guardar regra'}
</Button>
</div>
</div>
</div>
);
}
function DeleteRuleModal({
rule,
loading,
onClose,
onConfirm,
}: {
rule: RouterFirewallRule;
loading: boolean;
onClose: () => void;
onConfirm: () => Promise<void> | void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-3xl border border-white/10 bg-[#050b1a] shadow-2xl">
<div className="border-b border-white/10 px-6 py-5">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-red-300/80">
Apagar Regra
</p>
<h2 className="mt-2 text-xl font-black text-white">
Tem a certeza?
</h2>
<p className="mt-2 text-sm text-slate-400">
Vai apagar a regra{' '}
<span className="font-bold text-white">
{rule.name || rule.id}
</span>
.
</p>
<p className="mt-3 font-mono text-xs text-slate-500">
{rule.externalPort} {rule.internalIp}:{rule.internalPort}
</p>
</div>
<div className="flex justify-end gap-3 px-6 py-4">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="rounded-2xl px-4 py-3 font-bold"
>
Cancelar
</Button>
<Button
type="button"
variant="danger"
disabled={loading}
onClick={onConfirm}
className="rounded-2xl px-5 py-3 font-black"
>
<Trash2 size={16} />
{loading ? 'A apagar...' : 'Apagar'}
</Button>
</div>
</div>
</div>
);
}
function parseClientTxt(content: string): {
password: string;
controllers: EditableController[];
} {
const lines = content
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.split('\n')
.filter((line) => line.length > 0);
const password = lines[0] ?? '';
const controllers = lines.slice(1).map((line, index) => {
const [name = '', routerVpnIp = '', externalPort = '', password = ''] =
line.split('xxx');
return {
id: crypto.randomUUID(),
name,
routerVpnIp,
externalPort,
password,
};
});
return {
password,
controllers,
};
}
function Field({
label,
value,
onChange,
error,
inputMode,
maxLength,
type = 'text',
min,
max,
step,
hideLabel = false,
placeholder,
autoFocus = false,
disabled = false
}: {
label: string;
value: string;
onChange: (value: string) => void;
error?: string;
inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode'];
maxLength?: number;
type?: React.HTMLInputTypeAttribute;
min?: number;
max?: number;
step?: number;
hideLabel?: boolean;
placeholder?: string;
autoFocus?: boolean;
disabled?: boolean;
}) {
return (
<div>
{!hideLabel && (
<label className="mb-2 block text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
{label}
</label>
)}
<div className="relative">
<input
type={type}
value={value}
autoFocus={autoFocus}
min={min}
max={max}
disabled={disabled}
placeholder={placeholder}
step={step}
inputMode={inputMode}
maxLength={maxLength}
onChange={(event) => onChange(event.target.value)}
className={`w-full rounded-xl border bg-slate-950 px-3 py-3 pr-12 font-mono text-sm text-white outline-none transition placeholder:text-slate-600 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${error
? 'border-red-500/60 focus:border-red-400'
: 'border-white/10 focus:border-blue-500/40'
}`}
/>
{type === 'number' && (
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 flex-col gap-1">
<button
type="button"
onClick={() => {
const current = Number(value || 0);
const next = current + Number(step || 1);
if (typeof max === 'number' && next > max) return;
onChange(String(next));
}}
className="flex h-4 w-4 items-center justify-center rounded bg-white/[0.06] text-[10px] text-slate-300 transition hover:bg-blue-500/20 hover:text-white"
>
+
</button>
<button
type="button"
onClick={() => {
const current = Number(value || 0);
const next = current - Number(step || 1);
const floor = typeof min === 'number' ? min : 5900;
onChange(String(Math.max(floor, next)));
}}
className="flex h-4 w-4 items-center justify-center rounded bg-white/[0.06] text-[10px] text-slate-300 transition hover:bg-blue-500/20 hover:text-white"
>
</button>
</div>
)}
</div>
{error && (
<p className="mt-2 text-xs font-medium text-red-300">
{error}
</p>
)}
</div>
);
}
function IpField({
label,
value,
onChange,
error,
hideLabel = false,
autoFocusFirstOctet = false,
autoFocusOctetIndex = 0,
}: {
label: string;
value: string;
onChange: (value: string) => void;
error?: string;
hideLabel?: boolean;
autoFocusFirstOctet?: boolean;
autoFocusOctetIndex?: number;
}) {
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
useEffect(() => {
if (!autoFocusFirstOctet) return;
requestAnimationFrame(() => {
const input = inputRefs.current[autoFocusOctetIndex];
input?.focus();
input?.setSelectionRange(input.value.length, input.value.length);
});
}, [autoFocusFirstOctet, autoFocusOctetIndex]);
const parts = value.split('.');
const octets = Array.from({ length: 4 }, (_, index) => parts[index] ?? '');
function updateOctet(index: number, rawValue: string) {
const nextValue = rawValue.replace(/\D/g, '').slice(0, 3);
if (nextValue && Number(nextValue) > 255) return;
const nextOctets = [...octets];
nextOctets[index] = nextValue;
onChange(nextOctets.join('.'));
}
function handleKeyDown(
index: number,
event: React.KeyboardEvent<HTMLInputElement>,
) {
if (event.key === '.') {
event.preventDefault();
inputRefs.current[index + 1]?.focus();
return;
}
if (event.key !== 'Backspace') return;
const current = octets[index];
if (current.length > 0) {
const nextOctets = [...octets];
nextOctets[index] = current.slice(0, -1);
onChange(nextOctets.join('.'));
event.preventDefault();
return;
}
if (index > 0) {
event.preventDefault();
inputRefs.current[index - 1]?.focus();
}
}
return (
<div>
{!hideLabel && (
<label className="mb-2 block text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
{label}
</label>
)}
<div
className={`flex items-center rounded-xl border bg-slate-950 px-3 py-2 font-mono text-sm text-white transition ${error
? 'border-red-500/60 focus-within:border-red-400'
: 'border-white/10 focus-within:border-blue-500/40'
}`}
>
{octets.map((octet, index) => (
<Fragment key={index}>
<input
ref={(element) => {
inputRefs.current[index] = element;
}}
value={octet}
inputMode="numeric"
maxLength={3}
onChange={(event) =>
updateOctet(index, event.target.value)
}
onKeyDown={(event) =>
handleKeyDown(index, event)
}
className="w-full min-w-0 bg-transparent px-1 py-1 text-center outline-none"
/>
{index < 3 && (
<span className="select-none px-1 text-slate-500">
.
</span>
)}
</Fragment>
))}
</div>
{error && (
<p className="mt-2 text-xs font-medium text-red-300">
{error}
</p>
)}
</div>
);
}
type FieldErrors = Partial<Record<keyof EditableController, string>>;
const IPV4_REGEX =
/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
const HOSTNAME_REGEX =
/^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
function isValidRouterHost(value: string) {
const trimmed = value.trim();
return IPV4_REGEX.test(trimmed) || HOSTNAME_REGEX.test(trimmed);
}
const INVALID_CONTROLLER_NAME_REGEX = /(xxx|\r|\n)/i;
function hasTxtSeparator(value: string) {
return value.includes('xxx') || value.includes('\n') || value.includes('\r');
}
function validatePort(value: string) {
const port = Number(value);
if (!value.trim()) {
return 'A porta externa é obrigatória.';
}
if (!/^\d+$/.test(value)) {
return 'A porta deve conter apenas números.';
}
if (port < 5900 || port > 5999) {
return 'A porta deve estar entre 5900 e 5999.';
}
return '';
}
function isValidPort(value: string) {
const port = Number(value);
return /^\d+$/.test(value) && port >= 1 && port <= 65535;
}
function getNextAvailablePort(controllers: EditableController[]) {
const usedPorts = new Set(
controllers
.map((controller) => Number(controller.externalPort))
.filter((port) => Number.isInteger(port) && port >= 5900),
);
let port = 5900;
while (usedPorts.has(port)) {
port += 1;
}
return String(port);
}
function validateController(controller: EditableController): FieldErrors {
const errors: FieldErrors = {};
if (!controller.name.trim()) {
errors.name = 'O nome é obrigatório.';
} else if (INVALID_CONTROLLER_NAME_REGEX.test(controller.name)) {
errors.name =
'O nome não pode conter "xxx" ou quebras de linha.';
}
if (!controller.routerVpnIp.trim()) {
errors.routerVpnIp = 'O router/host é obrigatório.';
} else if (!isValidRouterHost(controller.routerVpnIp)) {
errors.routerVpnIp = 'Introduz um IPv4 ou hostname válido.';
}
const portError = validatePort(controller.externalPort);
if (portError) errors.externalPort = portError;
if (!/^\d{6}$/.test(controller.password)) {
errors.password = 'O PIN/password deve ter exatamente 6 dígitos.';
}
return errors;
}
function hasErrors(errors: FieldErrors) {
return Object.values(errors).some(Boolean);
}
type RouterFieldMode = 'host' | 'ip';
function SmartRouterField({
label,
value,
onChange,
error,
}: {
label: string;
value: string;
onChange: (value: string) => void;
error?: string;
}) {
const [mode, setMode] = useState<RouterFieldMode>(() =>
IPV4_REGEX.test(value.trim()) ? 'ip' : 'host',
);
const [focusOctetIndex, setFocusOctetIndex] = useState(0);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!value.trim()) {
setMode('host');
setFocusOctetIndex(0);
}
}, [value]);
function handleHostChange(nextValue: string) {
const shouldSwitchToIp =
/^[\d.]+$/.test(nextValue) && /\d+\.$/.test(nextValue);
if (shouldSwitchToIp) {
setFocusOctetIndex(
Math.min(nextValue.split('.').length - 1, 3),
);
setMode('ip');
}
onChange(nextValue);
}
function handleIpChange(nextValue: string) {
if (!nextValue.replace(/\./g, '').trim()) {
onChange('');
return;
}
onChange(nextValue);
}
return (
<div
onFocus={() => setIsFocused(true)}
onBlur={() => {
setIsFocused(false);
if (!value.trim()) {
setMode('host');
setFocusOctetIndex(0);
}
}}
>
<div className="mb-2 flex items-center justify-between gap-3">
<label className="block text-xs font-medium uppercase tracking-[0.16em] text-slate-500">
{label}
</label>
<span className="rounded-xl border border-white/10 bg-slate-950 px-2.5 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-blue-200">
{mode === 'ip' ? 'Auto · IPv4' : 'Auto · Host'}
</span>
</div>
{mode === 'ip' ? (
<IpField
label=""
value={value}
error={error}
onChange={handleIpChange}
hideLabel
autoFocusFirstOctet
autoFocusOctetIndex={focusOctetIndex}
/>
) : (
<Field
label=""
value={value}
error={error}
onChange={handleHostChange}
placeholder="198.19.0.167 ou adoraflor.nsupdate.info"
hideLabel
autoFocus
/>
)}
</div>
);
}
function generateClientPassword() {
const alphabet =
'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
return Array.from({ length: 8 }, () =>
alphabet[Math.floor(Math.random() * alphabet.length)],
).join('');
}