1211 lines
46 KiB
TypeScript
1211 lines
46 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, Fragment } from 'react';
|
||
import {
|
||
Monitor,
|
||
Pencil,
|
||
Plus,
|
||
RefreshCw,
|
||
Save,
|
||
Search,
|
||
Server,
|
||
ShieldCheck,
|
||
Trash2,
|
||
X,
|
||
} from 'lucide-react';
|
||
|
||
import { Card } from '@/components/ui/Card';
|
||
import { Select } from '@/components/ui/Select';
|
||
import { vpsApi } from '@/services/vpsApi';
|
||
|
||
import type { ControllerClient } 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);
|
||
|
||
async function loadClients() {
|
||
try {
|
||
setLoading(true);
|
||
setError('');
|
||
|
||
const response = await vpsApi.listControllerClients();
|
||
|
||
setClients(response.clients);
|
||
setSelectedId((current) => 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];
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||
<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"
|
||
onClick={loadClients}
|
||
disabled={loading}
|
||
className="inline-flex shrink-0 items-center gap-2 rounded-2xl border border-blue-500/20 bg-blue-500/15 px-4 py-3 text-sm font-bold text-blue-100 transition hover:bg-blue-500/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
<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 min-h-0 flex-1 gap-4 overflow-hidden xl:grid-cols-12">
|
||
<Card className="flex min-h-0 flex-col overflow-hidden xl:col-span-5">
|
||
<div className="mb-4 flex shrink-0 items-center justify-between gap-3">
|
||
<div>
|
||
<h3 className="font-bold text-white">
|
||
Clientes
|
||
</h3>
|
||
|
||
<p className="text-xs text-slate-500">
|
||
Ficheiros em /var/litoral_regas_app/maquinas
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-white/[0.03] px-3 py-2 text-xs font-bold text-slate-300">
|
||
{filteredClients.length} / {clients.length} clientes
|
||
</div>
|
||
</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={`min-h-0 flex-1 space-y-2 overflow-y-auto pr-2 ${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={`w-full rounded-2xl border p-4 text-left transition ${active
|
||
? 'border-blue-500/25 bg-blue-500/10'
|
||
: 'border-white/10 bg-white/[0.025] hover:bg-white/[0.045]'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="truncate font-bold text-white">
|
||
{client.name}
|
||
</p>
|
||
|
||
<p className="mt-1 truncate font-mono text-xs text-slate-500">
|
||
{client.file}
|
||
</p>
|
||
</div>
|
||
|
||
<span
|
||
className={`rounded-xl px-2.5 py-1 text-xs font-bold ${client.controller_count > 0
|
||
? 'bg-blue-500/10 text-blue-200'
|
||
: 'bg-white/[0.04] text-slate-400'
|
||
}`}
|
||
>
|
||
{client.controller_count}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||
<ShieldCheck
|
||
size={14}
|
||
className={
|
||
client.controller_count > 0
|
||
? 'text-blue-300'
|
||
: 'text-slate-600'
|
||
}
|
||
/>
|
||
|
||
<span>
|
||
{client.controller_count > 0
|
||
? 'Com controladores'
|
||
: 'Sem controladores'}
|
||
</span>
|
||
</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"
|
||
disabled={page <= 1}
|
||
onClick={() =>
|
||
setPage((current) =>
|
||
Math.max(1, current - 1),
|
||
)
|
||
}
|
||
className="rounded-xl border border-white/10 px-3 py-2 font-bold text-slate-300 transition hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
Anterior
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
disabled={page >= totalPages}
|
||
onClick={() =>
|
||
setPage((current) =>
|
||
Math.min(totalPages, current + 1),
|
||
)
|
||
}
|
||
className="rounded-xl border border-white/10 px-3 py-2 font-bold text-slate-300 transition hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
Seguinte
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card className="min-h-0 overflow-hidden xl:col-span-7">
|
||
{selectedClient ? (
|
||
<div className="flex h-full flex-col overflow-y-auto">
|
||
<div className="mb-5 flex items-start justify-between gap-4">
|
||
<div>
|
||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-blue-300/70">
|
||
Cliente selecionado
|
||
</p>
|
||
|
||
<h3 className="mt-2 text-2xl font-black text-white">
|
||
{selectedClient.name}
|
||
</h3>
|
||
|
||
<p className="mt-2 break-all font-mono text-xs text-slate-500">
|
||
{selectedClient.path}
|
||
</p>
|
||
|
||
<p className="mt-2 text-xs text-slate-500">
|
||
Última alteração:{' '}
|
||
{selectedClient.modified_at ?? '—'}
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setEditingClient(selectedClient)
|
||
}
|
||
className="inline-flex items-center gap-2 rounded-2xl bg-blue-500 px-4 py-3 text-sm font-black text-white transition hover:bg-blue-400"
|
||
>
|
||
<Pencil size={17} />
|
||
Editar Cliente
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<MiniStat
|
||
icon={<Monitor size={18} />}
|
||
label="Controladores"
|
||
value={String(
|
||
selectedClient.controller_count,
|
||
)}
|
||
/>
|
||
|
||
<MiniStat
|
||
icon={<Server size={18} />}
|
||
label="Ficheiro VPS"
|
||
value={selectedClient.file}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.025] p-4">
|
||
<h4 className="font-bold text-white">
|
||
Próximas ações
|
||
</h4>
|
||
|
||
<div className="mt-4 space-y-3 text-sm text-slate-400">
|
||
<p>• Abrir modal de edição do cliente.</p>
|
||
<p>
|
||
• Listar controladores do ficheiro .txt.
|
||
</p>
|
||
<p>
|
||
• Detetar router associado pelo IP
|
||
WireGuard.
|
||
</p>
|
||
<p>
|
||
• Adicionar, atualizar ou remover DNAT no
|
||
router.
|
||
</p>
|
||
<p>
|
||
• Atualizar entrada correspondente no
|
||
ficheiro VPS.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-slate-500">
|
||
Nenhum cliente selecionado.
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
|
||
{editingClient && (
|
||
<EditClientModal
|
||
client={editingClient}
|
||
onClose={() => setEditingClient(null)}
|
||
/>
|
||
)}
|
||
</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 EditClientModal({
|
||
client,
|
||
onClose,
|
||
}: {
|
||
client: ControllerClient;
|
||
onClose: () => void;
|
||
}) {
|
||
const [clientPassword, setClientPassword] = useState('');
|
||
const [controllers, setControllers] = useState<EditableController[]>([]);
|
||
const [loadingClientFile, setLoadingClientFile] = useState(true);
|
||
const [clientFileError, setClientFileError] = 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),
|
||
);
|
||
}
|
||
|
||
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>
|
||
|
||
{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 p-6 xl:grid-cols-12">
|
||
<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"
|
||
onClick={addController}
|
||
className="inline-flex items-center gap-2 rounded-2xl border border-blue-500/20 bg-blue-500/15 px-4 py-3 text-sm font-bold text-blue-100 transition 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(
|
||
/[^A-Za-z0-9_-]/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"
|
||
onClick={onClose}
|
||
className="rounded-2xl border border-white/10 px-4 py-3 text-sm font-bold text-slate-300 transition hover:bg-white/[0.04]"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
disabled={!formIsValid}
|
||
className="inline-flex items-center gap-2 rounded-2xl bg-blue-500 px-5 py-3 text-sm font-black text-white transition hover:bg-blue-400 disabled:cursor-not-allowed disabled:bg-slate-700 disabled:text-slate-400"
|
||
>
|
||
<Save size={16} />
|
||
Guardar alterações
|
||
</button>
|
||
</div>
|
||
</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
|
||
}: {
|
||
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;
|
||
}) {
|
||
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}
|
||
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 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>
|
||
);
|
||
} |