Files
lr-openwrt-tool/src/components/controllers/ControllersRoute.tsx
T
2026-05-15 08:54:51 +01:00

1211 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState, Fragment } from 'react';
import {
Monitor,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Server,
ShieldCheck,
Trash2,
X,
} 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>
);
}