Merge pull request #1960 from Fredkiss3/refactor/separate-tables

refactor: separate tables
This commit is contained in:
Milo Schwartz
2025-12-03 11:28:21 -08:00
committed by GitHub
31 changed files with 4383 additions and 3229 deletions

View File

@@ -0,0 +1,634 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Input } from "@app/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable
} from "@tanstack/react-table";
import {
ArrowUpDown,
ArrowUpRight,
Columns,
MoreHorizontal,
Plus,
RefreshCw,
Search
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { orgQueries, siteQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown";
};
export type ResourceRow = {
id: number;
nice: string | null;
name: string;
orgId: string;
domain: string;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
};
export type InternalResourceRow = {
id: number;
name: string;
orgId: string;
siteName: string;
siteAddress: string | null;
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr";
// protocol: string | null;
// proxyPort: number | null;
siteId: number;
siteNiceId: string;
destination: string;
// destinationPort: number | null;
alias: string | null;
};
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
defaultSort?: {
id: string;
desc: boolean;
};
};
export default function ClientResourcesTable({
internalResources,
orgId,
defaultSort
}: ClientResourcesTableProps) {
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [internalPageSize, setInternalPageSize] = useStoredPageSize(
"internal-resources",
20
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId, api }));
const [internalSorting, setInternalSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [internalColumnFilters, setInternalColumnFilters] =
useState<ColumnFiltersState>([]);
const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]);
const [isRefreshing, startTransition] = useTransition();
const [internalColumnVisibility, setInternalColumnVisibility] =
useStoredColumnVisibility("internal-resources", {});
const refreshData = async () => {
try {
router.refresh();
console.log("Data refreshed");
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
};
const deleteInternalResource = async (
resourceId: number,
siteId: number
) => {
try {
await api
.delete(`/org/${orgId}/site/${siteId}/resource/${resourceId}`)
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
} catch (e) {
console.error(t("resourceErrorDelete"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("v"))
});
}
};
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "siteName",
friendlyName: t("siteName"),
header: () => <span className="p-3">{t("siteName")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
>
<Button variant="outline">
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<span className="p-3">
{t("editInternalResourceDialogMode")}
</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<"host" | "cidr" | "port", string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
let displayText: string;
let copyText: string;
// if (
// resourceRow.mode === "port" &&
// // resourceRow.protocol &&
// // resourceRow.proxyPort &&
// // resourceRow.destinationPort
// ) {
// // const protocol = resourceRow.protocol.toUpperCase();
// // For port mode: site part uses alias or site address, destination part uses destination IP
// // If site address has CIDR notation, extract just the IP address
// let siteAddress = resourceRow.siteAddress;
// if (siteAddress && siteAddress.includes("/")) {
// siteAddress = siteAddress.split("/")[0];
// }
// const siteDisplay = resourceRow.alias || siteAddress;
// // displayText = `${protocol} ${siteDisplay}:${resourceRow.proxyPort} -> ${resourceRow.destination}:${resourceRow.destinationPort}`;
// // copyText = `${siteDisplay}:${resourceRow.proxyPort}`;
// } else if (resourceRow.mode === "host") {
if (resourceRow.mode === "host") {
// For host mode: use alias if available, otherwise use destination
const destinationDisplay =
resourceRow.alias || resourceRow.destination;
displayText = destinationDisplay;
copyText = destinationDisplay;
} else if (resourceRow.mode === "cidr") {
displayText = resourceRow.destination;
copyText = resourceRow.destination;
} else {
const destinationDisplay =
resourceRow.alias || resourceRow.destination;
displayText = destinationDisplay;
copyText = destinationDisplay;
}
return (
<CopyToClipboard
text={copyText}
isLink={false}
displayText={displayText}
/>
);
}
},
{
id: "actions",
enableHiding: false,
header: ({ table }) => {
const hasHideableColumns = table
.getAllColumns()
.some((column) => column.getCanHide());
if (!hasHideableColumns) {
return <span className="p-3"></span>;
}
return (
<div className="flex flex-col items-end gap-1 p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
>
<Columns className="h-4 w-4" />
<span className="sr-only">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
const columnDef =
column.columnDef as any;
const friendlyName =
columnDef.friendlyName;
const displayName =
friendlyName ||
(typeof columnDef.header ===
"string"
? columnDef.header
: column.id);
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(
!!value
)
}
onSelect={(e) =>
e.preventDefault()
}
>
{displayName}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
];
const internalTable = useReactTable({
data: internalResources,
columns: internalColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setInternalSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setInternalColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setInternalGlobalFilter,
onColumnVisibilityChange: setInternalColumnVisibility,
initialState: {
pagination: {
pageSize: internalPageSize,
pageIndex: 0
},
columnVisibility: internalColumnVisibility
},
state: {
sorting: internalSorting,
columnFilters: internalColumnFilters,
globalFilter: internalGlobalFilter,
columnVisibility: internalColumnVisibility
}
});
return (
<>
{selectedInternalResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedInternalResource(null);
}}
dialog={
<div>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () =>
deleteInternalResource(
selectedInternalResource!.id,
selectedInternalResource!.siteId
)
}
string={selectedInternalResource.name}
title={t("resourceDelete")}
/>
)}
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={internalGlobalFilter ?? ""}
onChange={(e) =>
internalTable.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
</div>
<div className="flex items-center gap-2 sm:justify-end">
<div>
<Button
variant="outline"
onClick={() => startTransition(refreshData)}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">
{t("refresh")}
</span>
</Button>
</div>
<div>
{" "}
<Button
onClick={() => setIsCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
{t("resourceAdd")}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto mt-9">
<Table>
<TableHeader>
{internalTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{internalTable.getRowModel().rows
?.length ? (
internalTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={`whitespace-nowrap ${
cell.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={internalColumns.length}
className="h-24 text-center"
>
{t(
"resourcesTableNoInternalResourcesFound"
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
<DataTablePagination
table={internalTable}
onPageSizeChange={setInternalPageSize}
/>
</div>
</CardContent>
</Card>
</div>
{editingResource && (
<EditInternalResourceDialog
open={isEditDialogOpen}
setOpen={setIsEditDialogOpen}
resource={editingResource}
orgId={orgId}
onSuccess={() => {
router.refresh();
setEditingResource(null);
}}
/>
)}
<CreateInternalResourceDialog
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
sites={sites}
onSuccess={() => {
router.refresh();
}}
/>
</>
);
}

View File

@@ -1,985 +0,0 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
VisibilityState
} from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
Check,
MoreHorizontal,
X,
RefreshCw,
Columns,
Search,
Plus
} from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useMemo } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "@app/components/ui/tabs";
import { Alert, AlertDescription } from "@app/components/ui/alert";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
userId: string | null;
username: string | null;
userEmail: string | null;
};
type ClientTableProps = {
userClients: ClientRow[];
machineClients: ClientRow[];
orgId: string;
defaultView?: "user" | "machine";
};
const STORAGE_KEYS = {
PAGE_SIZE: "datatable-page-size",
COLUMN_VISIBILITY: "datatable-column-visibility",
getTablePageSize: (tableId?: string) =>
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE,
getTableColumnVisibility: (tableId?: string) =>
tableId
? `datatable-${tableId}-column-visibility`
: STORAGE_KEYS.COLUMN_VISIBILITY
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === "undefined") return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = parseInt(stored, 10);
if (parsed > 0 && parsed <= 1000) {
return parsed;
}
}
} catch (error) {
console.warn("Failed to read page size from localStorage:", error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === "undefined") return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn("Failed to save page size to localStorage:", error);
}
};
const getStoredColumnVisibility = (
tableId?: string,
defaultVisibility?: Record<string, boolean>
): Record<string, boolean> => {
if (typeof window === "undefined") return defaultVisibility || {};
try {
const key = STORAGE_KEYS.getTableColumnVisibility(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
// Validate that it's an object
if (typeof parsed === "object" && parsed !== null) {
return parsed;
}
}
} catch (error) {
console.warn(
"Failed to read column visibility from localStorage:",
error
);
}
return defaultVisibility || {};
};
const setStoredColumnVisibility = (
visibility: Record<string, boolean>,
tableId?: string
): void => {
if (typeof window === "undefined") return;
try {
const key = STORAGE_KEYS.getTableColumnVisibility(tableId);
localStorage.setItem(key, JSON.stringify(visibility));
} catch (error) {
console.warn(
"Failed to save column visibility to localStorage:",
error
);
}
};
export default function ClientsTable({
userClients,
machineClients,
orgId,
defaultView = "user"
}: ClientTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations();
const [userPageSize, setUserPageSize] = useState<number>(() =>
getStoredPageSize("user-clients", 20)
);
const [machinePageSize, setMachinePageSize] = useState<number>(() =>
getStoredPageSize("machine-clients", 20)
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false);
const [userSorting, setUserSorting] = useState<SortingState>([]);
const [userColumnFilters, setUserColumnFilters] =
useState<ColumnFiltersState>([]);
const [userGlobalFilter, setUserGlobalFilter] = useState<any>([]);
const [machineSorting, setMachineSorting] = useState<SortingState>([]);
const [machineColumnFilters, setMachineColumnFilters] =
useState<ColumnFiltersState>([]);
const [machineGlobalFilter, setMachineGlobalFilter] = useState<any>([]);
const defaultUserColumnVisibility = {
client: false,
subnet: false
};
const defaultMachineColumnVisibility = {
client: false,
subnet: false,
userId: false
};
const [userColumnVisibility, setUserColumnVisibility] =
useState<VisibilityState>(() =>
getStoredColumnVisibility(
"user-clients",
defaultUserColumnVisibility
)
);
const [machineColumnVisibility, setMachineColumnVisibility] =
useState<VisibilityState>(() =>
getStoredColumnVisibility(
"machine-clients",
defaultMachineColumnVisibility
)
);
const currentView = searchParams.get("view") || defaultView;
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value === "machine") {
params.set("view", "machine");
} else {
params.delete("view");
}
const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
router.replace(newUrl, { scroll: false });
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
};
const getSearchInput = () => {
if (currentView === "machine") {
return (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={machineGlobalFilter ?? ""}
onChange={(e) =>
machineTable.setGlobalFilter(String(e.target.value))
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
);
}
return (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={userGlobalFilter ?? ""}
onChange={(e) =>
userTable.setGlobalFilter(String(e.target.value))
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
);
};
const getActionButton = () => {
// Only show create button on machine clients tab
if (currentView === "machine") {
return (
<Button
onClick={() =>
router.push(`/${orgId}/settings/clients/create`)
}
>
<Plus className="mr-2 h-4 w-4" />
{t("createClient")}
</Button>
);
}
return null;
};
// Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => {
const currentData = currentView === "machine" ? machineClients : userClients;
return currentData?.some((client) => !client.userId) ?? false;
}, [currentView, machineClients, userClients]);
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userId",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
User
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
friendlyName: "Connectivity",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Olm</span>
{originalRow.olmVersion && (
<span className="text-xs text-gray-500">
v{originalRow.olmVersion}
</span>
)}
</div>
</Badge>
{originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
];
// Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) {
baseColumns.push({
id: "actions",
enableHiding: false,
header: ({ table }) => {
const hasHideableColumns = table
.getAllColumns()
.some((column) => column.getCanHide());
if (!hasHideableColumns) {
return <span className="p-3"></span>;
}
return (
<div className="flex flex-col items-end gap-1 p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
<Columns className="h-4 w-4" />
<span className="sr-only">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
const columnDef = column.columnDef as any;
const friendlyName = columnDef.friendlyName;
const displayName = friendlyName ||
(typeof columnDef.header === "string"
? columnDef.header
: column.id);
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
onSelect={(e) => e.preventDefault()}
>
{displayName}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => {
const clientRow = row.original;
return !clientRow.userId ? (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"outline"}>
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
) : null;
}
});
}
return baseColumns;
}, [hasRowsWithoutUserId, t]);
const userTable = useReactTable({
data: userClients || [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setUserSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setUserColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setUserGlobalFilter,
onColumnVisibilityChange: setUserColumnVisibility,
initialState: {
pagination: {
pageSize: userPageSize,
pageIndex: 0
},
columnVisibility: userColumnVisibility
},
state: {
sorting: userSorting,
columnFilters: userColumnFilters,
globalFilter: userGlobalFilter,
columnVisibility: userColumnVisibility
}
});
const machineTable = useReactTable({
data: machineClients || [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setMachineSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setMachineColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setMachineGlobalFilter,
onColumnVisibilityChange: setMachineColumnVisibility,
initialState: {
pagination: {
pageSize: machinePageSize,
pageIndex: 0
},
columnVisibility: machineColumnVisibility
},
state: {
sorting: machineSorting,
columnFilters: machineColumnFilters,
globalFilter: machineGlobalFilter,
columnVisibility: machineColumnVisibility
}
});
const handleUserPageSizeChange = (newPageSize: number) => {
setUserPageSize(newPageSize);
setStoredPageSize(newPageSize, "user-clients");
};
const handleMachinePageSizeChange = (newPageSize: number) => {
setMachinePageSize(newPageSize);
setStoredPageSize(newPageSize, "machine-clients");
};
// Persist column visibility changes to localStorage
useEffect(() => {
setStoredColumnVisibility(userColumnVisibility, "user-clients");
}, [userColumnVisibility]);
useEffect(() => {
setStoredColumnVisibility(machineColumnVisibility, "machine-clients");
}, [machineColumnVisibility]);
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<div className="container mx-auto max-w-12xl">
<Card>
<Tabs
defaultValue={defaultView}
className="w-full"
onValueChange={handleTabChange}
>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
{getSearchInput()}
<TabsList className="grid grid-cols-2">
<TabsTrigger value="user">
{t("clientsTableUserClients")}
</TabsTrigger>
<TabsTrigger value="machine">
{t("clientsTableMachineClients")}
</TabsTrigger>
</TabsList>
</div>
<div className="flex items-center gap-2 sm:justify-end">
<div>
<Button
variant="outline"
onClick={refreshData}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">
{t("refresh")}
</span>
</Button>
</div>
<div>{getActionButton()}</div>
</div>
</CardHeader>
<CardContent>
<TabsContent value="user">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{userTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header.column.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{userTable.getRowModel().rows
?.length ? (
userTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
className={`whitespace-nowrap ${
cell.column.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell.column.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("noResults")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
<DataTablePagination
table={userTable}
onPageSizeChange={
handleUserPageSizeChange
}
/>
</div>
</TabsContent>
<TabsContent value="machine">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{machineTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header.column.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{machineTable.getRowModel().rows
?.length ? (
machineTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
className={`whitespace-nowrap ${
cell.column.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell.column.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("noResults")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
<DataTablePagination
table={machineTable}
onPageSizeChange={
handleMachinePageSizeChange
}
/>
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
</div>
</>
);
}

View File

@@ -1,15 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
@@ -18,15 +19,6 @@ import {
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
@@ -36,29 +28,36 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { createApiClient, formatAxiosError } from "@app/lib/api";
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListSitesResponse } from "@server/routers/site";
import { ListRolesResponse } from "@server/routers/role";
import { ListUsersResponse } from "@server/routers/user";
import { ListClientsResponse } from "@server/routers/client/listClients";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { Separator } from "@app/components/ui/separator";
import { AxiosResponse } from "axios";
import { orgQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { ListClientsResponse } from "@server/routers/client/listClients";
import { ListSitesResponse } from "@server/routers/site";
import { ListUsersResponse } from "@server/routers/user";
import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query";
import { AxiosResponse } from "axios";
import { Check, ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
type Site = ListSitesResponse["sites"][0];
@@ -89,7 +88,9 @@ export default function CreateInternalResourceDialog({
// mode: z.enum(["host", "cidr", "port"]),
mode: z.enum(["host", "cidr"]),
destination: z.string().min(1),
siteId: z.int().positive(t("createInternalResourceDialogPleaseSelectSite")),
siteId: z
.int()
.positive(t("createInternalResourceDialogPleaseSelectSite")),
// protocol: z.enum(["tcp", "udp"]),
// proxyPort: z.int()
// .positive()
@@ -101,25 +102,31 @@ export default function CreateInternalResourceDialog({
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
// .nullish(),
alias: z.string().nullish(),
roles: z.array(
z.object({
id: z.string(),
text: z.string()
})
).optional(),
users: z.array(
z.object({
id: z.string(),
text: z.string()
})
).optional(),
clients: z.array(
z.object({
id: z.string(),
text: z.string()
})
).optional()
})
roles: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional(),
users: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional(),
clients: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional()
});
// .refine(
// (data) => {
// if (data.mode === "port") {
@@ -159,13 +166,47 @@ export default function CreateInternalResourceDialog({
type FormData = z.infer<typeof formSchema>;
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]);
const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]);
const [allClients, setAllClients] = useState<{ id: string; text: string }[]>([]);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<number | null>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<number | null>(null);
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<number | null>(null);
const [hasMachineClients, setHasMachineClients] = useState(false);
const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId }));
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
const { data: clientsResponse = [] } = useQuery(
orgQueries.clients({
orgId,
filters: {
filter: "machine"
}
})
);
const allRoles = rolesResponse
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin");
const allUsers = usersResponse.map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}));
const allClients = clientsResponse
.filter((client) => !client.userId)
.map((client) => ({
id: client.clientId.toString(),
text: client.name
}));
const hasMachineClients = allClients.length > 0;
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<
number | null
>(null);
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
@@ -208,50 +249,6 @@ export default function CreateInternalResourceDialog({
}
}, [open]);
useEffect(() => {
const fetchRolesUsersAndClients = async () => {
try {
const [rolesResponse, usersResponse, clientsResponse] = await Promise.all([
api.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`),
api.get<AxiosResponse<ListUsersResponse>>(`/org/${orgId}/users`),
api.get<AxiosResponse<ListClientsResponse>>(`/org/${orgId}/clients?filter=machine&limit=1000`)
]);
setAllRoles(
rolesResponse.data.data.roles
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin")
);
setAllUsers(
usersResponse.data.data.users.map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}))
);
const machineClients = clientsResponse.data.data.clients
.filter((client) => !client.userId)
.map((client) => ({
id: client.clientId.toString(),
text: client.name
}));
setAllClients(machineClients);
setHasMachineClients(machineClients.length > 0);
} catch (error) {
console.error("Error fetching roles, users, and clients:", error);
}
};
if (open) {
fetchRolesUsersAndClients();
}
}, [open, orgId]);
const handleSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
@@ -265,10 +262,19 @@ export default function CreateInternalResourceDialog({
// destinationPort: data.mode === "port" ? data.destinationPort : undefined,
destination: data.destination,
enabled: true,
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
roleIds: data.roles
? data.roles.map((r) => parseInt(r.id))
: [],
userIds: data.users ? data.users.map((u) => u.id) : [],
clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : []
clientIds: data.clients
? data.clients.map((c) => parseInt(c.id))
: []
}
);
@@ -295,7 +301,9 @@ export default function CreateInternalResourceDialog({
toast({
title: t("createInternalResourceDialogSuccess"),
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
@@ -307,7 +315,9 @@ export default function CreateInternalResourceDialog({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t("createInternalResourceDialogFailedToCreateInternalResource")
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
@@ -321,13 +331,19 @@ export default function CreateInternalResourceDialog({
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("createInternalResourceDialogNoSitesAvailable")}</CredenzaTitle>
<CredenzaTitle>
{t("createInternalResourceDialogNoSitesAvailable")}
</CredenzaTitle>
<CredenzaDescription>
{t("createInternalResourceDialogNoSitesAvailableDescription")}
{t(
"createInternalResourceDialogNoSitesAvailableDescription"
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaFooter>
<Button onClick={() => setOpen(false)}>{t("createInternalResourceDialogClose")}</Button>
<Button onClick={() => setOpen(false)}>
{t("createInternalResourceDialogClose")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
@@ -338,9 +354,13 @@ export default function CreateInternalResourceDialog({
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
<CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t("createInternalResourceDialogCreateClientResourceDescription")}
{t(
"createInternalResourceDialogCreateClientResourceDescription"
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -353,7 +373,9 @@ export default function CreateInternalResourceDialog({
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("createInternalResourceDialogResourceProperties")}
{t(
"createInternalResourceDialogResourceProperties"
)}
</h3>
<div className="space-y-4">
<FormField
@@ -361,7 +383,11 @@ export default function CreateInternalResourceDialog({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogName")}</FormLabel>
<FormLabel>
{t(
"createInternalResourceDialogName"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -375,7 +401,11 @@ export default function CreateInternalResourceDialog({
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t("createInternalResourceDialogSite")}</FormLabel>
<FormLabel>
{t(
"createInternalResourceDialogSite"
)}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -384,43 +414,71 @@ export default function CreateInternalResourceDialog({
role="combobox"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground"
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? availableSites.find(
(site) => site.siteId === field.value
(
site
) =>
site.siteId ===
field.value
)?.name
: t("createInternalResourceDialogSelectSite")}
: t(
"createInternalResourceDialogSelectSite"
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={t("createInternalResourceDialogSearchSites")} />
<CommandInput
placeholder={t(
"createInternalResourceDialogSearchSites"
)}
/>
<CommandList>
<CommandEmpty>{t("createInternalResourceDialogNoSitesFound")}</CommandEmpty>
<CommandEmpty>
{t(
"createInternalResourceDialogNoSitesFound"
)}
</CommandEmpty>
<CommandGroup>
{availableSites.map((site) => (
<CommandItem
key={site.siteId}
value={site.name}
onSelect={() => {
field.onChange(site.siteId);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === site.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
{availableSites.map(
(
site
) => (
<CommandItem
key={
site.siteId
}
value={
site.name
}
onSelect={() => {
field.onChange(
site.siteId
);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value ===
site.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
@@ -431,14 +489,20 @@ export default function CreateInternalResourceDialog({
)}
/>
<FormField
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogMode")}</FormLabel>
<FormLabel>
{t(
"createInternalResourceDialogMode"
)}
</FormLabel>
<Select
onValueChange={field.onChange}
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
@@ -448,15 +512,23 @@ export default function CreateInternalResourceDialog({
</FormControl>
<SelectContent>
{/* <SelectItem value="port">{t("createInternalResourceDialogModePort")}</SelectItem> */}
<SelectItem value="host">{t("createInternalResourceDialogModeHost")}</SelectItem>
<SelectItem value="cidr">{t("createInternalResourceDialogModeCidr")}</SelectItem>
<SelectItem value="host">
{t(
"createInternalResourceDialogModeHost"
)}
</SelectItem>
<SelectItem value="cidr">
{t(
"createInternalResourceDialogModeCidr"
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/*
{/*
{mode === "port" && (
<>
<div className="grid grid-cols-2 gap-4">
@@ -521,7 +593,9 @@ export default function CreateInternalResourceDialog({
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("createInternalResourceDialogTargetConfiguration")}
{t(
"createInternalResourceDialogTargetConfiguration"
)}
</h3>
<div className="space-y-4">
<FormField
@@ -530,14 +604,22 @@ export default function CreateInternalResourceDialog({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogDestination")}
{t(
"createInternalResourceDialogDestination"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" && t("createInternalResourceDialogDestinationHostDescription")}
{mode === "cidr" && t("createInternalResourceDialogDestinationCidrDescription")}
{mode === "host" &&
t(
"createInternalResourceDialogDestinationHostDescription"
)}
{mode === "cidr" &&
t(
"createInternalResourceDialogDestinationCidrDescription"
)}
{/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */}
</FormDescription>
<FormMessage />
@@ -584,12 +666,23 @@ export default function CreateInternalResourceDialog({
name="alias"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogAlias")}</FormLabel>
<FormLabel>
{t(
"createInternalResourceDialogAlias"
)}
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
<Input
{...field}
value={
field.value ?? ""
}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogAliasDescription")}
{t(
"createInternalResourceDialogAliasDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -609,31 +702,53 @@ export default function CreateInternalResourceDialog({
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel>
<FormLabel>
{t("roles")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeRolesTagIndex}
setActiveTagIndex={setActiveRolesTagIndex}
placeholder={t("accessRoleSelect2")}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={form.getValues().roles || []}
tags={
form.getValues()
.roles || []
}
setTags={(newRoles) => {
form.setValue(
"roles",
newRoles as [Tag, ...Tag[]]
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={true}
autocompleteOptions={allRoles}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("resourceRoleDescription")}
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem>
)}
@@ -643,25 +758,45 @@ export default function CreateInternalResourceDialog({
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("users")}</FormLabel>
<FormLabel>
{t("users")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeUsersTagIndex}
setActiveTagIndex={setActiveUsersTagIndex}
placeholder={t("accessUserSelect")}
tags={form.getValues().users || []}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
form.getValues()
.users || []
}
size="sm"
setTags={(newUsers) => {
form.setValue(
"users",
newUsers as [Tag, ...Tag[]]
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={true}
autocompleteOptions={allUsers}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
@@ -675,31 +810,62 @@ export default function CreateInternalResourceDialog({
name="clients"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("clients")}</FormLabel>
<FormLabel>
{t("clients")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeClientsTagIndex}
setActiveTagIndex={setActiveClientsTagIndex}
placeholder={t("accessClientSelect") || "Select machine clients"}
activeTagIndex={
activeClientsTagIndex
}
setActiveTagIndex={
setActiveClientsTagIndex
}
placeholder={
t(
"accessClientSelect"
) ||
"Select machine clients"
}
size="sm"
tags={form.getValues().clients || []}
setTags={(newClients) => {
tags={
form.getValues()
.clients ||
[]
}
setTags={(
newClients
) => {
form.setValue(
"clients",
newClients as [Tag, ...Tag[]]
newClients as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={true}
autocompleteOptions={allClients}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
enableAutocomplete={
true
}
autocompleteOptions={
allClients
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("resourceClientDescription") || "Machine clients that can access this resource"}
{t(
"resourceClientDescription"
) ||
"Machine clients that can access this resource"}
</FormDescription>
</FormItem>
)}

View File

@@ -45,6 +45,8 @@ import { ListClientsResponse } from "@server/routers/client/listClients";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { AxiosResponse } from "axios";
import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query";
import { orgQueries, resourceQueries } from "@app/lib/queries";
type InternalResourceData = {
id: number;
@@ -81,32 +83,41 @@ export default function EditInternalResourceDialog({
const [isSubmitting, setIsSubmitting] = useState(false);
const formSchema = z.object({
name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")),
name: z
.string()
.min(1, t("editInternalResourceDialogNameRequired"))
.max(255, t("editInternalResourceDialogNameMaxLength")),
mode: z.enum(["host", "cidr", "port"]),
// protocol: z.enum(["tcp", "udp"]).nullish(),
// proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
destination: z.string().min(1),
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
alias: z.string().nullish(),
roles: z.array(
z.object({
id: z.string(),
text: z.string()
})
).optional(),
users: z.array(
z.object({
id: z.string(),
text: z.string()
})
).optional(),
clients: z.array(
z.object({
id: z.string(),
text: z.string()
})
).optional()
})
roles: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional(),
users: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional(),
clients: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional()
});
// .refine(
// (data) => {
// if (data.mode === "port") {
@@ -146,14 +157,53 @@ export default function EditInternalResourceDialog({
type FormData = z.infer<typeof formSchema>;
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]);
const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]);
const [allClients, setAllClients] = useState<{ id: string; text: string }[]>([]);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<number | null>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<number | null>(null);
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<number | null>(null);
const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId }));
const allRoles = rolesResponse
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin");
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
const { data: existingClients = [] } = useQuery(
resourceQueries.resourceUsers({ resourceId: resource.id })
);
const { data: clientsResponse = [] } = useQuery(
orgQueries.clients({
orgId,
filters: {
filter: "machine"
}
})
);
const allUsers = usersResponse.map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}));
const allClients = clientsResponse
.filter((client) => !client.userId)
.map((client) => ({
id: client.clientId.toString(),
text: client.name
}));
const hasMachineClients =
allClients.length > 0 || existingClients.length > 0;
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<
number | null
>(null);
const [loadingRolesUsers, setLoadingRolesUsers] = useState(false);
const [hasMachineClients, setHasMachineClients] = useState(false);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
@@ -173,140 +223,141 @@ export default function EditInternalResourceDialog({
const mode = form.watch("mode");
const fetchRolesAndUsers = async () => {
setLoadingRolesUsers(true);
try {
const [
rolesResponse,
resourceRolesResponse,
usersResponse,
resourceUsersResponse,
clientsResponse
] = await Promise.all([
api.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`),
api.get<AxiosResponse<ListSiteResourceRolesResponse>>(
`/site-resource/${resource.id}/roles`
),
api.get<AxiosResponse<ListUsersResponse>>(`/org/${orgId}/users`),
api.get<AxiosResponse<ListSiteResourceUsersResponse>>(
`/site-resource/${resource.id}/users`
),
api.get<AxiosResponse<ListClientsResponse>>(`/org/${orgId}/clients?filter=machine&limit=1000`)
]);
// const fetchRolesAndUsers = async () => {
// setLoadingRolesUsers(true);
// try {
// const [
// // rolesResponse,
// resourceRolesResponse,
// usersResponse,
// resourceUsersResponse,
// clientsResponse
// ] = await Promise.all([
// // api.get<AxiosResponse<ListRolesResponse>>(
// // `/org/${orgId}/roles`
// // ),
// api.get<AxiosResponse<ListSiteResourceRolesResponse>>(
// `/site-resource/${resource.id}/roles`
// ),
// api.get<AxiosResponse<ListUsersResponse>>(
// `/org/${orgId}/users`
// ),
// api.get<AxiosResponse<ListSiteResourceUsersResponse>>(
// `/site-resource/${resource.id}/users`
// ),
// api.get<AxiosResponse<ListClientsResponse>>(
// `/org/${orgId}/clients?filter=machine&limit=1000`
// )
// ]);
let resourceClientsResponse: AxiosResponse<AxiosResponse<ListSiteResourceClientsResponse>>;
try {
resourceClientsResponse = await api.get<AxiosResponse<ListSiteResourceClientsResponse>>(
`/site-resource/${resource.id}/clients`
);
} catch {
resourceClientsResponse = {
data: {
data: {
clients: []
}
},
status: 200,
statusText: "OK",
headers: {} as any,
config: {} as any
} as any;
}
// let resourceClientsResponse: AxiosResponse<
// AxiosResponse<ListSiteResourceClientsResponse>
// >;
// try {
// resourceClientsResponse = await api.get<
// AxiosResponse<ListSiteResourceClientsResponse>
// >(`/site-resource/${resource.id}/clients`);
// } catch {
// resourceClientsResponse = {
// data: {
// data: {
// clients: []
// }
// },
// status: 200,
// statusText: "OK",
// headers: {} as any,
// config: {} as any
// } as any;
// }
setAllRoles(
rolesResponse.data.data.roles
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin")
);
// // setAllRoles(
// // rolesResponse.data.data.roles
// // .map((role) => ({
// // id: role.roleId.toString(),
// // text: role.name
// // }))
// // .filter((role) => role.text !== "Admin")
// // );
form.setValue(
"roles",
resourceRolesResponse.data.data.roles
.map((i) => ({
id: i.roleId.toString(),
text: i.name
}))
.filter((role) => role.text !== "Admin")
);
// form.setValue(
// "roles",
// resourceRolesResponse.data.data.roles
// .map((i) => ({
// id: i.roleId.toString(),
// text: i.name
// }))
// .filter((role) => role.text !== "Admin")
// );
setAllUsers(
usersResponse.data.data.users.map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}))
);
// form.setValue(
// "users",
// resourceUsersResponse.data.data.users.map((i) => ({
// id: i.userId.toString(),
// text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
// }))
// );
form.setValue(
"users",
resourceUsersResponse.data.data.users.map((i) => ({
id: i.userId.toString(),
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}))
);
// const machineClients = clientsResponse.data.data.clients
// .filter((client) => !client.userId)
// .map((client) => ({
// id: client.clientId.toString(),
// text: client.name
// }));
const machineClients = clientsResponse.data.data.clients
.filter((client) => !client.userId)
.map((client) => ({
id: client.clientId.toString(),
text: client.name
}));
// setAllClients(machineClients);
setAllClients(machineClients);
const existingClients = resourceClientsResponse.data.data.clients.map((c: { clientId: number; name: string }) => ({
id: c.clientId.toString(),
text: c.name
}));
// const existingClients =
// resourceClientsResponse.data.data.clients.map(
// (c: { clientId: number; name: string }) => ({
// id: c.clientId.toString(),
// text: c.name
// })
// );
form.setValue("clients", existingClients);
// form.setValue("clients", existingClients);
// Show clients tag input if there are machine clients OR existing client access
setHasMachineClients(machineClients.length > 0 || existingClients.length > 0);
} catch (error) {
console.error("Error fetching roles, users, and clients:", error);
} finally {
setLoadingRolesUsers(false);
}
};
// // Show clients tag input if there are machine clients OR existing client access
// setHasMachineClients(
// machineClients.length > 0 || existingClients.length > 0
// );
// } catch (error) {
// console.error("Error fetching roles, users, and clients:", error);
// } finally {
// setLoadingRolesUsers(false);
// }
// };
useEffect(() => {
if (open) {
form.reset({
name: resource.name,
mode: resource.mode || "host",
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
// proxyPort: resource.proxyPort ?? undefined,
destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
roles: [],
users: [],
clients: []
});
fetchRolesAndUsers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, resource]);
// useEffect(() => {
// if (open) {
// fetchRolesAndUsers();
// }
// }, [open, resource]);
const handleSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
// Update the site resource
await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, {
name: data.name,
mode: data.mode,
// protocol: data.mode === "port" ? data.protocol : null,
// proxyPort: data.mode === "port" ? data.proxyPort : null,
// destinationPort: data.mode === "port" ? data.destinationPort : null,
destination: data.destination,
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : null,
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id))
});
await api.post(
`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`,
{
name: data.name,
mode: data.mode,
// protocol: data.mode === "port" ? data.protocol : null,
// proxyPort: data.mode === "port" ? data.proxyPort : null,
// destinationPort: data.mode === "port" ? data.destinationPort : null,
destination: data.destination,
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id))
}
);
// Update roles, users, and clients
// await Promise.all([
@@ -323,7 +374,9 @@ export default function EditInternalResourceDialog({
toast({
title: t("editInternalResourceDialogSuccess"),
description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"),
description: t(
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
),
variant: "default"
});
@@ -333,7 +386,12 @@ export default function EditInternalResourceDialog({
console.error("Error updating internal resource:", error);
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")),
description: formatAxiosError(
error,
t(
"editInternalResourceDialogFailedToUpdateInternalResource"
)
),
variant: "destructive"
});
} finally {
@@ -342,27 +400,64 @@ export default function EditInternalResourceDialog({
};
return (
<Credenza open={open} onOpenChange={setOpen}>
<Credenza
open={open}
onOpenChange={(open) => {
if (!open) {
// reset only on close
form.reset({
name: resource.name,
mode: resource.mode || "host",
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
// proxyPort: resource.proxyPort ?? undefined,
destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
roles: [],
users: [],
clients: []
});
}
setOpen(open);
}}
>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("editInternalResourceDialogEditClientResource")}</CredenzaTitle>
<CredenzaTitle>
{t("editInternalResourceDialogEditClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })}
{t(
"editInternalResourceDialogUpdateResourceProperties",
{ resourceName: resource.name }
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6" id="edit-internal-resource-form">
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
id="edit-internal-resource-form"
>
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogResourceProperties")}</h3>
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogResourceProperties"
)}
</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogName")}</FormLabel>
<FormLabel>
{t(
"editInternalResourceDialogName"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -376,9 +471,15 @@ export default function EditInternalResourceDialog({
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogMode")}</FormLabel>
<FormLabel>
{t(
"editInternalResourceDialogMode"
)}
</FormLabel>
<Select
onValueChange={field.onChange}
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
@@ -388,8 +489,16 @@ export default function EditInternalResourceDialog({
</FormControl>
<SelectContent>
{/* <SelectItem value="port">{t("editInternalResourceDialogModePort")}</SelectItem> */}
<SelectItem value="host">{t("editInternalResourceDialogModeHost")}</SelectItem>
<SelectItem value="cidr">{t("editInternalResourceDialogModeCidr")}</SelectItem>
<SelectItem value="host">
{t(
"editInternalResourceDialogModeHost"
)}
</SelectItem>
<SelectItem value="cidr">
{t(
"editInternalResourceDialogModeCidr"
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -448,20 +557,34 @@ export default function EditInternalResourceDialog({
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogTargetConfiguration")}</h3>
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogTargetConfiguration"
)}
</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogDestination")}</FormLabel>
<FormLabel>
{t(
"editInternalResourceDialogDestination"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" && t("editInternalResourceDialogDestinationHostDescription")}
{mode === "cidr" && t("editInternalResourceDialogDestinationCidrDescription")}
{mode === "host" &&
t(
"editInternalResourceDialogDestinationHostDescription"
)}
{mode === "cidr" &&
t(
"editInternalResourceDialogDestinationCidrDescription"
)}
{/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */}
</FormDescription>
<FormMessage />
@@ -499,12 +622,23 @@ export default function EditInternalResourceDialog({
name="alias"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogAlias")}</FormLabel>
<FormLabel>
{t(
"editInternalResourceDialogAlias"
)}
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
<Input
{...field}
value={
field.value ?? ""
}
/>
</FormControl>
<FormDescription>
{t("editInternalResourceDialogAliasDescription")}
{t(
"editInternalResourceDialogAliasDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -529,31 +663,57 @@ export default function EditInternalResourceDialog({
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel>
<FormLabel>
{t("roles")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeRolesTagIndex}
setActiveTagIndex={setActiveRolesTagIndex}
placeholder={t("accessRoleSelect2")}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={form.getValues().roles || []}
setTags={(newRoles) => {
tags={
form.getValues()
.roles || []
}
setTags={(
newRoles
) => {
form.setValue(
"roles",
newRoles as [Tag, ...Tag[]]
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={true}
autocompleteOptions={allRoles}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("resourceRoleDescription")}
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem>
)}
@@ -563,25 +723,49 @@ export default function EditInternalResourceDialog({
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("users")}</FormLabel>
<FormLabel>
{t("users")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeUsersTagIndex}
setActiveTagIndex={setActiveUsersTagIndex}
placeholder={t("accessUserSelect")}
tags={form.getValues().users || []}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
form.getValues()
.users || []
}
size="sm"
setTags={(newUsers) => {
setTags={(
newUsers
) => {
form.setValue(
"users",
newUsers as [Tag, ...Tag[]]
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={true}
autocompleteOptions={allUsers}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
@@ -589,42 +773,73 @@ export default function EditInternalResourceDialog({
</FormItem>
)}
/>
{hasMachineClients && (
<FormField
control={form.control}
name="clients"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("clients")}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeClientsTagIndex}
setActiveTagIndex={setActiveClientsTagIndex}
placeholder={t("accessClientSelect") || "Select machine clients"}
size="sm"
tags={form.getValues().clients || []}
setTags={(newClients) => {
form.setValue(
"clients",
newClients as [Tag, ...Tag[]]
);
}}
enableAutocomplete={true}
autocompleteOptions={allClients}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("resourceClientDescription") || "Machine clients that can access this resource"}
</FormDescription>
</FormItem>
)}
/>
)}
{hasMachineClients && (
<FormField
control={form.control}
name="clients"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("clients")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeClientsTagIndex
}
setActiveTagIndex={
setActiveClientsTagIndex
}
placeholder={
t(
"accessClientSelect"
) ||
"Select machine clients"
}
size="sm"
tags={
form.getValues()
.clients ||
[]
}
setTags={(
newClients
) => {
form.setValue(
"clients",
newClients as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allClients
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceClientDescription"
) ||
"Machine clients that can access this resource"}
</FormDescription>
</FormItem>
)}
/>
)}
</div>
)}
</div>
@@ -645,7 +860,7 @@ export default function EditInternalResourceDialog({
disabled={isSubmitting}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}
{t("editInternalResourceDialogSaveResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -0,0 +1,700 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Input } from "@app/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable
} from "@tanstack/react-table";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
Columns,
MoreHorizontal,
Plus,
RefreshCw,
Search
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
userId: string | null;
username: string | null;
userEmail: string | null;
};
type ClientTableProps = {
machineClients: ClientRow[];
orgId: string;
};
export default function MachineClientsTable({
machineClients,
orgId
}: ClientTableProps) {
const router = useRouter();
const t = useTranslations();
const [machinePageSize, setMachinePageSize] = useStoredPageSize(
"machine-clients",
20
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const [machineSorting, setMachineSorting] = useState<SortingState>([]);
const [machineColumnFilters, setMachineColumnFilters] =
useState<ColumnFiltersState>([]);
const [machineGlobalFilter, setMachineGlobalFilter] = useState<any>([]);
const defaultMachineColumnVisibility = {
client: false,
subnet: false,
userId: false
};
const [machineColumnVisibility, setMachineColumnVisibility] =
useStoredColumnVisibility(
"machine-clients",
defaultMachineColumnVisibility
);
const refreshData = async () => {
try {
router.refresh();
console.log("Data refreshed");
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
// Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => {
return machineClients.some((client) => !client.userId) ?? false;
}, [machineClients]);
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userId",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
User
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
friendlyName: "Connectivity",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Olm</span>
{originalRow.olmVersion && (
<span className="text-xs text-gray-500">
v{originalRow.olmVersion}
</span>
)}
</div>
</Badge>
{originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}
];
// Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) {
baseColumns.push({
id: "actions",
enableHiding: false,
header: ({ table }) => {
const hasHideableColumns = table
.getAllColumns()
.some((column) => column.getCanHide());
if (!hasHideableColumns) {
return <span className="p-3"></span>;
}
return (
<div className="flex flex-col items-end gap-1 p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
>
<Columns className="h-4 w-4" />
<span className="sr-only">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
const columnDef =
column.columnDef as any;
const friendlyName =
columnDef.friendlyName;
const displayName =
friendlyName ||
(typeof columnDef.header ===
"string"
? columnDef.header
: column.id);
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(
!!value
)
}
onSelect={(e) =>
e.preventDefault()
}
>
{displayName}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => {
const clientRow = row.original;
return !clientRow.userId ? (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
Delete
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"outline"}>
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
) : null;
}
});
}
return baseColumns;
}, [hasRowsWithoutUserId, t]);
const machineTable = useReactTable({
data: machineClients || [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setMachineSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setMachineColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setMachineGlobalFilter,
onColumnVisibilityChange: setMachineColumnVisibility,
initialState: {
pagination: {
pageSize: machinePageSize,
pageIndex: 0
},
columnVisibility: machineColumnVisibility
},
state: {
sorting: machineSorting,
columnFilters: machineColumnFilters,
globalFilter: machineGlobalFilter,
columnVisibility: machineColumnVisibility
}
});
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={machineGlobalFilter ?? ""}
onChange={(e) =>
machineTable.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
</div>
<div className="flex items-center gap-2 sm:justify-end">
<div>
<Button
variant="outline"
onClick={() => startTransition(refreshData)}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">
{t("refresh")}
</span>
</Button>
</div>
<div>
{" "}
<Button
onClick={() =>
router.push(
`/${orgId}/settings/clients/create`
)
}
>
<Plus className="mr-2 h-4 w-4" />
{t("createClient")}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto mt-9">
<Table>
<TableHeader>
{machineTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{machineTable.getRowModel().rows?.length ? (
machineTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={`whitespace-nowrap ${
cell.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("noResults")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
<DataTablePagination
table={machineTable}
onPageSizeChange={setMachinePageSize}
/>
</div>
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -0,0 +1,830 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Input } from "@app/components/ui/input";
import { Switch } from "@app/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource";
import {
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable
} from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ArrowRight,
ArrowUpDown,
CheckCircle2,
ChevronDown,
Clock,
Columns,
MoreHorizontal,
Plus,
RefreshCw,
Search,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown";
};
export type ResourceRow = {
id: number;
nice: string | null;
name: string;
orgId: string;
domain: string;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
};
function getOverallHealthStatus(
targets?: TargetHealth[]
): "online" | "degraded" | "offline" | "unknown" {
if (!targets || targets.length === 0) {
return "unknown";
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
if (monitoredTargets.length === 0) {
return "unknown";
}
const healthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "healthy"
).length;
const unhealthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "unhealthy"
).length;
if (healthyCount === monitoredTargets.length) {
return "online";
} else if (unhealthyCount === monitoredTargets.length) {
return "offline";
} else {
return "degraded";
}
}
function StatusIcon({
status,
className = ""
}: {
status: "online" | "degraded" | "offline" | "unknown";
className?: string;
}) {
const iconClass = `h-4 w-4 ${className}`;
switch (status) {
case "online":
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case "offline":
return <XCircle className={`${iconClass} text-destructive`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
default:
return null;
}
}
type ProxyResourcesTableProps = {
resources: ResourceRow[];
orgId: string;
defaultSort?: {
id: string;
desc: boolean;
};
};
export default function ProxyResourcesTable({
resources,
orgId,
defaultSort
}: ProxyResourcesTableProps) {
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [proxyPageSize, setProxyPageSize] = useStoredPageSize(
"proxy-resources",
20
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const [proxySorting, setProxySorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [proxyColumnFilters, setProxyColumnFilters] =
useState<ColumnFiltersState>([]);
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
const [isRefreshing, startTransition] = useTransition();
const [proxyColumnVisibility, setProxyColumnVisibility] =
useStoredColumnVisibility("proxy-resources", {});
const refreshData = () => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
};
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
async function toggleResourceEnabled(val: boolean, resourceId: number) {
await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resourceId}`,
{
enabled: val
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
});
}
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
const overallStatus = getOverallHealthStatus(targets);
if (!targets || targets.length === 0) {
return (
<div className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm">
{t("resourcesTableNoTargets")}
</span>
</div>
);
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
const unknownTargets = targets.filter(
(t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown"
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8 px-0 font-normal"
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === "online" &&
t("resourcesTableHealthy")}
{overallStatus === "degraded" &&
t("resourcesTableDegraded")}
{overallStatus === "offline" &&
t("resourcesTableOffline")}
{overallStatus === "unknown" &&
t("resourcesTableUnknown")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[280px]">
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status={
target.healthStatus ===
"healthy"
? "online"
: "offline"
}
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span
className={`capitalize ${
target.healthStatus === "healthy"
? "text-green-500"
: "text-destructive"
}`}
>
{target.healthStatus}
</span>
</DropdownMenuItem>
))}
</>
)}
{unknownTargets.length > 0 && (
<>
{unknownTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status="unknown"
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled
? t("disabled")
: t("resourcesTableNotMonitored")}
</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
const proxyColumns: ExtendedColumnDef<ResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "nice",
friendlyName: t("resource"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("resource")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "protocol",
friendlyName: t("protocol"),
header: () => <span className="p-3">{t("protocol")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<span>
{resourceRow.http
? resourceRow.ssl
? "HTTPS"
: "HTTP"
: resourceRow.protocol.toUpperCase()}
</span>
);
}
},
{
id: "status",
accessorKey: "status",
friendlyName: t("status"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} />;
},
sortingFn: (rowA, rowB) => {
const statusA = getOverallHealthStatus(rowA.original.targets);
const statusB = getOverallHealthStatus(rowB.original.targets);
const statusOrder = {
online: 3,
degraded: 2,
offline: 1,
unknown: 0
};
return statusOrder[statusA] - statusOrder[statusB];
}
},
{
accessorKey: "domain",
friendlyName: t("access"),
header: () => <span className="p-3">{t("access")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center space-x-2">
{!resourceRow.http ? (
<CopyToClipboard
text={resourceRow.proxyPort?.toString() || ""}
isLink={false}
/>
) : !resourceRow.domainId ? (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
)}
</div>
);
}
},
{
accessorKey: "authState",
friendlyName: t("authentication"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("authentication")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
{resourceRow.authState === "protected" ? (
<span className="flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 text-green-500" />
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 text-yellow-500" />
<span>{t("notProtected")}</span>
</span>
) : (
<span>-</span>
)}
</div>
);
}
},
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<Switch
defaultChecked={
row.original.http
? !!row.original.domainId && row.original.enabled
: row.original.enabled
}
disabled={
row.original.http ? !row.original.domainId : false
}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
/>
)
},
{
id: "actions",
enableHiding: false,
header: ({ table }) => {
const hasHideableColumns = table
.getAllColumns()
.some((column) => column.getCanHide());
if (!hasHideableColumns) {
return <span className="p-3"></span>;
}
return (
<div className="flex flex-col items-end gap-1 p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
>
<Columns className="h-4 w-4" />
<span className="sr-only">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
const columnDef =
column.columnDef as any;
const friendlyName =
columnDef.friendlyName;
const displayName =
friendlyName ||
(typeof columnDef.header ===
"string"
? columnDef.header
: column.id);
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(
!!value
)
}
onSelect={(e) =>
e.preventDefault()
}
>
{displayName}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResource(resourceRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.nice}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
const proxyTable = useReactTable({
data: resources,
columns: proxyColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setProxySorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setProxyColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setProxyGlobalFilter,
onColumnVisibilityChange: setProxyColumnVisibility,
initialState: {
pagination: {
pageSize: proxyPageSize,
pageIndex: 0
},
columnVisibility: proxyColumnVisibility
},
state: {
sorting: proxySorting,
columnFilters: proxyColumnFilters,
globalFilter: proxyGlobalFilter,
columnVisibility: proxyColumnVisibility
}
});
return (
<>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResource(null);
}}
dialog={
<div>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title={t("resourceDelete")}
/>
)}
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={proxyGlobalFilter ?? ""}
onChange={(e) =>
proxyTable.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
</div>
<div className="flex items-center gap-2 sm:justify-end">
<div>
<Button
variant="outline"
onClick={() => startTransition(refreshData)}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">
{t("refresh")}
</span>
</Button>
</div>
<div>
<Button
onClick={() =>
router.push(
`/${orgId}/settings/resources/create`
)
}
>
<Plus className="mr-2 h-4 w-4" />
{t("resourceAdd")}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto mt-9">
<Table>
<TableHeader>
{proxyTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{proxyTable.getRowModel().rows?.length ? (
proxyTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={`whitespace-nowrap ${
cell.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={proxyColumns.length}
className="h-24 text-center"
>
{t(
"resourcesTableNoProxyResourcesFound"
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
<DataTablePagination
table={proxyTable}
onPageSizeChange={setProxyPageSize}
/>
</div>
</CardContent>
</Card>
</div>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,29 @@ import * as React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClient } from "@tanstack/react-query";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { durationToMs } from "@app/lib/durationToMs";
export type ReactQueryProviderProps = {
children: React.ReactNode;
};
export function ReactQueryProvider({ children }: ReactQueryProviderProps) {
export function TanstackQueryProvider({ children }: ReactQueryProviderProps) {
const api = createApiClient(useEnvContext());
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 2, // retry twice by default
staleTime: 5 * 60 * 1_000 // 5 minutes
staleTime: durationToMs(5, "minutes"),
meta: {
api
}
},
mutations: {
meta: { api }
}
}
})

View File

@@ -0,0 +1,679 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Input } from "@app/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable
} from "@tanstack/react-table";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
Columns,
MoreHorizontal,
RefreshCw,
Search
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
userId: string | null;
username: string | null;
userEmail: string | null;
};
type ClientTableProps = {
userClients: ClientRow[];
orgId: string;
};
export default function UserDevicesTable({ userClients }: ClientTableProps) {
const router = useRouter();
const t = useTranslations();
const [userPageSize, setUserPageSize] = useStoredPageSize(
"user-clients",
20
);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const [userSorting, setUserSorting] = useState<SortingState>([]);
const [userColumnFilters, setUserColumnFilters] =
useState<ColumnFiltersState>([]);
const [userGlobalFilter, setUserGlobalFilter] = useState<any>([]);
const defaultUserColumnVisibility = {
client: false,
subnet: false
};
const [userColumnVisibility, setUserColumnVisibility] =
useStoredColumnVisibility("user-clients", defaultUserColumnVisibility);
const refreshData = async () => {
try {
router.refresh();
console.log("Data refreshed");
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
// Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => {
return userClients.some((client) => !client.userId);
}, [userClients]);
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userId",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
User
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
friendlyName: "Connectivity",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Olm</span>
{originalRow.olmVersion && (
<span className="text-xs text-gray-500">
v{originalRow.olmVersion}
</span>
)}
</div>
</Badge>
{originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}
];
// Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) {
baseColumns.push({
id: "actions",
enableHiding: false,
header: ({ table }) => {
const hasHideableColumns = table
.getAllColumns()
.some((column) => column.getCanHide());
if (!hasHideableColumns) {
return <span className="p-3"></span>;
}
return (
<div className="flex flex-col items-end gap-1 p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
>
<Columns className="h-4 w-4" />
<span className="sr-only">
{t("columns") || "Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t("toggleColumns") || "Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
const columnDef =
column.columnDef as any;
const friendlyName =
columnDef.friendlyName;
const displayName =
friendlyName ||
(typeof columnDef.header ===
"string"
? columnDef.header
: column.id);
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(
!!value
)
}
onSelect={(e) =>
e.preventDefault()
}
>
{displayName}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => {
const clientRow = row.original;
return !clientRow.userId ? (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
Delete
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"outline"}>
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
) : null;
}
});
}
return baseColumns;
}, [hasRowsWithoutUserId, t]);
const userTable = useReactTable({
data: userClients || [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setUserSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setUserColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setUserGlobalFilter,
onColumnVisibilityChange: setUserColumnVisibility,
initialState: {
pagination: {
pageSize: userPageSize,
pageIndex: 0
},
columnVisibility: userColumnVisibility
},
state: {
sorting: userSorting,
columnFilters: userColumnFilters,
globalFilter: userGlobalFilter,
columnVisibility: userColumnVisibility
}
});
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={userGlobalFilter ?? ""}
onChange={(e) =>
userTable.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
</div>
<div className="flex items-center gap-2 sm:justify-end">
<div>
<Button
variant="outline"
onClick={() => startTransition(refreshData)}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">
{t("refresh")}
</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto mt-9">
<Table>
<TableHeader>
{userTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers
.filter((header) =>
header.column.getIsVisible()
)
.map((header) => (
<TableHead
key={header.id}
className={`whitespace-nowrap ${
header.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: header
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{userTable.getRowModel().rows?.length ? (
userTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
className={`whitespace-nowrap ${
cell.column
.id ===
"actions"
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: cell
.column
.id ===
"name"
? "md:sticky md:left-0 z-10 bg-card"
: ""
}`}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("noResults")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
<DataTablePagination
table={userTable}
onPageSizeChange={setUserPageSize}
/>
</div>
</CardContent>
</Card>
</div>
</>
);
}