feat: add service catalog management with policy integration for domain-based resource access control

Add ServiceCatalogItem type and services CRUD API endpoints (list, create, update, delete). Extend Policy type to include services array with domain, upstream_ip, proxy_ip, and ports metadata.

Add ServicesPage component with table view and create/edit modals for managing service definitions. Include service name, domain, proxy, and upstream columns with port parsing logic.

Integrate service selection
This commit is contained in:
2026-03-18 13:09:54 +01:00
parent 0ac93dfeb6
commit 6cf49ff3e0
25 changed files with 1375 additions and 99 deletions

View File

@@ -55,6 +55,15 @@ export type Policy = {
full_tunnel: boolean;
is_active: boolean;
destinations?: string[];
services?: Array<{
id: string;
name: string;
domain: string;
upstream_ip: string;
proxy_ip: string;
ports: number[];
description: string;
}>;
targets?: Array<{
type: string;
id: string;
@@ -62,6 +71,17 @@ export type Policy = {
}>;
};
export type ServiceCatalogItem = {
id: string;
name: string;
description: string;
domain: string;
upstream_ip: string;
proxy_ip: string;
ports: number[];
is_active: boolean;
};
export type Group = {
id: string;
name: string;
@@ -168,6 +188,37 @@ export const api = {
method: "DELETE"
}),
devices: () => request<Device[]>("/admin/devices"),
services: () => request<ServiceCatalogItem[]>("/admin/services"),
createService: (payload: {
name: string;
description: string;
domain: string;
upstream_ip: string;
proxy_ip: string;
ports: number[];
is_active?: boolean;
}) =>
request<ServiceCatalogItem>("/admin/services", {
method: "POST",
body: JSON.stringify(payload)
}),
updateService: (serviceId: string, payload: {
name?: string;
description?: string;
domain?: string;
upstream_ip?: string;
proxy_ip?: string;
ports?: number[];
is_active?: boolean;
}) =>
request<ServiceCatalogItem>(`/admin/services/${serviceId}`, {
method: "PATCH",
body: JSON.stringify(payload)
}),
deleteService: (serviceId: string) =>
request<{ ok: boolean }>(`/admin/services/${serviceId}`, {
method: "DELETE"
}),
deviceProfile: (deviceId: string) => request<DeviceProfile>(`/admin/devices/${deviceId}/profile`),
revokeDevice: (deviceId: string) =>
request<{ ok: boolean }>(`/admin/devices/${deviceId}/revoke`, {
@@ -187,6 +238,7 @@ export const api = {
effect: string;
full_tunnel: boolean;
destinations: string[];
service_ids: string[];
targets: Array<{ type: string; id: string }>;
}) =>
request<Policy>("/admin/policies", {
@@ -201,6 +253,7 @@ export const api = {
full_tunnel?: boolean;
is_active?: boolean;
destinations?: string[];
service_ids?: string[];
targets?: Array<{ type: string; id: string }>;
}) =>
request<Policy>(`/admin/policies/${policyId}`, {

View File

@@ -10,6 +10,7 @@ import { DevicesPage } from "../features/devices/DevicesPage";
import { GatewaysPage } from "../features/gateways/GatewaysPage";
import { GroupsPage } from "../features/groups/GroupsPage";
import { PoliciesPage } from "../features/policies/PoliciesPage";
import { ServicesPage } from "../features/services/ServicesPage";
import { SettingsPage } from "../features/settings/SettingsPage";
import { UsersPage } from "../features/users/UsersPage";
@@ -44,6 +45,7 @@ export function App() {
<Route path="/users" element={<UsersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/devices" element={<DevicesPage />} />
<Route path="/services" element={<ServicesPage />} />
<Route path="/policies" element={<PoliciesPage />} />
<Route path="/gateways" element={<GatewaysPage />} />
<Route path="/audit" element={<AuditPage />} />

View File

@@ -5,6 +5,7 @@ const items = [
["Users", "/users"],
["Groups", "/groups"],
["Devices", "/devices"],
["Services", "/services"],
["Policies", "/policies"],
["Gateways", "/gateways"],
["Audit", "/audit"],

View File

@@ -28,10 +28,15 @@ export function PoliciesPage() {
queryKey: ["groups"],
queryFn: api.groups
});
const servicesQuery = useQuery({
queryKey: ["services"],
queryFn: api.services
});
const [form, setForm] = useState({
name: "",
description: "",
destinations: "",
serviceIds: [] as string[],
targetType: "user",
targetIds: [] as string[],
fullTunnel: false
@@ -42,6 +47,7 @@ export function PoliciesPage() {
name: "",
description: "",
destinations: "",
serviceIds: [] as string[],
fullTunnel: false,
isActive: true,
targetType: "user",
@@ -52,17 +58,17 @@ export function PoliciesPage() {
mutationFn: api.createPolicy,
onSuccess: () => {
setCreateOpen(false);
setForm({ name: "", description: "", destinations: "", targetType: "user", targetIds: [], fullTunnel: false });
setForm({ name: "", description: "", destinations: "", serviceIds: [], targetType: "user", targetIds: [], fullTunnel: false });
void queryClient.invalidateQueries({ queryKey: ["policies"] });
}
});
const updateMutation = useMutation({
mutationFn: ({ policyId, payload }: { policyId: string; payload: { name: string; description: string; destinations: string[]; full_tunnel: boolean; is_active: boolean; targets: Array<{ type: string; id: string }> } }) =>
mutationFn: ({ policyId, payload }: { policyId: string; payload: { name: string; description: string; destinations: string[]; service_ids: string[]; full_tunnel: boolean; is_active: boolean; targets: Array<{ type: string; id: string }> } }) =>
api.updatePolicy(policyId, payload),
onSuccess: () => {
setEditingPolicyId(null);
setEditForm({ name: "", description: "", destinations: "", fullTunnel: false, isActive: true, targetType: "user", targetIds: [] });
setEditForm({ name: "", description: "", destinations: "", serviceIds: [], fullTunnel: false, isActive: true, targetType: "user", targetIds: [] });
void queryClient.invalidateQueries({ queryKey: ["policies"] });
}
});
@@ -78,12 +84,16 @@ export function PoliciesPage() {
id: policy.id,
name: policy.name,
targets: policy.targets?.length ? policy.targets.map((target) => `${target.type}: ${target.name ?? target.id}`).join(", ") : "No targets",
destinations: policy.destinations?.join(", ") ?? (policy.full_tunnel ? "0.0.0.0/0" : "-"),
destinations: [
...(policy.services?.map((service) => service.name) ?? []),
...(policy.destinations ?? [])
].join(", ") || (policy.full_tunnel ? "0.0.0.0/0" : "-"),
mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel"
})) ?? [];
const selectableUsers = useMemo(() => usersQuery.data ?? [], [usersQuery.data]);
const selectableGroups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const selectableServices = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]);
const selectableTargets = form.targetType === "group" ? selectableGroups : selectableUsers;
const editableTargets = editForm.targetType === "group" ? selectableGroups : selectableUsers;
@@ -99,6 +109,7 @@ export function PoliciesPage() {
effect: "allow",
full_tunnel: form.fullTunnel,
destinations: form.fullTunnel ? ["0.0.0.0/0"] : form.destinations.split(",").map((value) => value.trim()).filter(Boolean),
service_ids: form.serviceIds,
targets: form.targetIds.map((id) => ({ type: form.targetType, id }))
});
}
@@ -114,6 +125,7 @@ export function PoliciesPage() {
name: policy.name,
description: policy.description,
destinations: policy.destinations?.join(", ") ?? "",
serviceIds: policy.services?.map((service) => service.id) ?? [],
fullTunnel: policy.full_tunnel,
isActive: policy.is_active,
targetType,
@@ -132,6 +144,7 @@ export function PoliciesPage() {
name: editForm.name,
description: editForm.description,
destinations: editForm.fullTunnel ? ["0.0.0.0/0"] : editForm.destinations.split(",").map((value) => value.trim()).filter(Boolean),
service_ids: editForm.serviceIds,
full_tunnel: editForm.fullTunnel,
is_active: editForm.isActive,
targets: editForm.targetIds.map((id) => ({ type: editForm.targetType, id }))
@@ -158,6 +171,25 @@ export function PoliciesPage() {
}));
}
function toggleService(id: string, editing = false) {
if (editing) {
setEditForm((value) => ({
...value,
serviceIds: value.serviceIds.includes(id)
? value.serviceIds.filter((item) => item !== id)
: [...value.serviceIds, id]
}));
return;
}
setForm((value) => ({
...value,
serviceIds: value.serviceIds.includes(id)
? value.serviceIds.filter((item) => item !== id)
: [...value.serviceIds, id]
}));
}
return (
<Page
title="Policies"
@@ -214,6 +246,17 @@ export function PoliciesPage() {
onChange={(event) => setForm((value) => ({ ...value, destinations: event.target.value }))}
disabled={form.fullTunnel}
/>
<div className="selection-panel">
<p className="eyebrow">Allowed services</p>
<div className="selection-list">
{selectableServices.map((service) => (
<label className="selection-item" key={service.id}>
<input type="checkbox" checked={form.serviceIds.includes(service.id)} onChange={() => toggleService(service.id)} />
<span>{service.name} ({service.domain})</span>
</label>
))}
</div>
</div>
<label className="checkbox">
<input type="checkbox" checked={form.fullTunnel} onChange={(event) => setForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
Full tunnel
@@ -251,6 +294,17 @@ export function PoliciesPage() {
onChange={(event) => setEditForm((value) => ({ ...value, destinations: event.target.value }))}
disabled={editForm.fullTunnel}
/>
<div className="selection-panel">
<p className="eyebrow">Allowed services</p>
<div className="selection-list">
{selectableServices.map((service) => (
<label className="selection-item" key={service.id}>
<input type="checkbox" checked={editForm.serviceIds.includes(service.id)} onChange={() => toggleService(service.id, true)} />
<span>{service.name} ({service.domain})</span>
</label>
))}
</div>
</div>
<label className="checkbox">
<input type="checkbox" checked={editForm.fullTunnel} onChange={(event) => setEditForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
Full tunnel

View File

@@ -0,0 +1,203 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api/client";
import { Modal } from "../../components/Modal";
import { Page } from "../../components/Page";
import { Table } from "../../components/Table";
const columns = [
{ key: "name", label: "Service" },
{ key: "domain", label: "Domain" },
{ key: "proxy", label: "Proxy" },
{ key: "upstream", label: "Upstream" },
{ key: "actions", label: "Actions" }
];
export function ServicesPage() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["services"],
queryFn: api.services
});
const [createOpen, setCreateOpen] = useState(false);
const [editingServiceId, setEditingServiceId] = useState<string | null>(null);
const [form, setForm] = useState({
name: "",
description: "",
domain: "",
upstream_ip: "",
proxy_ip: "",
ports: "80,443",
is_active: true
});
const createMutation = useMutation({
mutationFn: api.createService,
onSuccess: () => {
setCreateOpen(false);
resetForm();
void queryClient.invalidateQueries({ queryKey: ["services"] });
}
});
const updateMutation = useMutation({
mutationFn: ({ serviceId, payload }: { serviceId: string; payload: Parameters<typeof api.updateService>[1] }) =>
api.updateService(serviceId, payload),
onSuccess: () => {
setEditingServiceId(null);
resetForm();
void queryClient.invalidateQueries({ queryKey: ["services"] });
}
});
const deleteMutation = useMutation({
mutationFn: api.deleteService,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["services"] });
}
});
function resetForm() {
setForm({
name: "",
description: "",
domain: "",
upstream_ip: "",
proxy_ip: "",
ports: "80,443",
is_active: true
});
}
function parsePorts(raw: string) {
return raw
.split(",")
.map((value) => Number.parseInt(value.trim(), 10))
.filter((value) => Number.isFinite(value) && value > 0);
}
function onCreate(event: FormEvent) {
event.preventDefault();
createMutation.mutate({
...form,
ports: parsePorts(form.ports)
});
}
function startEdit(serviceId: string) {
const service = query.data?.find((item) => item.id === serviceId);
if (!service) {
return;
}
setEditingServiceId(serviceId);
setForm({
name: service.name,
description: service.description,
domain: service.domain,
upstream_ip: service.upstream_ip,
proxy_ip: service.proxy_ip,
ports: service.ports.join(","),
is_active: service.is_active
});
}
function onEdit(event: FormEvent) {
event.preventDefault();
if (!editingServiceId) {
return;
}
updateMutation.mutate({
serviceId: editingServiceId,
payload: {
...form,
ports: parsePorts(form.ports)
}
});
}
const rows = query.data?.map((service) => ({
id: service.id,
name: service.name,
domain: service.domain,
proxy: `${service.proxy_ip}:${service.ports.join(",")}`,
upstream: service.upstream_ip
})) ?? [];
return (
<Page
title="Services"
subtitle="Define named internal services with domain, proxy hop, and upstream metadata."
actions={(
<div className="action-row">
<button className="button" type="button" onClick={() => setCreateOpen(true)}>New service</button>
</div>
)}
>
{query.isError ? <p className="notice">Unable to load services from the API.</p> : null}
{createMutation.isError ? <p className="notice">Unable to create service.</p> : null}
{updateMutation.isError ? <p className="notice">Unable to update service.</p> : null}
{deleteMutation.isError ? <p className="notice">Unable to delete service.</p> : null}
<Table
columns={columns}
rows={rows}
renderCell={(row, column) => {
if (column.key === "actions") {
return (
<div className="action-row">
<button className="ghost-button" type="button" onClick={() => startEdit(row.id)}>Edit</button>
<button className="ghost-button" type="button" onClick={() => deleteMutation.mutate(row.id)}>Delete</button>
</div>
);
}
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
}}
/>
{createOpen ? (
<Modal title="Create service" subtitle="Register a named domain-based resource for NexaVPN policies." onClose={() => setCreateOpen(false)}>
<form className="stacked-form" onSubmit={onCreate}>
<input placeholder="service name" value={form.name} onChange={(event) => setForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={form.description} onChange={(event) => setForm((value) => ({ ...value, description: event.target.value }))} />
<input placeholder="domain" value={form.domain} onChange={(event) => setForm((value) => ({ ...value, domain: event.target.value }))} />
<input placeholder="upstream ip" value={form.upstream_ip} onChange={(event) => setForm((value) => ({ ...value, upstream_ip: event.target.value }))} />
<input placeholder="proxy ip" value={form.proxy_ip} onChange={(event) => setForm((value) => ({ ...value, proxy_ip: event.target.value }))} />
<input placeholder="ports: 80,443" value={form.ports} onChange={(event) => setForm((value) => ({ ...value, ports: event.target.value }))} />
<label className="checkbox">
<input type="checkbox" checked={form.is_active} onChange={(event) => setForm((value) => ({ ...value, is_active: event.target.checked }))} />
Active
</label>
<div className="action-row">
<button className="button" type="submit" disabled={createMutation.isPending}>Create service</button>
<button className="ghost-button" type="button" onClick={() => setCreateOpen(false)}>Cancel</button>
</div>
</form>
</Modal>
) : null}
{editingServiceId ? (
<Modal title="Edit service" subtitle="Update the service metadata and proxy target." onClose={() => setEditingServiceId(null)}>
<form className="stacked-form" onSubmit={onEdit}>
<input placeholder="service name" value={form.name} onChange={(event) => setForm((value) => ({ ...value, name: event.target.value }))} />
<input placeholder="description" value={form.description} onChange={(event) => setForm((value) => ({ ...value, description: event.target.value }))} />
<input placeholder="domain" value={form.domain} onChange={(event) => setForm((value) => ({ ...value, domain: event.target.value }))} />
<input placeholder="upstream ip" value={form.upstream_ip} onChange={(event) => setForm((value) => ({ ...value, upstream_ip: event.target.value }))} />
<input placeholder="proxy ip" value={form.proxy_ip} onChange={(event) => setForm((value) => ({ ...value, proxy_ip: event.target.value }))} />
<input placeholder="ports" value={form.ports} onChange={(event) => setForm((value) => ({ ...value, ports: event.target.value }))} />
<label className="checkbox">
<input type="checkbox" checked={form.is_active} onChange={(event) => setForm((value) => ({ ...value, is_active: event.target.checked }))} />
Active
</label>
<div className="action-row">
<button className="button" type="submit" disabled={updateMutation.isPending}>Save changes</button>
<button className="ghost-button" type="button" onClick={() => setEditingServiceId(null)}>Cancel</button>
</div>
</form>
</Modal>
) : null}
</Page>
);
}