feat: add groups management with CRUD operations and policy target assignment
Add Group type with id, name, description, members array and optional user_ids field. Add name field to policy targets for display. Add groups API client methods for list, create, update and delete operations. Add GroupsPage component with create form, edit modal, member selection and table view. Add groups route and navigation item to Layout. Add reusable Modal component with title, subtitle and close handler. Update
This commit is contained in:
@@ -56,9 +56,22 @@ export type Policy = {
|
|||||||
targets?: Array<{
|
targets?: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
name?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
members: Array<{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
}>;
|
||||||
|
user_ids?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type Gateway = {
|
export type Gateway = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -129,6 +142,29 @@ export const api = {
|
|||||||
request<{ ok: boolean }>(`/admin/users/${userId}`, {
|
request<{ ok: boolean }>(`/admin/users/${userId}`, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
}),
|
}),
|
||||||
|
groups: () => request<Group[]>("/admin/groups"),
|
||||||
|
createGroup: (payload: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
user_ids: string[];
|
||||||
|
}) =>
|
||||||
|
request<Group>("/admin/groups", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
|
updateGroup: (groupId: string, payload: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
user_ids?: string[];
|
||||||
|
}) =>
|
||||||
|
request<Group>(`/admin/groups/${groupId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}),
|
||||||
|
deleteGroup: (groupId: string) =>
|
||||||
|
request<{ ok: boolean }>(`/admin/groups/${groupId}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
}),
|
||||||
devices: () => request<Device[]>("/admin/devices"),
|
devices: () => request<Device[]>("/admin/devices"),
|
||||||
deviceProfile: (deviceId: string) => request<DeviceProfile>(`/admin/devices/${deviceId}/profile`),
|
deviceProfile: (deviceId: string) => request<DeviceProfile>(`/admin/devices/${deviceId}/profile`),
|
||||||
revokeDevice: (deviceId: string) =>
|
revokeDevice: (deviceId: string) =>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LoginPage } from "../features/auth/LoginPage";
|
|||||||
import { DashboardPage } from "../features/dashboard/DashboardPage";
|
import { DashboardPage } from "../features/dashboard/DashboardPage";
|
||||||
import { DevicesPage } from "../features/devices/DevicesPage";
|
import { DevicesPage } from "../features/devices/DevicesPage";
|
||||||
import { GatewaysPage } from "../features/gateways/GatewaysPage";
|
import { GatewaysPage } from "../features/gateways/GatewaysPage";
|
||||||
|
import { GroupsPage } from "../features/groups/GroupsPage";
|
||||||
import { PoliciesPage } from "../features/policies/PoliciesPage";
|
import { PoliciesPage } from "../features/policies/PoliciesPage";
|
||||||
import { SettingsPage } from "../features/settings/SettingsPage";
|
import { SettingsPage } from "../features/settings/SettingsPage";
|
||||||
import { UsersPage } from "../features/users/UsersPage";
|
import { UsersPage } from "../features/users/UsersPage";
|
||||||
@@ -41,6 +42,7 @@ export function App() {
|
|||||||
<Route element={authenticated ? <Layout onLogout={handleLogout} /> : <Navigate to="/login" replace />}>
|
<Route element={authenticated ? <Layout onLogout={handleLogout} /> : <Navigate to="/login" replace />}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="/groups" element={<GroupsPage />} />
|
||||||
<Route path="/devices" element={<DevicesPage />} />
|
<Route path="/devices" element={<DevicesPage />} />
|
||||||
<Route path="/policies" element={<PoliciesPage />} />
|
<Route path="/policies" element={<PoliciesPage />} />
|
||||||
<Route path="/gateways" element={<GatewaysPage />} />
|
<Route path="/gateways" element={<GatewaysPage />} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NavLink, Outlet } from "react-router-dom";
|
|||||||
const items = [
|
const items = [
|
||||||
["Dashboard", "/"],
|
["Dashboard", "/"],
|
||||||
["Users", "/users"],
|
["Users", "/users"],
|
||||||
|
["Groups", "/groups"],
|
||||||
["Devices", "/devices"],
|
["Devices", "/devices"],
|
||||||
["Policies", "/policies"],
|
["Policies", "/policies"],
|
||||||
["Gateways", "/gateways"],
|
["Gateways", "/gateways"],
|
||||||
|
|||||||
26
admin-web/src/components/Modal.tsx
Normal file
26
admin-web/src/components/Modal.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
type ModalProps = PropsWithChildren<{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function Modal({ title, subtitle, onClose, children }: ModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose} role="presentation">
|
||||||
|
<div className="modal-card" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={title}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{subtitle ? <p>{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
<button className="ghost-button" type="button" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
admin-web/src/features/groups/GroupsPage.tsx
Normal file
187
admin-web/src/features/groups/GroupsPage.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
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: "Group" },
|
||||||
|
{ key: "description", label: "Description" },
|
||||||
|
{ key: "members", label: "Members" },
|
||||||
|
{ key: "actions", label: "Actions" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GroupsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: ["groups"],
|
||||||
|
queryFn: api.groups
|
||||||
|
});
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: api.users
|
||||||
|
});
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
user_ids: [] as string[]
|
||||||
|
});
|
||||||
|
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
user_ids: [] as string[]
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: api.createGroup,
|
||||||
|
onSuccess: () => {
|
||||||
|
setForm({ name: "", description: "", user_ids: [] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["groups"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ groupId, payload }: { groupId: string; payload: typeof editForm }) => api.updateGroup(groupId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditingGroupId(null);
|
||||||
|
setEditForm({ name: "", description: "", user_ids: [] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["groups"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: api.deleteGroup,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["groups"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = groupsQuery.data?.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
description: group.description || "-",
|
||||||
|
members: group.members.length ? group.members.map((member) => member.username).join(", ") : "No members"
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
function toggleUser(userId: string, editing = false) {
|
||||||
|
if (editing) {
|
||||||
|
setEditForm((value) => ({
|
||||||
|
...value,
|
||||||
|
user_ids: value.user_ids.includes(userId)
|
||||||
|
? value.user_ids.filter((id) => id !== userId)
|
||||||
|
: [...value.user_ids, userId]
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm((value) => ({
|
||||||
|
...value,
|
||||||
|
user_ids: value.user_ids.includes(userId)
|
||||||
|
? value.user_ids.filter((id) => id !== userId)
|
||||||
|
: [...value.user_ids, userId]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
createMutation.mutate(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(groupId: string) {
|
||||||
|
const group = groupsQuery.data?.find((item) => item.id === groupId);
|
||||||
|
if (!group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditingGroupId(groupId);
|
||||||
|
setEditForm({
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
user_ids: group.members.map((member) => member.id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editingGroupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateMutation.mutate({
|
||||||
|
groupId: editingGroupId,
|
||||||
|
payload: editForm
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
title="Groups"
|
||||||
|
subtitle="Bundle users into reusable access groups for policy assignment."
|
||||||
|
actions={<span className="pill">Reusable targets</span>}
|
||||||
|
>
|
||||||
|
<form className="stacked-form card" onSubmit={onSubmit}>
|
||||||
|
<div className="form-grid two">
|
||||||
|
<input placeholder="group 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 }))} />
|
||||||
|
</div>
|
||||||
|
<div className="selection-panel">
|
||||||
|
<p className="eyebrow">Members</p>
|
||||||
|
<div className="selection-list">
|
||||||
|
{(usersQuery.data ?? []).map((user) => (
|
||||||
|
<label className="selection-item" key={user.id}>
|
||||||
|
<input type="checkbox" checked={form.user_ids.includes(user.id)} onChange={() => toggleUser(user.id)} />
|
||||||
|
<span>{user.username}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-row">
|
||||||
|
<button className="button" type="submit" disabled={createMutation.isPending}>Create group</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{groupsQuery.isError ? <p className="notice">Unable to load groups from the API.</p> : null}
|
||||||
|
{createMutation.isError ? <p className="notice">Unable to create group.</p> : null}
|
||||||
|
{updateMutation.isError ? <p className="notice">Unable to update group.</p> : null}
|
||||||
|
{deleteMutation.isError ? <p className="notice">Unable to delete group.</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>;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editingGroupId ? (
|
||||||
|
<Modal title="Edit group" subtitle="Adjust members and details in one place." onClose={() => setEditingGroupId(null)}>
|
||||||
|
<form className="stacked-form" onSubmit={onEditSubmit}>
|
||||||
|
<input placeholder="group name" value={editForm.name} onChange={(event) => setEditForm((value) => ({ ...value, name: event.target.value }))} />
|
||||||
|
<input placeholder="description" value={editForm.description} onChange={(event) => setEditForm((value) => ({ ...value, description: event.target.value }))} />
|
||||||
|
<div className="selection-panel">
|
||||||
|
<p className="eyebrow">Members</p>
|
||||||
|
<div className="selection-list">
|
||||||
|
{(usersQuery.data ?? []).map((user) => (
|
||||||
|
<label className="selection-item" key={user.id}>
|
||||||
|
<input type="checkbox" checked={editForm.user_ids.includes(user.id)} onChange={() => toggleUser(user.id, true)} />
|
||||||
|
<span>{user.username}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-row">
|
||||||
|
<button className="button" type="submit" disabled={updateMutation.isPending}>Save group</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={() => setEditingGroupId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { FormEvent, useMemo, useState } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "../../api/client";
|
import { api } from "../../api/client";
|
||||||
|
import { Modal } from "../../components/Modal";
|
||||||
import { Page } from "../../components/Page";
|
import { Page } from "../../components/Page";
|
||||||
import { Table } from "../../components/Table";
|
import { Table } from "../../components/Table";
|
||||||
|
|
||||||
@@ -23,36 +24,45 @@ export function PoliciesPage() {
|
|||||||
queryKey: ["users"],
|
queryKey: ["users"],
|
||||||
queryFn: api.users
|
queryFn: api.users
|
||||||
});
|
});
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: ["groups"],
|
||||||
|
queryFn: api.groups
|
||||||
|
});
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
destinations: "",
|
destinations: "",
|
||||||
targetUserId: "",
|
targetType: "user",
|
||||||
|
targetIds: [] as string[],
|
||||||
fullTunnel: false
|
fullTunnel: false
|
||||||
});
|
});
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editingPolicyId, setEditingPolicyId] = useState<string | null>(null);
|
const [editingPolicyId, setEditingPolicyId] = useState<string | null>(null);
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
destinations: "",
|
destinations: "",
|
||||||
fullTunnel: false,
|
fullTunnel: false,
|
||||||
isActive: true
|
isActive: true,
|
||||||
|
targetType: "user",
|
||||||
|
targetIds: [] as string[]
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: api.createPolicy,
|
mutationFn: api.createPolicy,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setForm({ name: "", description: "", destinations: "", targetUserId: "", fullTunnel: false });
|
setCreateOpen(false);
|
||||||
|
setForm({ name: "", description: "", destinations: "", targetType: "user", targetIds: [], fullTunnel: false });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["policies"] });
|
void queryClient.invalidateQueries({ queryKey: ["policies"] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ policyId, payload }: { policyId: string; payload: { name: string; description: string; destinations: string[]; full_tunnel: boolean; is_active: boolean } }) =>
|
mutationFn: ({ policyId, payload }: { policyId: string; payload: { name: string; description: string; destinations: string[]; full_tunnel: boolean; is_active: boolean; targets: Array<{ type: string; id: string }> } }) =>
|
||||||
api.updatePolicy(policyId, payload),
|
api.updatePolicy(policyId, payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setEditingPolicyId(null);
|
setEditingPolicyId(null);
|
||||||
setEditForm({ name: "", description: "", destinations: "", fullTunnel: false, isActive: true });
|
setEditForm({ name: "", description: "", destinations: "", fullTunnel: false, isActive: true, targetType: "user", targetIds: [] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["policies"] });
|
void queryClient.invalidateQueries({ queryKey: ["policies"] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -67,16 +77,19 @@ export function PoliciesPage() {
|
|||||||
const rows = query.data?.map((policy) => ({
|
const rows = query.data?.map((policy) => ({
|
||||||
id: policy.id,
|
id: policy.id,
|
||||||
name: policy.name,
|
name: policy.name,
|
||||||
targets: policy.targets?.length ? `${policy.targets.length} target(s)` : "assigned targets",
|
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.destinations?.join(", ") ?? (policy.full_tunnel ? "0.0.0.0/0" : "-"),
|
||||||
mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel"
|
mode: policy.full_tunnel ? "Full tunnel" : "Split tunnel"
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
const selectableUsers = useMemo(() => usersQuery.data ?? [], [usersQuery.data]);
|
const selectableUsers = useMemo(() => usersQuery.data ?? [], [usersQuery.data]);
|
||||||
|
const selectableGroups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
||||||
|
const selectableTargets = form.targetType === "group" ? selectableGroups : selectableUsers;
|
||||||
|
const editableTargets = editForm.targetType === "group" ? selectableGroups : selectableUsers;
|
||||||
|
|
||||||
function onSubmit(event: FormEvent) {
|
function onSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!form.targetUserId) {
|
if (!form.targetIds.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
@@ -86,7 +99,7 @@ export function PoliciesPage() {
|
|||||||
effect: "allow",
|
effect: "allow",
|
||||||
full_tunnel: form.fullTunnel,
|
full_tunnel: form.fullTunnel,
|
||||||
destinations: form.fullTunnel ? ["0.0.0.0/0"] : form.destinations.split(",").map((value) => value.trim()).filter(Boolean),
|
destinations: form.fullTunnel ? ["0.0.0.0/0"] : form.destinations.split(",").map((value) => value.trim()).filter(Boolean),
|
||||||
targets: [{ type: "user", id: form.targetUserId }]
|
targets: form.targetIds.map((id) => ({ type: form.targetType, id }))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +109,15 @@ export function PoliciesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditingPolicyId(policyId);
|
setEditingPolicyId(policyId);
|
||||||
|
const targetType = policy.targets?.[0]?.type ?? "user";
|
||||||
setEditForm({
|
setEditForm({
|
||||||
name: policy.name,
|
name: policy.name,
|
||||||
description: policy.description,
|
description: policy.description,
|
||||||
destinations: policy.destinations?.join(", ") ?? "",
|
destinations: policy.destinations?.join(", ") ?? "",
|
||||||
fullTunnel: policy.full_tunnel,
|
fullTunnel: policy.full_tunnel,
|
||||||
isActive: policy.is_active
|
isActive: policy.is_active,
|
||||||
|
targetType,
|
||||||
|
targetIds: policy.targets?.filter((target) => target.type === targetType).map((target) => target.id) ?? []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,68 +133,37 @@ export function PoliciesPage() {
|
|||||||
description: editForm.description,
|
description: editForm.description,
|
||||||
destinations: editForm.fullTunnel ? ["0.0.0.0/0"] : editForm.destinations.split(",").map((value) => value.trim()).filter(Boolean),
|
destinations: editForm.fullTunnel ? ["0.0.0.0/0"] : editForm.destinations.split(",").map((value) => value.trim()).filter(Boolean),
|
||||||
full_tunnel: editForm.fullTunnel,
|
full_tunnel: editForm.fullTunnel,
|
||||||
is_active: editForm.isActive
|
is_active: editForm.isActive,
|
||||||
|
targets: editForm.targetIds.map((id) => ({ type: editForm.targetType, id }))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleTarget(id: string, editing = false) {
|
||||||
|
const setter = editing ? setEditForm : setForm;
|
||||||
|
setter((value) => ({
|
||||||
|
...value,
|
||||||
|
targetIds: value.targetIds.includes(id)
|
||||||
|
? value.targetIds.filter((item) => item !== id)
|
||||||
|
: [...value.targetIds, id]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
title="Policies"
|
title="Policies"
|
||||||
subtitle="Manage per-user and per-device access controls enforced at the gateway."
|
subtitle="Manage per-user and per-device access controls enforced at the gateway."
|
||||||
actions={<span className="pill">Gateway enforced</span>}
|
actions={(
|
||||||
|
<div className="action-row">
|
||||||
|
<span className="pill">Gateway enforced</span>
|
||||||
|
<button className="button" type="button" onClick={() => setCreateOpen(true)}>New policy</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<form className="inline-form" onSubmit={onSubmit}>
|
|
||||||
<input placeholder="policy 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="destinations: 172.16.10.0/24,10.0.0.5/32"
|
|
||||||
value={form.destinations}
|
|
||||||
onChange={(event) => setForm((value) => ({ ...value, destinations: event.target.value }))}
|
|
||||||
disabled={form.fullTunnel}
|
|
||||||
/>
|
|
||||||
<select value={form.targetUserId} onChange={(event) => setForm((value) => ({ ...value, targetUserId: event.target.value }))}>
|
|
||||||
<option value="">target user</option>
|
|
||||||
{selectableUsers.map((user) => (
|
|
||||||
<option key={user.id} value={user.id}>
|
|
||||||
{user.username}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<label className="checkbox">
|
|
||||||
<input type="checkbox" checked={form.fullTunnel} onChange={(event) => setForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
|
|
||||||
Full tunnel
|
|
||||||
</label>
|
|
||||||
<button className="button" type="submit" disabled={createMutation.isPending}>Create policy</button>
|
|
||||||
</form>
|
|
||||||
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
|
{query.isError ? <p className="notice">Unable to load policies from the API.</p> : null}
|
||||||
{createMutation.isError ? <p className="notice">Unable to create policy.</p> : null}
|
{createMutation.isError ? <p className="notice">Unable to create policy.</p> : null}
|
||||||
{updateMutation.isError ? <p className="notice">Unable to update policy.</p> : null}
|
{updateMutation.isError ? <p className="notice">Unable to update policy.</p> : null}
|
||||||
{deleteMutation.isError ? <p className="notice">Unable to delete policy.</p> : null}
|
{deleteMutation.isError ? <p className="notice">Unable to delete policy.</p> : null}
|
||||||
{editingPolicyId ? (
|
|
||||||
<form className="inline-form" onSubmit={onEditSubmit}>
|
|
||||||
<input placeholder="policy name" value={editForm.name} onChange={(event) => setEditForm((value) => ({ ...value, name: event.target.value }))} />
|
|
||||||
<input placeholder="description" value={editForm.description} onChange={(event) => setEditForm((value) => ({ ...value, description: event.target.value }))} />
|
|
||||||
<input
|
|
||||||
placeholder="destinations"
|
|
||||||
value={editForm.destinations}
|
|
||||||
onChange={(event) => setEditForm((value) => ({ ...value, destinations: event.target.value }))}
|
|
||||||
disabled={editForm.fullTunnel}
|
|
||||||
/>
|
|
||||||
<label className="checkbox">
|
|
||||||
<input type="checkbox" checked={editForm.fullTunnel} onChange={(event) => setEditForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
|
|
||||||
Full tunnel
|
|
||||||
</label>
|
|
||||||
<label className="checkbox">
|
|
||||||
<input type="checkbox" checked={editForm.isActive} onChange={(event) => setEditForm((value) => ({ ...value, isActive: event.target.checked }))} />
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
<div className="action-row">
|
|
||||||
<button className="button" type="submit" disabled={updateMutation.isPending}>Save policy</button>
|
|
||||||
<button className="ghost-button" type="button" onClick={() => setEditingPolicyId(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -194,6 +179,84 @@ export function PoliciesPage() {
|
|||||||
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
|
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{createOpen ? (
|
||||||
|
<Modal title="Create policy" subtitle="Choose destinations and assign them to users or groups." onClose={() => setCreateOpen(false)}>
|
||||||
|
<form className="stacked-form" onSubmit={onSubmit}>
|
||||||
|
<input placeholder="policy 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 }))} />
|
||||||
|
<select value={form.targetType} onChange={(event) => setForm((value) => ({ ...value, targetType: event.target.value, targetIds: [] }))}>
|
||||||
|
<option value="user">Users</option>
|
||||||
|
<option value="group">Groups</option>
|
||||||
|
</select>
|
||||||
|
<div className="selection-panel">
|
||||||
|
<p className="eyebrow">{form.targetType === "group" ? "Target groups" : "Target users"}</p>
|
||||||
|
<div className="selection-list">
|
||||||
|
{selectableTargets.map((target) => (
|
||||||
|
<label className="selection-item" key={target.id}>
|
||||||
|
<input type="checkbox" checked={form.targetIds.includes(target.id)} onChange={() => toggleTarget(target.id)} />
|
||||||
|
<span>{"username" in target ? target.username : target.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
placeholder="destinations: 172.16.10.0/24,10.0.0.5/32"
|
||||||
|
value={form.destinations}
|
||||||
|
onChange={(event) => setForm((value) => ({ ...value, destinations: event.target.value }))}
|
||||||
|
disabled={form.fullTunnel}
|
||||||
|
/>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={form.fullTunnel} onChange={(event) => setForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
|
||||||
|
Full tunnel
|
||||||
|
</label>
|
||||||
|
<div className="action-row">
|
||||||
|
<button className="button" type="submit" disabled={createMutation.isPending}>Create policy</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={() => setCreateOpen(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
{editingPolicyId ? (
|
||||||
|
<Modal title="Edit policy" subtitle="Change targets, mode, and destinations in one place." onClose={() => setEditingPolicyId(null)}>
|
||||||
|
<form className="stacked-form" onSubmit={onEditSubmit}>
|
||||||
|
<input placeholder="policy name" value={editForm.name} onChange={(event) => setEditForm((value) => ({ ...value, name: event.target.value }))} />
|
||||||
|
<input placeholder="description" value={editForm.description} onChange={(event) => setEditForm((value) => ({ ...value, description: event.target.value }))} />
|
||||||
|
<select value={editForm.targetType} onChange={(event) => setEditForm((value) => ({ ...value, targetType: event.target.value, targetIds: [] }))}>
|
||||||
|
<option value="user">Users</option>
|
||||||
|
<option value="group">Groups</option>
|
||||||
|
</select>
|
||||||
|
<div className="selection-panel">
|
||||||
|
<p className="eyebrow">{editForm.targetType === "group" ? "Target groups" : "Target users"}</p>
|
||||||
|
<div className="selection-list">
|
||||||
|
{editableTargets.map((target) => (
|
||||||
|
<label className="selection-item" key={target.id}>
|
||||||
|
<input type="checkbox" checked={editForm.targetIds.includes(target.id)} onChange={() => toggleTarget(target.id, true)} />
|
||||||
|
<span>{"username" in target ? target.username : target.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
placeholder="destinations"
|
||||||
|
value={editForm.destinations}
|
||||||
|
onChange={(event) => setEditForm((value) => ({ ...value, destinations: event.target.value }))}
|
||||||
|
disabled={editForm.fullTunnel}
|
||||||
|
/>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={editForm.fullTunnel} onChange={(event) => setEditForm((value) => ({ ...value, fullTunnel: event.target.checked }))} />
|
||||||
|
Full tunnel
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={editForm.isActive} onChange={(event) => setEditForm((value) => ({ ...value, isActive: event.target.checked }))} />
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
<div className="action-row">
|
||||||
|
<button className="button" type="submit" disabled={updateMutation.isPending}>Save policy</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={() => setEditingPolicyId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "../../api/client";
|
import { api } from "../../api/client";
|
||||||
|
import { Modal } from "../../components/Modal";
|
||||||
import { Page } from "../../components/Page";
|
import { Page } from "../../components/Page";
|
||||||
import { Table } from "../../components/Table";
|
import { Table } from "../../components/Table";
|
||||||
|
|
||||||
@@ -121,25 +122,6 @@ export function UsersPage() {
|
|||||||
{createMutation.isError ? <p className="notice">Unable to create user.</p> : null}
|
{createMutation.isError ? <p className="notice">Unable to create user.</p> : null}
|
||||||
{updateMutation.isError ? <p className="notice">Unable to update user.</p> : null}
|
{updateMutation.isError ? <p className="notice">Unable to update user.</p> : null}
|
||||||
{deleteMutation.isError ? <p className="notice">Unable to delete user.</p> : null}
|
{deleteMutation.isError ? <p className="notice">Unable to delete user.</p> : null}
|
||||||
{editingUserId ? (
|
|
||||||
<form className="inline-form" onSubmit={onEditSubmit}>
|
|
||||||
<input placeholder="display name" value={editForm.display_name} onChange={(event) => setEditForm((value) => ({ ...value, display_name: event.target.value }))} />
|
|
||||||
<input placeholder="email" value={editForm.email} onChange={(event) => setEditForm((value) => ({ ...value, email: event.target.value }))} />
|
|
||||||
<select value={editForm.role} onChange={(event) => setEditForm((value) => ({ ...value, role: event.target.value }))}>
|
|
||||||
<option value="user">user</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
<input placeholder="new password (optional)" type="password" value={editForm.password} onChange={(event) => setEditForm((value) => ({ ...value, password: event.target.value }))} />
|
|
||||||
<label className="checkbox">
|
|
||||||
<input type="checkbox" checked={editForm.is_active} onChange={(event) => setEditForm((value) => ({ ...value, is_active: event.target.checked }))} />
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
<div className="action-row">
|
|
||||||
<button className="button" type="submit" disabled={updateMutation.isPending}>Save user</button>
|
|
||||||
<button className="ghost-button" type="button" onClick={() => setEditingUserId(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -155,6 +137,27 @@ export function UsersPage() {
|
|||||||
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
|
return <span>{row[column.key as keyof (typeof rows)[number]]}</span>;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{editingUserId ? (
|
||||||
|
<Modal title="Edit user" subtitle="Update role, contact details, and account state." onClose={() => setEditingUserId(null)}>
|
||||||
|
<form className="stacked-form" onSubmit={onEditSubmit}>
|
||||||
|
<input placeholder="display name" value={editForm.display_name} onChange={(event) => setEditForm((value) => ({ ...value, display_name: event.target.value }))} />
|
||||||
|
<input placeholder="email" value={editForm.email} onChange={(event) => setEditForm((value) => ({ ...value, email: event.target.value }))} />
|
||||||
|
<select value={editForm.role} onChange={(event) => setEditForm((value) => ({ ...value, role: event.target.value }))}>
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<input placeholder="new password (optional)" type="password" value={editForm.password} onChange={(event) => setEditForm((value) => ({ ...value, password: event.target.value }))} />
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={editForm.is_active} onChange={(event) => setEditForm((value) => ({ ...value, is_active: event.target.checked }))} />
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
<div className="action-row">
|
||||||
|
<button className="button" type="submit" disabled={updateMutation.isPending}>Save user</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={() => setEditingUserId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,30 @@ button {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stacked-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-form input,
|
||||||
|
.stacked-form select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(8, 14, 26, 0.86);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid.two {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.inline-form input,
|
.inline-form input,
|
||||||
.inline-form select {
|
.inline-form select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -309,6 +333,60 @@ button {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selection-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(8, 14, 26, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(2, 6, 15, 0.72);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #111b30;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.shell {
|
.shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"nexavpn/backend/internal/db"
|
"nexavpn/backend/internal/db"
|
||||||
"nexavpn/backend/internal/device"
|
"nexavpn/backend/internal/device"
|
||||||
"nexavpn/backend/internal/gateway"
|
"nexavpn/backend/internal/gateway"
|
||||||
|
"nexavpn/backend/internal/group"
|
||||||
"nexavpn/backend/internal/httpserver"
|
"nexavpn/backend/internal/httpserver"
|
||||||
"nexavpn/backend/internal/ipam"
|
"nexavpn/backend/internal/ipam"
|
||||||
"nexavpn/backend/internal/policy"
|
"nexavpn/backend/internal/policy"
|
||||||
@@ -35,6 +36,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
authService := auth.NewService(authRepo, cfg.JWTSecret, cfg.JWTIssuer, cfg.AccessTokenTTL, cfg.RefreshTokenTTL)
|
authService := auth.NewService(authRepo, cfg.JWTSecret, cfg.JWTIssuer, cfg.AccessTokenTTL, cfg.RefreshTokenTTL)
|
||||||
|
|
||||||
userService := user.NewService(user.NewPGRepository(pool))
|
userService := user.NewService(user.NewPGRepository(pool))
|
||||||
|
groupService := group.NewService(group.NewPGRepository(pool))
|
||||||
policyService := policy.NewService(policy.NewPGRepository(pool))
|
policyService := policy.NewService(policy.NewPGRepository(pool))
|
||||||
gatewayService := gateway.NewService(gateway.NewPGRepository(pool))
|
gatewayService := gateway.NewService(gateway.NewPGRepository(pool))
|
||||||
deviceService := device.NewService(device.NewPGRepository(pool), policyService, gatewayService, ipam.NewService())
|
deviceService := device.NewService(device.NewPGRepository(pool), policyService, gatewayService, ipam.NewService())
|
||||||
@@ -44,6 +46,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
Auth: auth.NewHandler(authService, auditService),
|
Auth: auth.NewHandler(authService, auditService),
|
||||||
User: user.NewHandler(userService, auditService),
|
User: user.NewHandler(userService, auditService),
|
||||||
Device: device.NewHandler(deviceService, auditService),
|
Device: device.NewHandler(deviceService, auditService),
|
||||||
|
Group: group.NewHandler(groupService, auditService),
|
||||||
Policy: policy.NewHandler(policyService, auditService),
|
Policy: policy.NewHandler(policyService, auditService),
|
||||||
Gateway: gateway.NewHandler(gatewayService, cfg.GatewayBootstrapToken),
|
Gateway: gateway.NewHandler(gatewayService, cfg.GatewayBootstrapToken),
|
||||||
Audit: audit.NewHandler(auditService),
|
Audit: audit.NewHandler(auditService),
|
||||||
|
|||||||
@@ -92,9 +92,11 @@ func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID)
|
|||||||
coalesce(array_agg(distinct pd.destination::text) filter (where pd.destination is not null), '{}')
|
coalesce(array_agg(distinct pd.destination::text) filter (where pd.destination is not null), '{}')
|
||||||
from devices d
|
from devices d
|
||||||
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
||||||
|
left join group_memberships gm on gm.user_id = d.user_id
|
||||||
left join policy_targets pt on (
|
left join policy_targets pt on (
|
||||||
(pt.target_type = 'device' and pt.target_id = d.id) or
|
(pt.target_type = 'device' and pt.target_id = d.id) or
|
||||||
(pt.target_type = 'user' and pt.target_id = d.user_id)
|
(pt.target_type = 'user' and pt.target_id = d.user_id) or
|
||||||
|
(pt.target_type = 'group' and pt.target_id = gm.group_id)
|
||||||
)
|
)
|
||||||
left join policy_destinations pd on pd.policy_id = pt.policy_id
|
left join policy_destinations pd on pd.policy_id = pt.policy_id
|
||||||
where d.gateway_id = $1 and d.deleted_at is null and d.status = 'active'
|
where d.gateway_id = $1 and d.deleted_at is null and d.status = 'active'
|
||||||
|
|||||||
117
backend/internal/group/handler.go
Normal file
117
backend/internal/group/handler.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"nexavpn/backend/internal/apiutil"
|
||||||
|
"nexavpn/backend/internal/audit"
|
||||||
|
"nexavpn/backend/internal/requestctx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
audit *audit.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *Service, auditService *audit.Service) *Handler {
|
||||||
|
return &Handler{service: service, audit: auditService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := h.service.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "groups_list_failed", "unable to list groups")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiutil.JSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input CreateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := h.service.Create(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "group_create_failed", "unable to create group")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EntityType: "group",
|
||||||
|
EntityID: &item.ID,
|
||||||
|
EventType: "admin.group.created",
|
||||||
|
Status: "success",
|
||||||
|
Message: "admin created group",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusCreated, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_group_id", "invalid group id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input UpdateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := h.service.Update(r.Context(), groupID, input)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "group_update_failed", "unable to update group")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EntityType: "group",
|
||||||
|
EntityID: &groupID,
|
||||||
|
EventType: "admin.group.updated",
|
||||||
|
Status: "success",
|
||||||
|
Message: "admin updated group",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groupID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
apiutil.Error(w, http.StatusBadRequest, "invalid_group_id", "invalid group id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.Delete(r.Context(), groupID); err != nil {
|
||||||
|
apiutil.Error(w, http.StatusInternalServerError, "group_delete_failed", "unable to delete group")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
||||||
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
||||||
|
ActorUserID: &claims.UserID,
|
||||||
|
EntityType: "group",
|
||||||
|
EntityID: &groupID,
|
||||||
|
EventType: "admin.group.deleted",
|
||||||
|
Status: "success",
|
||||||
|
Message: "admin deleted group",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
183
backend/internal/group/repository.go
Normal file
183
backend/internal/group/repository.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
List(ctx context.Context) ([]Group, error)
|
||||||
|
Create(ctx context.Context, input CreateRequest) (Group, error)
|
||||||
|
Update(ctx context.Context, groupID uuid.UUID, input UpdateRequest) (Group, error)
|
||||||
|
Delete(ctx context.Context, groupID uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type PGRepository struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
|
||||||
|
return &PGRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) List(ctx context.Context) ([]Group, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select id, name, description
|
||||||
|
from groups
|
||||||
|
where deleted_at is null
|
||||||
|
order by created_at desc
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []Group
|
||||||
|
for rows.Next() {
|
||||||
|
var item Group
|
||||||
|
if err := rows.Scan(&item.ID, &item.Name, &item.Description); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := r.listMembers(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Members = members
|
||||||
|
for _, member := range members {
|
||||||
|
item.UserIDs = append(item.UserIDs, member.ID)
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Create(ctx context.Context, input CreateRequest) (Group, error) {
|
||||||
|
tx, err := r.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
groupID := uuid.New()
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
insert into groups (id, name, description)
|
||||||
|
values ($1, $2, $3)
|
||||||
|
`, groupID, input.Name, input.Description); err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, userID := range input.UserIDs {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
insert into group_memberships (id, group_id, user_id)
|
||||||
|
values ($1, $2, $3)
|
||||||
|
`, uuid.New(), groupID, userID); err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.getByID(ctx, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Update(ctx context.Context, groupID uuid.UUID, input UpdateRequest) (Group, error) {
|
||||||
|
tx, err := r.db.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
update groups
|
||||||
|
set name = coalesce($2, name),
|
||||||
|
description = coalesce($3, description),
|
||||||
|
updated_at = now()
|
||||||
|
where id = $1 and deleted_at is null
|
||||||
|
`, groupID, input.Name, input.Description); err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.UserIDs != nil {
|
||||||
|
if _, err := tx.Exec(ctx, `delete from group_memberships where group_id = $1`, groupID); err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
for _, userID := range input.UserIDs {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
insert into group_memberships (id, group_id, user_id)
|
||||||
|
values ($1, $2, $3)
|
||||||
|
`, uuid.New(), groupID, userID); err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.getByID(ctx, groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) Delete(ctx context.Context, groupID uuid.UUID) error {
|
||||||
|
_, err := r.db.Exec(ctx, `
|
||||||
|
update groups
|
||||||
|
set deleted_at = now(), updated_at = now()
|
||||||
|
where id = $1 and deleted_at is null
|
||||||
|
`, groupID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) getByID(ctx context.Context, groupID uuid.UUID) (Group, error) {
|
||||||
|
row := r.db.QueryRow(ctx, `
|
||||||
|
select id, name, description
|
||||||
|
from groups
|
||||||
|
where id = $1 and deleted_at is null
|
||||||
|
`, groupID)
|
||||||
|
|
||||||
|
var item Group
|
||||||
|
if err := row.Scan(&item.ID, &item.Name, &item.Description); err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := r.listMembers(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Group{}, err
|
||||||
|
}
|
||||||
|
item.Members = members
|
||||||
|
for _, member := range members {
|
||||||
|
item.UserIDs = append(item.UserIDs, member.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) listMembers(ctx context.Context, groupID uuid.UUID) ([]Member, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select u.id, u.username, u.display_name
|
||||||
|
from group_memberships gm
|
||||||
|
join users u on u.id = gm.user_id
|
||||||
|
where gm.group_id = $1 and u.deleted_at is null
|
||||||
|
order by u.username asc
|
||||||
|
`, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []Member
|
||||||
|
for rows.Next() {
|
||||||
|
var item Member
|
||||||
|
if err := rows.Scan(&item.ID, &item.Username, &item.DisplayName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
31
backend/internal/group/service.go
Normal file
31
backend/internal/group/service.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo Repository) *Service {
|
||||||
|
return &Service{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context) ([]Group, error) {
|
||||||
|
return s.repo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, input CreateRequest) (Group, error) {
|
||||||
|
return s.repo.Create(ctx, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Update(ctx context.Context, groupID uuid.UUID, input UpdateRequest) (Group, error) {
|
||||||
|
return s.repo.Update(ctx, groupID, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(ctx context.Context, groupID uuid.UUID) error {
|
||||||
|
return s.repo.Delete(ctx, groupID)
|
||||||
|
}
|
||||||
29
backend/internal/group/types.go
Normal file
29
backend/internal/group/types.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package group
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
type Member struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Members []Member `json:"members"`
|
||||||
|
UserIDs []uuid.UUID `json:"user_ids,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
UserIDs []uuid.UUID `json:"user_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
UserIDs []uuid.UUID `json:"user_ids"`
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"nexavpn/backend/internal/audit"
|
"nexavpn/backend/internal/audit"
|
||||||
"nexavpn/backend/internal/device"
|
"nexavpn/backend/internal/device"
|
||||||
"nexavpn/backend/internal/gateway"
|
"nexavpn/backend/internal/gateway"
|
||||||
|
"nexavpn/backend/internal/group"
|
||||||
"nexavpn/backend/internal/policy"
|
"nexavpn/backend/internal/policy"
|
||||||
"nexavpn/backend/internal/user"
|
"nexavpn/backend/internal/user"
|
||||||
)
|
)
|
||||||
@@ -20,6 +21,7 @@ type Handlers struct {
|
|||||||
Device *device.Handler
|
Device *device.Handler
|
||||||
Policy *policy.Handler
|
Policy *policy.Handler
|
||||||
Gateway *gateway.Handler
|
Gateway *gateway.Handler
|
||||||
|
Group *group.Handler
|
||||||
Audit *audit.Handler
|
Audit *audit.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +62,10 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
|
|||||||
r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID)
|
r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID)
|
||||||
r.Post("/devices/{id}/revoke", handlers.Device.Revoke)
|
r.Post("/devices/{id}/revoke", handlers.Device.Revoke)
|
||||||
r.Post("/devices/{id}/rotate", handlers.Device.Rotate)
|
r.Post("/devices/{id}/rotate", handlers.Device.Rotate)
|
||||||
|
r.Get("/groups", handlers.Group.List)
|
||||||
|
r.Post("/groups", handlers.Group.Create)
|
||||||
|
r.Patch("/groups/{id}", handlers.Group.Update)
|
||||||
|
r.Delete("/groups/{id}", handlers.Group.Delete)
|
||||||
r.Get("/policies", handlers.Policy.List)
|
r.Get("/policies", handlers.Policy.List)
|
||||||
r.Post("/policies", handlers.Policy.Create)
|
r.Post("/policies", handlers.Policy.Create)
|
||||||
r.Patch("/policies/{id}", handlers.Policy.Update)
|
r.Patch("/policies/{id}", handlers.Policy.Update)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ func (r *PGRepository) List(ctx context.Context) ([]Policy, error) {
|
|||||||
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.Priority, &item.Effect, &item.FullTunnel, &item.IsActive, &item.Destinations); err != nil {
|
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.Priority, &item.Effect, &item.FullTunnel, &item.IsActive, &item.Destinations); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
targets, err := r.listTargets(ctx, item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Targets = targets
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return items, rows.Err()
|
return items, rows.Err()
|
||||||
@@ -95,18 +100,7 @@ func (r *PGRepository) Create(ctx context.Context, input CreateRequest, createdB
|
|||||||
return Policy{}, err
|
return Policy{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
inputPolicy := Policy{
|
return r.getByID(ctx, policyID)
|
||||||
ID: policyID,
|
|
||||||
Name: input.Name,
|
|
||||||
Description: input.Description,
|
|
||||||
Priority: input.Priority,
|
|
||||||
Effect: input.Effect,
|
|
||||||
FullTunnel: input.FullTunnel,
|
|
||||||
IsActive: true,
|
|
||||||
Destinations: input.Destinations,
|
|
||||||
Targets: input.Targets,
|
|
||||||
}
|
|
||||||
return inputPolicy, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PGRepository) Update(ctx context.Context, policyID uuid.UUID, input UpdateRequest) (Policy, error) {
|
func (r *PGRepository) Update(ctx context.Context, policyID uuid.UUID, input UpdateRequest) (Policy, error) {
|
||||||
@@ -164,19 +158,7 @@ func (r *PGRepository) Update(ctx context.Context, policyID uuid.UUID, input Upd
|
|||||||
return Policy{}, err
|
return Policy{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := r.List(ctx)
|
return r.getByID(ctx, policyID)
|
||||||
if err != nil {
|
|
||||||
return Policy{}, err
|
|
||||||
}
|
|
||||||
for _, item := range items {
|
|
||||||
if item.ID == policyID {
|
|
||||||
if input.Targets != nil {
|
|
||||||
item.Targets = input.Targets
|
|
||||||
}
|
|
||||||
return item, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Policy{}, errors.New("policy not found after update")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PGRepository) Delete(ctx context.Context, policyID uuid.UUID) error {
|
func (r *PGRepository) Delete(ctx context.Context, policyID uuid.UUID) error {
|
||||||
@@ -195,6 +177,10 @@ func (r *PGRepository) ResolveDestinations(ctx context.Context, userID uuid.UUID
|
|||||||
and p.effect = 'allow'
|
and p.effect = 'allow'
|
||||||
and (
|
and (
|
||||||
(pt.target_type = 'user' and pt.target_id = $1)
|
(pt.target_type = 'user' and pt.target_id = $1)
|
||||||
|
or (pt.target_type = 'group' and exists (
|
||||||
|
select 1 from group_memberships gm
|
||||||
|
where gm.group_id = pt.target_id and gm.user_id = $1
|
||||||
|
))
|
||||||
`
|
`
|
||||||
args := []any{userID}
|
args := []any{userID}
|
||||||
if deviceID != nil {
|
if deviceID != nil {
|
||||||
@@ -219,3 +205,46 @@ func (r *PGRepository) ResolveDestinations(ctx context.Context, userID uuid.UUID
|
|||||||
}
|
}
|
||||||
return destinations, rows.Err()
|
return destinations, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) getByID(ctx context.Context, policyID uuid.UUID) (Policy, error) {
|
||||||
|
items, err := r.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Policy{}, err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ID == policyID {
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Policy{}, errors.New("policy not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) listTargets(ctx context.Context, policyID uuid.UUID) ([]Target, error) {
|
||||||
|
rows, err := r.db.Query(ctx, `
|
||||||
|
select
|
||||||
|
pt.target_type,
|
||||||
|
pt.target_id,
|
||||||
|
coalesce(u.username, g.name, d.name, '')
|
||||||
|
from policy_targets pt
|
||||||
|
left join users u on pt.target_type = 'user' and u.id = pt.target_id and u.deleted_at is null
|
||||||
|
left join groups g on pt.target_type = 'group' and g.id = pt.target_id and g.deleted_at is null
|
||||||
|
left join devices d on pt.target_type = 'device' and d.id = pt.target_id and d.deleted_at is null
|
||||||
|
where pt.policy_id = $1
|
||||||
|
order by pt.created_at asc
|
||||||
|
`, policyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []Target
|
||||||
|
for rows.Next() {
|
||||||
|
var item Target
|
||||||
|
if err := rows.Scan(&item.Type, &item.ID, &item.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "github.com/google/uuid"
|
|||||||
type Target struct {
|
type Target struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Policy struct {
|
type Policy struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user