small clients ui tweaks

This commit is contained in:
miloschwartz 2025-04-20 16:49:48 -04:00
parent f960fb7d67
commit fa6fc9e80d
No known key found for this signature in database
25 changed files with 215 additions and 377 deletions

View file

@ -2,7 +2,7 @@ import {
encodeHexLowerCase, encodeHexLowerCase,
} from "@oslojs/encoding"; } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { Olm, olms, olmSessions, OlmSession } from "@server/db/schema"; import { Olm, olms, olmSessions, OlmSession } from "@server/db/schemas";
import db from "@server/db"; import db from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";

View file

@ -1,5 +1,5 @@
import db from "@server/db"; import db from "@server/db";
import { clients, orgs, sites } from "@server/db/schema"; import { clients, orgs, sites } from "@server/db/schemas";
import { and, eq, isNotNull } from "drizzle-orm"; import { and, eq, isNotNull } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { userOrgs, clients, roleClients, userClients } from "@server/db/schema"; import { userOrgs, clients, roleClients, userClients } from "@server/db/schemas";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";

View file

@ -12,7 +12,7 @@ import {
exitNodes, exitNodes,
orgs, orgs,
sites sites
} from "@server/db/schema"; } from "@server/db/schemas";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { clients, clientSites } from "@server/db/schema"; import { clients, clientSites } from "@server/db/schemas";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { clients } from "@server/db/schema"; import { clients } from "@server/db/schemas";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";

View file

