2235 lines
92 KiB
TypeScript
2235 lines
92 KiB
TypeScript
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('');
|
||
}
|
||
|