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:
@@ -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}`, {
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -5,6 +5,7 @@ const items = [
|
||||
["Users", "/users"],
|
||||
["Groups", "/groups"],
|
||||
["Devices", "/devices"],
|
||||
["Services", "/services"],
|
||||
["Policies", "/policies"],
|
||||
["Gateways", "/gateways"],
|
||||
["Audit", "/audit"],
|
||||
|
||||
@@ -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
|
||||
|
||||
203
admin-web/src/features/services/ServicesPage.tsx
Normal file
203
admin-web/src/features/services/ServicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user