@ -6,7 +6,7 @@ import {
sites, sites,
userClients, userClients,
clientSites clientSites
} from "@server/db/schema"; } from "@server/db/schemas";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
@ -53,6 +53,7 @@ function queryClients(orgId: string, accessibleClientIds: number[]) {
}) })
.from(clients) .from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.where( .where(
and( and(
inArray(clients.clientId, accessibleClientIds), inArray(clients.clientId, accessibleClientIds),

View file

@ -4,7 +4,7 @@ import { db } from "@server/db";
import { import {
clients, clients,
clientSites clientSites
} from "@server/db/schema"; } from "@server/db/schemas";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { clients, exitNodes, newts, olms, Site, sites, clientSites } from "@server/db/schema"; import { clients, exitNodes, newts, olms, Site, sites, clientSites } from "@server/db/schemas";
import { db } from "@server/db"; import { db } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { clients, newts, olms, Site, sites, clientSites } from "@server/db/schema"; import { clients, newts, olms, Site, sites, clientSites } from "@server/db/schemas";
import { db } from "@server/db"; import { db } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";

View file

@ -3,7 +3,7 @@ import { MessageHandler } from "../ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import db from "@server/db"; import db from "@server/db";
import { clients, clientSites, Newt, sites } from "@server/db/schema"; import { clients, clientSites, Newt, sites } from "@server/db/schemas";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { updatePeer } from "../olm/peers"; import { updatePeer } from "../olm/peers";

View file

@ -1,6 +1,6 @@
import db from "@server/db"; import db from "@server/db";
import { MessageHandler } from "../ws"; import { MessageHandler } from "../ws";
import { clients, Newt } from "@server/db/schema"; import { clients, Newt } from "@server/db/schemas";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";

View file

@ -1,28 +1,38 @@
import db from '@server/db'; import db from "@server/db";
import { newts, sites } from '@server/db/schema'; import { newts, sites } from "@server/db/schemas";
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import { sendToClient } from '../ws'; import { sendToClient } from "../ws";
import logger from '@server/logger'; import logger from "@server/logger";
export async function addPeer(siteId: number, peer: { export async function addPeer(
siteId: number,
peer: {
publicKey: string; publicKey: string;
allowedIps: string[]; allowedIps: string[];
endpoint: string; endpoint: string;
}) { }
) {
const [site] = await db.select().from(sites).where(eq(sites.siteId, siteId)).limit(1); const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) { if (!site) {
throw new Error(`Exit node with ID ${siteId} not found`); throw new Error(`Exit node with ID ${siteId} not found`);
} }
// get the newt on the site // get the newt on the site
const [newt] = await db.select().from(newts).where(eq(newts.siteId, siteId)).limit(1); const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (!newt) { if (!newt) {
throw new Error(`Site found for site ${siteId}`); throw new Error(`Site found for site ${siteId}`);
} }
sendToClient(newt.newtId, { sendToClient(newt.newtId, {
type: 'newt/wg/peer/add', type: "newt/wg/peer/add",
data: peer data: peer
}); });
@ -30,19 +40,27 @@ export async function addPeer(siteId: number, peer: {
} }
export async function deletePeer(siteId: number, publicKey: string) { export async function deletePeer(siteId: number, publicKey: string) {
const [site] = await db.select().from(sites).where(eq(sites.siteId, siteId)).limit(1); const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) { if (!site) {
throw new Error(`Site with ID ${siteId} not found`); throw new Error(`Site with ID ${siteId} not found`);
} }
// get the newt on the site // get the newt on the site
const [newt] = await db.select().from(newts).where(eq(newts.siteId, siteId)).limit(1); const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (!newt) { if (!newt) {
throw new Error(`Newt not found for site ${siteId}`); throw new Error(`Newt not found for site ${siteId}`);
} }
sendToClient(newt.newtId, { sendToClient(newt.newtId, {
type: 'newt/wg/peer/remove', type: "newt/wg/peer/remove",
data: { data: {
publicKey publicKey
} }
@ -51,23 +69,35 @@ export async function deletePeer(siteId: number, publicKey: string) {
logger.info(`Deleted peer ${publicKey} from newt ${newt.newtId}`); logger.info(`Deleted peer ${publicKey} from newt ${newt.newtId}`);
} }
export async function updatePeer(siteId: number, publicKey: string, peer: { export async function updatePeer(
siteId: number,
publicKey: string,
peer: {
allowedIps?: string[]; allowedIps?: string[];
endpoint?: string; endpoint?: string;
}) { }
const [site] = await db.select().from(sites).where(eq(sites.siteId, siteId)).limit(1); ) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) { if (!site) {
throw new Error(`Site with ID ${siteId} not found`); throw new Error(`Site with ID ${siteId} not found`);
} }
// get the newt on the site // get the newt on the site
const [newt] = await db.select().from(newts).where(eq(newts.siteId, siteId)).limit(1); const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (!newt) { if (!newt) {
throw new Error(`Newt not found for site ${siteId}`); throw new Error(`Newt not found for site ${siteId}`);
} }
sendToClient(newt.newtId, { sendToClient(newt.newtId, {
type: 'newt/wg/peer/update', type: "newt/wg/peer/update",
data: { data: {
publicKey, publicKey,
...peer ...peer

View file

@ -3,7 +3,7 @@ import db from "@server/db";
import { hash } from "@node-rs/argon2"; import { hash } from "@node-rs/argon2";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { z } from "zod"; import { z } from "zod";
import { newts } from "@server/db/schema"; import { newts } from "@server/db/schemas";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { SqliteError } from "better-sqlite3"; import { SqliteError } from "better-sqlite3";

View file

@ -1,6 +1,6 @@
import { generateSessionToken } from "@server/auth/sessions/app"; import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db"; import db from "@server/db";
import { olms } from "@server/db/schema"; import { olms } from "@server/db/schemas";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";

View file

@ -1,6 +1,6 @@
import db from "@server/db"; import db from "@server/db";
import { MessageHandler } from "../ws"; import { MessageHandler } from "../ws";
import { clients, Olm } from "@server/db/schema"; import { clients, Olm } from "@server/db/schemas";
import { eq, lt, isNull } from "drizzle-orm"; import { eq, lt, isNull } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";

View file

@ -7,7 +7,7 @@ import {
Olm, Olm,
olms, olms,
sites sites
} from "@server/db/schema"; } from "@server/db/schemas";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers"; import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger"; import logger from "@server/logger";

View file

@ -1,6 +1,6 @@
import db from "@server/db"; import db from "@server/db";
import { MessageHandler } from "../ws"; import { MessageHandler } from "../ws";
import { clients, clientSites, Olm } from "@server/db/schema"; import { clients, clientSites, Olm } from "@server/db/schemas";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { updatePeer } from "../newt/peers"; import { updatePeer } from "../newt/peers";
import logger from "@server/logger"; import logger from "@server/logger";

View file

@ -1,23 +1,30 @@
import db from '@server/db'; import db from "@server/db";
import { clients, olms, newts } from '@server/db/schema'; import { clients, olms, newts } from "@server/db/schemas";
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import { sendToClient } from '../ws'; import { sendToClient } from "../ws";
import logger from '@server/logger'; import logger from "@server/logger";
export async function addPeer(clientId: number, peer: { export async function addPeer(
siteId: number, clientId: number,
peer: {
siteId: number;
publicKey: string; publicKey: string;
endpoint: string; endpoint: string;
serverIP: string | null; serverIP: string | null;
serverPort: number | null; serverPort: number | null;
}) { }
const [olm] = await db.select().from(olms).where(eq(olms.clientId, clientId)).limit(1); ) {
const [olm] = await db
.select()
.from(olms)
.where(eq(olms.clientId, clientId))
.limit(1);
if (!olm) { if (!olm) {
throw new Error(`Olm with ID ${clientId} not found`); throw new Error(`Olm with ID ${clientId} not found`);
} }
sendToClient(olm.olmId, { sendToClient(olm.olmId, {
type: 'olm/wg/peer/add', type: "olm/wg/peer/add",
data: { data: {
siteId: peer.siteId, siteId: peer.siteId,
publicKey: peer.publicKey, publicKey: peer.publicKey,
@ -31,13 +38,17 @@ export async function addPeer(clientId: number, peer: {
} }
export async function deletePeer(clientId: number, publicKey: string) { export async function deletePeer(clientId: number, publicKey: string) {
const [olm] = await db.select().from(olms).where(eq(olms.clientId, clientId)).limit(1); const [olm] = await db
.select()
.from(olms)
.where(eq(olms.clientId, clientId))
.limit(1);
if (!olm) { if (!olm) {
throw new Error(`Olm with ID ${clientId} not found`); throw new Error(`Olm with ID ${clientId} not found`);
} }
sendToClient(olm.olmId, { sendToClient(olm.olmId, {
type: 'olm/wg/peer/remove', type: "olm/wg/peer/remove",
data: { data: {
publicKey publicKey
} }
@ -46,20 +57,27 @@ export async function deletePeer(clientId: number, publicKey: string) {
logger.info(`Deleted peer ${publicKey} from olm ${olm.olmId}`); logger.info(`Deleted peer ${publicKey} from olm ${olm.olmId}`);
} }
export async function updatePeer(clientId: number, peer: { export async function updatePeer(
siteId: number, clientId: number,
peer: {
siteId: number;
publicKey: string; publicKey: string;
endpoint: string; endpoint: string;
serverIP: string | null; serverIP: string | null;
serverPort: number | null; serverPort: number | null;
}) { }
const [olm] = await db.select().from(olms).where(eq(olms.clientId, clientId)).limit(1); ) {
const [olm] = await db
.select()
.from(olms)
.where(eq(olms.clientId, clientId))
.limit(1);
if (!olm) { if (!olm) {
throw new Error(`Olm with ID ${clientId} not found`); throw new Error(`Olm with ID ${clientId} not found`);
} }
sendToClient(olm.olmId, { sendToClient(olm.olmId, {
type: 'olm/wg/peer/update', type: "olm/wg/peer/update",
data: { data: {
siteId: peer.siteId, siteId: peer.siteId,
publicKey: peer.publicKey, publicKey: peer.publicKey,

View file

@ -2,30 +2,8 @@
import { import {
ColumnDef, ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search } from "lucide-react";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
@ -34,120 +12,19 @@ interface DataTableProps<TData, TValue> {
} }
export function ClientsDataTable<TData, TValue>({ export function ClientsDataTable<TData, TValue>({
addClient,
columns, columns,
data
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data, data,
columns, addClient
getCoreRowModel: getCoreRowModel(), }: DataTableProps<TData, TValue>) {
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
initialState: {
pagination: {
pageSize: 20,
pageIndex: 0
}
},
state: {
sorting,
columnFilters
}
});
return ( return (
<div> <DataTable
<div className="flex items-center justify-between pb-4"> columns={columns}
<div className="flex items-center max-w-sm mr-2 w-full relative"> data={data}
<Input title="Clients"
placeholder="Search clients" searchPlaceholder="Search clients..."
value={ searchColumn="name"
(table onAdd={addClient}
.getColumn("name") addButtonText="Add Client"
?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table
.getColumn("name")
?.setFilterValue(event.target.value)
}
className="w-full pl-8"
/> />
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
</div>
<Button
onClick={() => {
if (addClient) {
addClient();
}
}}
>
<Plus className="mr-2 h-4 w-4" /> Add Client
</Button>
</div>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() && "selected"
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No clients. Create one to get started.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>
</div>
); );
} }

View file

@ -47,6 +47,7 @@ import {
import { ScrollArea } from "@app/components/ui/scroll-area"; import { ScrollArea } from "@app/components/ui/scroll-area";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
const createClientFormSchema = z.object({ const createClientFormSchema = z.object({
name: z name: z
@ -57,8 +58,15 @@ const createClientFormSchema = z.object({
.max(30, { .max(30, {
message: "Name must not be longer than 30 characters." message: "Name must not be longer than 30 characters."
}), }),
siteIds: z.array(z.number()).min(1, { siteIds: z
message: "Select at least one site." .array(
z.object({
id: z.string(),
text: z.string()
})
)
.refine((val) => val.length > 0, {
message: "At least one site is required."
}), }),
subnet: z.string().min(1, { subnet: z.string().min(1, {
message: "Subnet is required." message: "Subnet is required."
@ -89,7 +97,7 @@ export default function CreateClientForm({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const { env } = useEnvContext(); const { env } = useEnvContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]); const [sites, setSites] = useState<Tag[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isChecked, setIsChecked] = useState(false); const [isChecked, setIsChecked] = useState(false);
const [clientDefaults, setClientDefaults] = const [clientDefaults, setClientDefaults] =
@ -98,6 +106,9 @@ export default function CreateClientForm({
const [selectedSites, setSelectedSites] = useState< const [selectedSites, setSelectedSites] = useState<
Array<{ id: number; name: string }> Array<{ id: number; name: string }>
>([]); >([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
number | null
>(null);
const handleCheckboxChange = (checked: boolean) => { const handleCheckboxChange = (checked: boolean) => {
setIsChecked(checked); setIsChecked(checked);
@ -111,14 +122,6 @@ export default function CreateClientForm({
defaultValues defaultValues
}); });
useEffect(() => {
// Update form value when selectedSites changes
form.setValue(
"siteIds",
selectedSites.map((site) => site.id)
);
}, [selectedSites, form]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -137,7 +140,12 @@ export default function CreateClientForm({
const sites = res.data.data.sites.filter( const sites = res.data.data.sites.filter(
(s) => s.type === "newt" && s.subnet (s) => s.type === "newt" && s.subnet
); );
setSites(sites); setSites(
sites.map((site) => ({
id: site.siteId.toString(),
text: site.name
}))
);
}; };
const fetchDefaults = async () => { const fetchDefaults = async () => {
@ -167,19 +175,6 @@ export default function CreateClientForm({
fetchDefaults(); fetchDefaults();
}, [open]); }, [open]);
const addSite = (siteId: number, siteName: string) => {
if (!selectedSites.some((site) => site.id === siteId)) {
setSelectedSites([
...selectedSites,
{ id: siteId, name: siteName }
]);
}
};
const removeSite = (siteId: number) => {
setSelectedSites(selectedSites.filter((site) => site.id !== siteId));
};
async function onSubmit(data: CreateClientFormValues) { async function onSubmit(data: CreateClientFormValues) {
setLoading?.(true); setLoading?.(true);
setIsLoading(true); setIsLoading(true);
@ -197,7 +192,7 @@ export default function CreateClientForm({
const payload = { const payload = {
name: data.name, name: data.name,
siteIds: data.siteIds, siteIds: data.siteIds.map((site) => parseInt(site.id)),
olmId: clientDefaults.olmId, olmId: clientDefaults.olmId,
secret: clientDefaults.olmSecret, secret: clientDefaults.olmSecret,
subnet: data.subnet, subnet: data.subnet,
@ -274,7 +269,8 @@ export default function CreateClientForm({
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
The address that this client will use for connectivity. The address that this client will use for
connectivity.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -284,97 +280,28 @@ export default function CreateClientForm({
<FormField <FormField
control={form.control} control={form.control}
name="siteIds" name="siteIds"
render={() => ( render={(field) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel>Sites</FormLabel> <FormLabel>Sites</FormLabel>
<Popover> <TagInput
<PopoverTrigger asChild> {...field}
<FormControl> activeTagIndex={activeSitesTagIndex}
<Button setActiveTagIndex={setActiveSitesTagIndex}
variant="outline" placeholder="Select sites"
role="combobox" size="sm"
className={cn( tags={form.getValues().siteIds}
"justify-between", setTags={(newTags) => {
selectedSites.length === form.setValue(
0 && "siteIds",
"text-muted-foreground" newTags as [Tag, ...Tag[]]
)}
>
{selectedSites.length > 0
? `${selectedSites.length} site${selectedSites.length !== 1 ? "s" : ""} selected`
: "Select sites"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0 w-[300px]">
<Command>
<CommandInput placeholder="Search sites..." />
<CommandList>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-[200px]">
{sites.map((site) => (
<CommandItem
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={
site.siteId
}
onSelect={() => {
addSite(
site.siteId,
site.name
); );
}} }}
> enableAutocomplete={true}
<CheckIcon autocompleteOptions={sites}
className={cn( allowDuplicates={false}
"mr-2 h-4 w-4", restrictTagsToAutocompleteOptions={true}
selectedSites.some( sortTags={true}
(
s
) =>
s.id ===
site.siteId
)
? "opacity-100"
: "opacity-0"
)}
/> />
{site.name}
</CommandItem>
))}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedSites.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{selectedSites.map((site) => (
<Badge
key={site.id}
variant="secondary"
>
{site.name}
<button
type="button"
onClick={() =>
removeSite(site.id)
}
className="ml-1 rounded-full outline-none focus:ring-2 focus:ring-offset-2"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<FormDescription> <FormDescription>
The client will have connectivity to the The client will have connectivity to the
selected sites. The sites must be configured selected sites. The sites must be configured

View file

@ -40,11 +40,8 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() { export default function GeneralPage() {
const { client, updateClient } = useClientContext(); const { client, updateClient } = useClientContext();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const form = useForm<GeneralFormValues>({ const form = useForm<GeneralFormValues>({
@ -58,19 +55,9 @@ export default function GeneralPage() {
async function onSubmit(data: GeneralFormValues) { async function onSubmit(data: GeneralFormValues) {
setLoading(true); setLoading(true);
await api try {
.post(`/client/${client?.clientId}`, { await api.post(`/client/${client?.clientId}`, {
name: data.name name: data.name
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update client",
description: formatAxiosError(
e,
"An error occurred while updating the client."
)
});
}); });
updateClient({ name: data.name }); updateClient({ name: data.name });
@ -80,9 +67,19 @@ export default function GeneralPage() {
description: "The client has been updated." description: "The client has been updated."
}); });
setLoading(false);
router.refresh(); router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: "Failed to update client",
description: formatAxiosError(
e,
"An error occurred while updating the client."
)
});
} finally {
setLoading(false);
}
} }
return ( return (

View file

@ -2,21 +2,14 @@ import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings"; import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { GetClientResponse } from "@server/routers/client"; import { GetClientResponse } from "@server/routers/client";
import ClientInfoCard from "./ClientInfoCard"; import ClientInfoCard from "./ClientInfoCard";
import ClientProvider from "@app/providers/ClientProvider"; import ClientProvider from "@app/providers/ClientProvider";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
interface SettingsLayoutProps { type SettingsLayoutProps = {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ clientId: number; orgId: string }>; params: Promise<{ clientId: number; orgId: string }>;
} }
@ -38,39 +31,27 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect(`/${params.orgId}/settings/clients`); redirect(`/${params.orgId}/settings/clients`);
} }
const sidebarNavItems = [ const navItems = [
{ {
title: "General", title: "General",
href: "/{orgId}/settings/clients/{clientId}/general" href: `/{orgId}/settings/clients/{clientId}/general`
} }
]; ];
return ( return (
<> <>
<div className="mb-4 flex-row">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="../">Clients</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{client.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<SettingsSectionTitle <SettingsSectionTitle
title={`${client?.name} Settings`} title={`${client?.name} Settings`}
description="Configure the settings on your site" description="Configure the settings on your site"
/> />
<ClientProvider client={client}> <ClientProvider client={client}>
<SidebarSettings sidebarNavItems={sidebarNavItems}> <div className="space-y-6">
<ClientInfoCard /> <ClientInfoCard />
<HorizontalTabs items={navItems}>
{children} {children}
</SidebarSettings> </HorizontalTabs>
</div>
</ClientProvider> </ClientProvider>
</> </>
); );

View file

@ -6,7 +6,8 @@ import {
Link as LinkIcon, Link as LinkIcon,
Waypoints, Waypoints,
Combine, Combine,
Fingerprint Fingerprint,
Workflow
} from "lucide-react"; } from "lucide-react";
export const rootNavItems: SidebarNavItem[] = [ export const rootNavItems: SidebarNavItem[] = [
@ -28,6 +29,11 @@ export const orgNavItems: SidebarNavItem[] = [
href: "/{orgId}/settings/resources", href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" /> icon: <Waypoints className="h-4 w-4" />
}, },
{
title: "Clients",
href: "/{orgId}/settings/clients",
icon: <Workflow className="h-4 w-4" />
},
{ {
title: "Access Control", title: "Access Control",
href: "/{orgId}/settings/access", href: "/{orgId}/settings/access",

View file

@ -29,7 +29,8 @@ export function HorizontalTabs({
.replace("{orgId}", params.orgId as string) .replace("{orgId}", params.orgId as string)
.replace("{resourceId}", params.resourceId as string) .replace("{resourceId}", params.resourceId as string)
.replace("{niceId}", params.niceId as string) .replace("{niceId}", params.niceId as string)
.replace("{userId}", params.userId as string); .replace("{userId}", params.userId as string)
.replace("{clientId}", params.clientId as string);
} }
return ( return (