mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-03 09:16:40 +00:00
Merge pull request #1960 from Fredkiss3/refactor/separate-tables
refactor: separate tables
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -49,4 +49,5 @@ postgres/
|
|||||||
dynamic/
|
dynamic/
|
||||||
*.mmdb
|
*.mmdb
|
||||||
scratch/
|
scratch/
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
|
hydrateSaas.ts
|
||||||
@@ -144,8 +144,10 @@
|
|||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
"never": "Never",
|
"never": "Never",
|
||||||
"shareErrorSelectResource": "Please select a resource",
|
"shareErrorSelectResource": "Please select a resource",
|
||||||
"resourceTitle": "Manage Resources",
|
"proxyResourceTitle": "Manage Proxy Resources",
|
||||||
"resourceDescription": "Access resources on sites publically or privately",
|
"proxyResourceDescription": "Create and manage secure, publicly accessible resources through a web browser",
|
||||||
|
"clientResourceTitle": "Manage Client Resources",
|
||||||
|
"clientResourceDescription": "Create and manage private resources that are accessible only through a connected client, with no public exposure",
|
||||||
"resourcesSearch": "Search resources...",
|
"resourcesSearch": "Search resources...",
|
||||||
"resourceAdd": "Add Resource",
|
"resourceAdd": "Add Resource",
|
||||||
"resourceErrorDelte": "Error deleting resource",
|
"resourceErrorDelte": "Error deleting resource",
|
||||||
@@ -1151,6 +1153,8 @@
|
|||||||
"sidebarHome": "Home",
|
"sidebarHome": "Home",
|
||||||
"sidebarSites": "Sites",
|
"sidebarSites": "Sites",
|
||||||
"sidebarResources": "Resources",
|
"sidebarResources": "Resources",
|
||||||
|
"sidebarProxyResources": "Proxy Resources",
|
||||||
|
"sidebarClientResources": "Client Resources",
|
||||||
"sidebarAccessControl": "Access Control",
|
"sidebarAccessControl": "Access Control",
|
||||||
"sidebarUsers": "Users",
|
"sidebarUsers": "Users",
|
||||||
"sidebarInvitations": "Invitations",
|
"sidebarInvitations": "Invitations",
|
||||||
@@ -1162,6 +1166,8 @@
|
|||||||
"sidebarIdentityProviders": "Identity Providers",
|
"sidebarIdentityProviders": "Identity Providers",
|
||||||
"sidebarLicense": "License",
|
"sidebarLicense": "License",
|
||||||
"sidebarClients": "Clients",
|
"sidebarClients": "Clients",
|
||||||
|
"sidebarUserDevices": "User devices",
|
||||||
|
"sidebarMachineClients": "Machine Clients",
|
||||||
"sidebarDomains": "Domains",
|
"sidebarDomains": "Domains",
|
||||||
"sidebarBluePrints": "Blueprints",
|
"sidebarBluePrints": "Blueprints",
|
||||||
"blueprints": "Blueprints",
|
"blueprints": "Blueprints",
|
||||||
@@ -1875,8 +1881,10 @@
|
|||||||
"enterpriseEdition": "Enterprise Edition",
|
"enterpriseEdition": "Enterprise Edition",
|
||||||
"unlicensed": "Unlicensed",
|
"unlicensed": "Unlicensed",
|
||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"manageClients": "Manage Clients",
|
"manageUserDevices": "User Devices",
|
||||||
"manageClientsDescription": "Clients are devices that can connect to your sites",
|
"manageUserDevicesDescription": "View and manage personal devices that users connect with to securely access your organization’s resources",
|
||||||
|
"manageMachineClients": "Manage Machine Clients",
|
||||||
|
"manageMachineClientsDescription": "Create and manage automated clients, such as servers or services, that securely connect to your network",
|
||||||
"clientsTableUserClients": "User",
|
"clientsTableUserClients": "User",
|
||||||
"clientsTableMachineClients": "Machine",
|
"clientsTableMachineClients": "Machine",
|
||||||
"licenseTableValidUntil": "Valid Until",
|
"licenseTableValidUntil": "Valid Until",
|
||||||
@@ -2182,7 +2190,7 @@
|
|||||||
"generatedcredentials": "Generated Credentials",
|
"generatedcredentials": "Generated Credentials",
|
||||||
"copyandsavethesecredentials": "Copy and save these credentials",
|
"copyandsavethesecredentials": "Copy and save these credentials",
|
||||||
"copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.",
|
"copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.",
|
||||||
"credentialsSaved" : "Credentials Saved",
|
"credentialsSaved": "Credentials Saved",
|
||||||
"credentialsSavedDescription": "Credentials have been regenerated and saved successfully.",
|
"credentialsSavedDescription": "Credentials have been regenerated and saved successfully.",
|
||||||
"credentialsSaveError": "Credentials Save Error",
|
"credentialsSaveError": "Credentials Save Error",
|
||||||
"credentialsSaveErrorDescription": "An error occurred while regenerating and saving the credentials.",
|
"credentialsSaveErrorDescription": "An error occurred while regenerating and saving the credentials.",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const runMigrations = async () => {
|
|||||||
migrationsFolder: migrationsFolder
|
migrationsFolder: migrationsFolder
|
||||||
});
|
});
|
||||||
console.log("Migrations completed successfully.");
|
console.log("Migrations completed successfully.");
|
||||||
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error running migrations:", error);
|
console.error("Error running migrations:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -359,10 +359,8 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
|
|||||||
return subnet;
|
return subnet;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRemoteSubnets(
|
export function generateRemoteSubnets(allSiteResources: SiteResource[]): string[] {
|
||||||
allSiteResources: SiteResource[]
|
const remoteSubnets = allSiteResources
|
||||||
): string[] {
|
|
||||||
let remoteSubnets = allSiteResources
|
|
||||||
.filter((sr) => {
|
.filter((sr) => {
|
||||||
if (sr.mode === "cidr") return true;
|
if (sr.mode === "cidr") return true;
|
||||||
if (sr.mode === "host") {
|
if (sr.mode === "host") {
|
||||||
@@ -415,7 +413,7 @@ export function generateSubnetProxyTargets(
|
|||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[]
|
}[]
|
||||||
): SubnetProxyTarget[] {
|
): SubnetProxyTarget[] {
|
||||||
let targets: SubnetProxyTarget[] = [];
|
const targets: SubnetProxyTarget[] = [];
|
||||||
|
|
||||||
if (clients.length === 0) {
|
if (clients.length === 0) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -394,9 +394,9 @@ async function handleMessagesForSiteClients(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newtJobs: Promise<any>[] = [];
|
const newtJobs: Promise<any>[] = [];
|
||||||
let olmJobs: Promise<any>[] = [];
|
const olmJobs: Promise<any>[] = [];
|
||||||
let exitNodeJobs: Promise<any>[] = [];
|
const exitNodeJobs: Promise<any>[] = [];
|
||||||
|
|
||||||
// Combine all clients that need processing (those being added or removed)
|
// Combine all clients that need processing (those being added or removed)
|
||||||
const clientsToProcess = new Map<
|
const clientsToProcess = new Map<
|
||||||
@@ -632,8 +632,8 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let proxyJobs = [];
|
const proxyJobs = [];
|
||||||
let olmJobs = [];
|
const olmJobs = [];
|
||||||
// Generate targets for added associations
|
// Generate targets for added associations
|
||||||
if (clientSiteResourcesToAdd.length > 0) {
|
if (clientSiteResourcesToAdd.length > 0) {
|
||||||
const addedClients = allClients.filter((client) =>
|
const addedClients = allClients.filter((client) =>
|
||||||
|
|||||||
@@ -1043,7 +1043,7 @@ hybridRouter.get(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rules = await db
|
const rules = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceRules)
|
.from(resourceRules)
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(eq(siteResources.siteId, siteId));
|
.where(eq(siteResources.siteId, siteId));
|
||||||
|
|
||||||
let targetsToSend: SubnetProxyTarget[] = [];
|
const targetsToSend: SubnetProxyTarget[] = [];
|
||||||
|
|
||||||
for (const resource of allSiteResources) {
|
for (const resource of allSiteResources) {
|
||||||
// Get clients associated with this specific resource
|
// Get clients associated with this specific resource
|
||||||
|
|||||||
@@ -304,8 +304,7 @@ export async function updateSiteResource(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update olms for both destination and alias changes
|
const olmJobs: Promise<void>[] = [];
|
||||||
let olmJobs: Promise<void>[] = [];
|
|
||||||
for (const client of mergedAllClients) {
|
for (const client of mergedAllClients) {
|
||||||
// we also need to update the remote subnets on the olms for each client that has access to this site
|
// we also need to update the remote subnets on the olms for each client that has access to this site
|
||||||
olmJobs.push(
|
olmJobs.push(
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
|
||||||
import { cache } from "react";
|
|
||||||
import MemberResourcesPortal from "../../components/MemberResourcesPortal";
|
|
||||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
|
||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { Layout } from "@app/components/Layout";
|
import { Layout } from "@app/components/Layout";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import MemberResourcesPortal from "@app/components/MemberResourcesPortal";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import EnvProvider from "@app/providers/EnvProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { orgLangingNavItems } from "@app/app/navigation";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
type OrgPageProps = {
|
type OrgPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
|||||||
78
src/app/[orgId]/settings/clients/machine/page.tsx
Normal file
78
src/app/[orgId]/settings/clients/machine/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { ClientRow } from "@app/components/MachineClientsTable";
|
||||||
|
import MachineClientsTable from "@app/components/MachineClientsTable";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { ListClientsResponse } from "@server/routers/client";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
type ClientsPageProps = {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
searchParams: Promise<{ view?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ClientsPage(props: ClientsPageProps) {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
let machineClients: ListClientsResponse["clients"] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const machineRes = await internal.get<
|
||||||
|
AxiosResponse<ListClientsResponse>
|
||||||
|
>(
|
||||||
|
`/org/${params.orgId}/clients?filter=machine`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
machineClients = machineRes.data.data.clients;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
function formatSize(mb: number): string {
|
||||||
|
if (mb >= 1024 * 1024) {
|
||||||
|
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||||
|
} else if (mb >= 1024) {
|
||||||
|
return `${(mb / 1024).toFixed(2)} GB`;
|
||||||
|
} else {
|
||||||
|
return `${mb.toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapClientToRow = (
|
||||||
|
client: ListClientsResponse["clients"][0]
|
||||||
|
): ClientRow => {
|
||||||
|
return {
|
||||||
|
name: client.name,
|
||||||
|
id: client.clientId,
|
||||||
|
subnet: client.subnet.split("/")[0],
|
||||||
|
mbIn: formatSize(client.megabytesIn || 0),
|
||||||
|
mbOut: formatSize(client.megabytesOut || 0),
|
||||||
|
orgId: params.orgId,
|
||||||
|
online: client.online,
|
||||||
|
olmVersion: client.olmVersion || undefined,
|
||||||
|
olmUpdateAvailable: client.olmUpdateAvailable || false,
|
||||||
|
userId: client.userId,
|
||||||
|
username: client.username,
|
||||||
|
userEmail: client.userEmail
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("manageMachineClients")}
|
||||||
|
description={t("manageMachineClientsDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MachineClientsTable
|
||||||
|
machineClients={machineClientRows}
|
||||||
|
orgId={params.orgId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
import { internal } from "@app/lib/api";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { ClientRow } from "../../../../components/ClientsTable";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
import { ListClientsResponse } from "@server/routers/client";
|
|
||||||
import ClientsTable from "../../../../components/ClientsTable";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
|
|
||||||
type ClientsPageProps = {
|
type ClientsPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -15,76 +8,6 @@ type ClientsPageProps = {
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ClientsPage(props: ClientsPageProps) {
|
export default async function ClientsPage(props: ClientsPageProps) {
|
||||||
const t = await getTranslations();
|
|
||||||
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const searchParams = await props.searchParams;
|
redirect(`/${params.orgId}/settings/clients/user`);
|
||||||
|
|
||||||
// Default to 'user' view, or use the query param if provided
|
|
||||||
let defaultView: "user" | "machine" = "user";
|
|
||||||
defaultView = searchParams.view === "machine" ? "machine" : "user";
|
|
||||||
|
|
||||||
let userClients: ListClientsResponse["clients"] = [];
|
|
||||||
let machineClients: ListClientsResponse["clients"] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [userRes, machineRes] = await Promise.all([
|
|
||||||
internal.get<AxiosResponse<ListClientsResponse>>(
|
|
||||||
`/org/${params.orgId}/clients?filter=user`,
|
|
||||||
await authCookieHeader()
|
|
||||||
),
|
|
||||||
internal.get<AxiosResponse<ListClientsResponse>>(
|
|
||||||
`/org/${params.orgId}/clients?filter=machine`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
userClients = userRes.data.data.clients;
|
|
||||||
machineClients = machineRes.data.data.clients;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
function formatSize(mb: number): string {
|
|
||||||
if (mb >= 1024 * 1024) {
|
|
||||||
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
|
||||||
} else if (mb >= 1024) {
|
|
||||||
return `${(mb / 1024).toFixed(2)} GB`;
|
|
||||||
} else {
|
|
||||||
return `${mb.toFixed(2)} MB`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapClientToRow = (client: ListClientsResponse["clients"][0]): ClientRow => {
|
|
||||||
return {
|
|
||||||
name: client.name,
|
|
||||||
id: client.clientId,
|
|
||||||
subnet: client.subnet.split("/")[0],
|
|
||||||
mbIn: formatSize(client.megabytesIn || 0),
|
|
||||||
mbOut: formatSize(client.megabytesOut || 0),
|
|
||||||
orgId: params.orgId,
|
|
||||||
online: client.online,
|
|
||||||
olmVersion: client.olmVersion || undefined,
|
|
||||||
olmUpdateAvailable: client.olmUpdateAvailable || false,
|
|
||||||
userId: client.userId,
|
|
||||||
username: client.username,
|
|
||||||
userEmail: client.userEmail
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const userClientRows: ClientRow[] = userClients.map(mapClientToRow);
|
|
||||||
const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SettingsSectionTitle
|
|
||||||
title={t("manageClients")}
|
|
||||||
description={t("manageClientsDescription")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ClientsTable
|
|
||||||
userClients={userClientRows}
|
|
||||||
machineClients={machineClientRows}
|
|
||||||
orgId={params.orgId}
|
|
||||||
defaultView={defaultView}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/app/[orgId]/settings/clients/user/page.tsx
Normal file
75
src/app/[orgId]/settings/clients/user/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { ListClientsResponse } from "@server/routers/client";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { ClientRow } from "@app/components/MachineClientsTable";
|
||||||
|
import UserDevicesTable from "@app/components/UserDevicesTable";
|
||||||
|
|
||||||
|
type ClientsPageProps = {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ClientsPage(props: ClientsPageProps) {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
let userClients: ListClientsResponse["clients"] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>(
|
||||||
|
`/org/${params.orgId}/clients?filter=user`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
userClients = userRes.data.data.clients;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
function formatSize(mb: number): string {
|
||||||
|
if (mb >= 1024 * 1024) {
|
||||||
|
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||||
|
} else if (mb >= 1024) {
|
||||||
|
return `${(mb / 1024).toFixed(2)} GB`;
|
||||||
|
} else {
|
||||||
|
return `${mb.toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapClientToRow = (
|
||||||
|
client: ListClientsResponse["clients"][0]
|
||||||
|
): ClientRow => {
|
||||||
|
return {
|
||||||
|
name: client.name,
|
||||||
|
id: client.clientId,
|
||||||
|
subnet: client.subnet.split("/")[0],
|
||||||
|
mbIn: formatSize(client.megabytesIn || 0),
|
||||||
|
mbOut: formatSize(client.megabytesOut || 0),
|
||||||
|
orgId: params.orgId,
|
||||||
|
online: client.online,
|
||||||
|
olmVersion: client.olmVersion || undefined,
|
||||||
|
olmUpdateAvailable: client.olmUpdateAvailable || false,
|
||||||
|
userId: client.userId,
|
||||||
|
username: client.username,
|
||||||
|
userEmail: client.userEmail
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const userClientRows: ClientRow[] = userClients.map(mapClientToRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("manageUserDevices")}
|
||||||
|
description={t("manageUserDevicesDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserDevicesTable
|
||||||
|
userClients={userClientRows}
|
||||||
|
orgId={params.orgId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/[orgId]/settings/resources/client/page.tsx
Normal file
93
src/app/[orgId]/settings/resources/client/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import ClientResourcesTable from "@app/components/ClientResourcesTable";
|
||||||
|
import type { InternalResourceRow } from "@app/components/ClientResourcesTable";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
|
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||||
|
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
export interface ClientResourcesPageProps {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
searchParams: Promise<{ view?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ClientResourcesPage(
|
||||||
|
props: ClientResourcesPageProps
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
let resources: ListResourcesResponse["resources"] = [];
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||||
|
`/org/${params.orgId}/resources`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
resources = res.data.data.resources;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
|
try {
|
||||||
|
const res = await internal.get<
|
||||||
|
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||||
|
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
||||||
|
siteResources = res.data.data.siteResources;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
let org = null;
|
||||||
|
try {
|
||||||
|
const res = await getCachedOrg(params.orgId);
|
||||||
|
org = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/resources`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
redirect(`/${params.orgId}/settings/resources`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
||||||
|
(siteResource) => {
|
||||||
|
return {
|
||||||
|
id: siteResource.siteResourceId,
|
||||||
|
name: siteResource.name,
|
||||||
|
orgId: params.orgId,
|
||||||
|
siteName: siteResource.siteName,
|
||||||
|
siteAddress: siteResource.siteAddress || null,
|
||||||
|
mode: siteResource.mode || ("port" as any),
|
||||||
|
// protocol: siteResource.protocol,
|
||||||
|
// proxyPort: siteResource.proxyPort,
|
||||||
|
siteId: siteResource.siteId,
|
||||||
|
destination: siteResource.destination,
|
||||||
|
// destinationPort: siteResource.destinationPort,
|
||||||
|
alias: siteResource.alias || null,
|
||||||
|
siteNiceId: siteResource.siteNiceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("clientResourceTitle")}
|
||||||
|
description={t("clientResourceDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrgProvider org={org}>
|
||||||
|
<ClientResourcesTable
|
||||||
|
internalResources={internalResourceRows}
|
||||||
|
orgId={params.orgId}
|
||||||
|
defaultSort={{
|
||||||
|
id: "name",
|
||||||
|
desc: false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OrgProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,149 +1,10 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import ResourcesTable, {
|
|
||||||
ResourceRow,
|
|
||||||
InternalResourceRow
|
|
||||||
} from "../../../../components/ResourcesTable";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { ListResourcesResponse } from "@server/routers/resource";
|
|
||||||
import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
|
||||||
import { toUnicode } from "punycode";
|
|
||||||
|
|
||||||
type ResourcesPageProps = {
|
export interface ResourcesPageProps {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
searchParams: Promise<{ view?: string }>;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function ResourcesPage(props: ResourcesPageProps) {
|
export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const searchParams = await props.searchParams;
|
redirect(`/${params.orgId}/settings/resources/proxy`);
|
||||||
const t = await getTranslations();
|
|
||||||
|
|
||||||
const env = pullEnv();
|
|
||||||
|
|
||||||
// Default to 'proxy' view, or use the query param if provided
|
|
||||||
let defaultView: "proxy" | "internal" = "proxy";
|
|
||||||
if (env.flags.enableClients) {
|
|
||||||
defaultView = searchParams.view === "internal" ? "internal" : "proxy";
|
|
||||||
}
|
|
||||||
|
|
||||||
let resources: ListResourcesResponse["resources"] = [];
|
|
||||||
try {
|
|
||||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
|
||||||
`/org/${params.orgId}/resources`,
|
|
||||||
await authCookieHeader()
|
|
||||||
);
|
|
||||||
resources = res.data.data.resources;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
|
||||||
try {
|
|
||||||
const res = await internal.get<
|
|
||||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
|
||||||
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
|
||||||
siteResources = res.data.data.siteResources;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
let org = null;
|
|
||||||
try {
|
|
||||||
const getOrg = cache(async () =>
|
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
|
||||||
`/org/${params.orgId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrg();
|
|
||||||
org = res.data.data;
|
|
||||||
} catch {
|
|
||||||
redirect(`/${params.orgId}/settings/resources`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
redirect(`/${params.orgId}/settings/resources`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceRows: ResourceRow[] = resources.map((resource) => {
|
|
||||||
return {
|
|
||||||
id: resource.resourceId,
|
|
||||||
name: resource.name,
|
|
||||||
orgId: params.orgId,
|
|
||||||
nice: resource.niceId,
|
|
||||||
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
|
||||||
protocol: resource.protocol,
|
|
||||||
proxyPort: resource.proxyPort,
|
|
||||||
http: resource.http,
|
|
||||||
authState: !resource.http
|
|
||||||
? "none"
|
|
||||||
: resource.sso ||
|
|
||||||
resource.pincodeId !== null ||
|
|
||||||
resource.passwordId !== null ||
|
|
||||||
resource.whitelist ||
|
|
||||||
resource.headerAuthId
|
|
||||||
? "protected"
|
|
||||||
: "not_protected",
|
|
||||||
enabled: resource.enabled,
|
|
||||||
domainId: resource.domainId || undefined,
|
|
||||||
ssl: resource.ssl,
|
|
||||||
targets: resource.targets?.map((target) => ({
|
|
||||||
targetId: target.targetId,
|
|
||||||
ip: target.ip,
|
|
||||||
port: target.port,
|
|
||||||
enabled: target.enabled,
|
|
||||||
healthStatus: target.healthStatus
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
|
||||||
(siteResource) => {
|
|
||||||
return {
|
|
||||||
id: siteResource.siteResourceId,
|
|
||||||
name: siteResource.name,
|
|
||||||
orgId: params.orgId,
|
|
||||||
siteName: siteResource.siteName,
|
|
||||||
siteAddress: siteResource.siteAddress || null,
|
|
||||||
mode: siteResource.mode || ("port" as any),
|
|
||||||
// protocol: siteResource.protocol,
|
|
||||||
// proxyPort: siteResource.proxyPort,
|
|
||||||
siteId: siteResource.siteId,
|
|
||||||
destination: siteResource.destination,
|
|
||||||
// destinationPort: siteResource.destinationPort,
|
|
||||||
alias: siteResource.alias || null,
|
|
||||||
siteNiceId: siteResource.siteNiceId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SettingsSectionTitle
|
|
||||||
title={t("resourceTitle")}
|
|
||||||
description={t("resourceDescription")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OrgProvider org={org}>
|
|
||||||
<ResourcesTable
|
|
||||||
resources={resourceRows}
|
|
||||||
internalResources={internalResourceRows}
|
|
||||||
orgId={params.orgId}
|
|
||||||
defaultView={
|
|
||||||
env.flags.enableClients ? defaultView : "proxy"
|
|
||||||
}
|
|
||||||
defaultSort={{
|
|
||||||
id: "name",
|
|
||||||
desc: false
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</OrgProvider>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
112
src/app/[orgId]/settings/resources/proxy/page.tsx
Normal file
112
src/app/[orgId]/settings/resources/proxy/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
|
||||||
|
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
|
import type { GetOrgResponse } from "@server/routers/org";
|
||||||
|
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||||
|
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { toUnicode } from "punycode";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
export interface ProxyResourcesPageProps {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
searchParams: Promise<{ view?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProxyResourcesPage(
|
||||||
|
props: ProxyResourcesPageProps
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
let resources: ListResourcesResponse["resources"] = [];
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||||
|
`/org/${params.orgId}/resources`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
resources = res.data.data.resources;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
|
try {
|
||||||
|
const res = await internal.get<
|
||||||
|
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||||
|
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
||||||
|
siteResources = res.data.data.siteResources;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
let org = null;
|
||||||
|
try {
|
||||||
|
const getOrg = cache(async () =>
|
||||||
|
internal.get<AxiosResponse<GetOrgResponse>>(
|
||||||
|
`/org/${params.orgId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const res = await getOrg();
|
||||||
|
org = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/resources`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
redirect(`/${params.orgId}/settings/resources`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceRows: ResourceRow[] = resources.map((resource) => {
|
||||||
|
return {
|
||||||
|
id: resource.resourceId,
|
||||||
|
name: resource.name,
|
||||||
|
orgId: params.orgId,
|
||||||
|
nice: resource.niceId,
|
||||||
|
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
||||||
|
protocol: resource.protocol,
|
||||||
|
proxyPort: resource.proxyPort,
|
||||||
|
http: resource.http,
|
||||||
|
authState: !resource.http
|
||||||
|
? "none"
|
||||||
|
: resource.sso ||
|
||||||
|
resource.pincodeId !== null ||
|
||||||
|
resource.passwordId !== null ||
|
||||||
|
resource.whitelist ||
|
||||||
|
resource.headerAuthId
|
||||||
|
? "protected"
|
||||||
|
: "not_protected",
|
||||||
|
enabled: resource.enabled,
|
||||||
|
domainId: resource.domainId || undefined,
|
||||||
|
ssl: resource.ssl,
|
||||||
|
targets: resource.targets?.map((target) => ({
|
||||||
|
targetId: target.targetId,
|
||||||
|
ip: target.ip,
|
||||||
|
port: target.port,
|
||||||
|
enabled: target.enabled,
|
||||||
|
healthStatus: target.healthStatus
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("proxyResourceTitle")}
|
||||||
|
description={t("proxyResourceDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrgProvider org={org}>
|
||||||
|
<ProxyResourcesTable
|
||||||
|
resources={resourceRows}
|
||||||
|
orgId={params.orgId}
|
||||||
|
defaultSort={{
|
||||||
|
id: "name",
|
||||||
|
desc: false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OrgProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import { Toaster } from "@app/components/ui/toaster";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { TopLoader } from "@app/components/Toploader";
|
import { TopLoader } from "@app/components/Toploader";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { ReactQueryProvider } from "@app/components/react-query-provider";
|
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||||
@@ -95,16 +95,16 @@ export default async function RootLayout({
|
|||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ReactQueryProvider>
|
<NextIntlClientProvider>
|
||||||
<NextIntlClientProvider>
|
<ThemeProvider
|
||||||
<ThemeProvider
|
attribute="class"
|
||||||
attribute="class"
|
defaultTheme="system"
|
||||||
defaultTheme="system"
|
enableSystem
|
||||||
enableSystem
|
disableTransitionOnChange
|
||||||
disableTransitionOnChange
|
>
|
||||||
>
|
<ThemeDataProvider colors={loadBrandingColors()}>
|
||||||
<ThemeDataProvider colors={loadBrandingColors()}>
|
<EnvProvider env={env}>
|
||||||
<EnvProvider env={pullEnv()}>
|
<TanstackQueryProvider>
|
||||||
<LicenseStatusProvider
|
<LicenseStatusProvider
|
||||||
licenseStatus={licenseStatus}
|
licenseStatus={licenseStatus}
|
||||||
>
|
>
|
||||||
@@ -124,11 +124,11 @@ export default async function RootLayout({
|
|||||||
</SupportStatusProvider>
|
</SupportStatusProvider>
|
||||||
</LicenseStatusProvider>
|
</LicenseStatusProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</EnvProvider>
|
</TanstackQueryProvider>
|
||||||
</ThemeDataProvider>
|
</EnvProvider>
|
||||||
</ThemeProvider>
|
</ThemeDataProvider>
|
||||||
</NextIntlClientProvider>
|
</ThemeProvider>
|
||||||
</ReactQueryProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Logs,
|
Logs,
|
||||||
SquareMousePointer,
|
SquareMousePointer,
|
||||||
ScanEye
|
ScanEye,
|
||||||
|
GlobeLock,
|
||||||
|
Smartphone
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type SidebarNavSection = {
|
export type SidebarNavSection = {
|
||||||
@@ -31,7 +33,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [
|
|||||||
{
|
{
|
||||||
title: "sidebarAccount",
|
title: "sidebarAccount",
|
||||||
href: "/{orgId}",
|
href: "/{orgId}",
|
||||||
icon: <User className="h-4 w-4" />
|
icon: <User className="size-4 flex-none" />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,20 +46,50 @@ export const orgNavSections = (
|
|||||||
{
|
{
|
||||||
title: "sidebarSites",
|
title: "sidebarSites",
|
||||||
href: "/{orgId}/settings/sites",
|
href: "/{orgId}/settings/sites",
|
||||||
icon: <Combine className="h-4 w-4" />
|
icon: <Combine className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarResources",
|
title: "sidebarResources",
|
||||||
href: "/{orgId}/settings/resources",
|
icon: <Waypoints className="size-4 flex-none" />,
|
||||||
icon: <Waypoints className="h-4 w-4" />
|
items: [
|
||||||
|
{
|
||||||
|
title: "sidebarProxyResources",
|
||||||
|
href: "/{orgId}/settings/resources/proxy",
|
||||||
|
icon: <Globe className="size-4 flex-none" />
|
||||||
|
},
|
||||||
|
...(enableClients
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "sidebarClientResources",
|
||||||
|
href: "/{orgId}/settings/resources/client",
|
||||||
|
icon: (
|
||||||
|
<GlobeLock className="size-4 flex-none" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
},
|
},
|
||||||
...(enableClients
|
...(enableClients
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarClients",
|
title: "sidebarClients",
|
||||||
href: "/{orgId}/settings/clients",
|
icon: <MonitorUp className="size-4 flex-none" />,
|
||||||
icon: <MonitorUp className="h-4 w-4" />,
|
isBeta: true,
|
||||||
isBeta: true
|
items: [
|
||||||
|
{
|
||||||
|
href: "/{orgId}/settings/clients/user",
|
||||||
|
title: "sidebarUserDevices",
|
||||||
|
icon: (
|
||||||
|
<Smartphone className="size-4 flex-none" />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/{orgId}/settings/clients/machine",
|
||||||
|
title: "sidebarMachineClients",
|
||||||
|
icon: <Server className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -66,7 +98,7 @@ export const orgNavSections = (
|
|||||||
{
|
{
|
||||||
title: "sidebarRemoteExitNodes",
|
title: "sidebarRemoteExitNodes",
|
||||||
href: "/{orgId}/settings/remote-exit-nodes",
|
href: "/{orgId}/settings/remote-exit-nodes",
|
||||||
icon: <Server className="h-4 w-4" />,
|
icon: <Server className="size-4 flex-none" />,
|
||||||
showEE: true
|
showEE: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -74,12 +106,12 @@ export const orgNavSections = (
|
|||||||
{
|
{
|
||||||
title: "sidebarDomains",
|
title: "sidebarDomains",
|
||||||
href: "/{orgId}/settings/domains",
|
href: "/{orgId}/settings/domains",
|
||||||
icon: <Globe className="h-4 w-4" />
|
icon: <Globe className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarBluePrints",
|
title: "sidebarBluePrints",
|
||||||
href: "/{orgId}/settings/blueprints",
|
href: "/{orgId}/settings/blueprints",
|
||||||
icon: <ReceiptText className="h-4 w-4" />
|
icon: <ReceiptText className="size-4 flex-none" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -88,31 +120,31 @@ export const orgNavSections = (
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarUsers",
|
title: "sidebarUsers",
|
||||||
icon: <User className="h-4 w-4" />,
|
icon: <User className="size-4 flex-none" />,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarUsers",
|
title: "sidebarUsers",
|
||||||
href: "/{orgId}/settings/access/users",
|
href: "/{orgId}/settings/access/users",
|
||||||
icon: <User className="h-4 w-4" />
|
icon: <User className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarInvitations",
|
title: "sidebarInvitations",
|
||||||
href: "/{orgId}/settings/access/invitations",
|
href: "/{orgId}/settings/access/invitations",
|
||||||
icon: <TicketCheck className="h-4 w-4" />
|
icon: <TicketCheck className="size-4 flex-none" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarRoles",
|
title: "sidebarRoles",
|
||||||
href: "/{orgId}/settings/access/roles",
|
href: "/{orgId}/settings/access/roles",
|
||||||
icon: <Users className="h-4 w-4" />
|
icon: <Users className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build == "saas"
|
...(build == "saas"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarIdentityProviders",
|
title: "sidebarIdentityProviders",
|
||||||
href: "/{orgId}/settings/idp",
|
href: "/{orgId}/settings/idp",
|
||||||
icon: <Fingerprint className="h-4 w-4" />,
|
icon: <Fingerprint className="size-4 flex-none" />,
|
||||||
showEE: true
|
showEE: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -120,7 +152,7 @@ export const orgNavSections = (
|
|||||||
{
|
{
|
||||||
title: "sidebarShareableLinks",
|
title: "sidebarShareableLinks",
|
||||||
href: "/{orgId}/settings/share-links",
|
href: "/{orgId}/settings/share-links",
|
||||||
icon: <LinkIcon className="h-4 w-4" />
|
icon: <LinkIcon className="size-4 flex-none" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -131,19 +163,19 @@ export const orgNavSections = (
|
|||||||
{
|
{
|
||||||
title: "sidebarLogsRequest",
|
title: "sidebarLogsRequest",
|
||||||
href: "/{orgId}/settings/logs/request",
|
href: "/{orgId}/settings/logs/request",
|
||||||
icon: <SquareMousePointer className="h-4 w-4" />
|
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build != "oss"
|
...(build != "oss"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarLogsAccess",
|
title: "sidebarLogsAccess",
|
||||||
href: "/{orgId}/settings/logs/access",
|
href: "/{orgId}/settings/logs/access",
|
||||||
icon: <ScanEye className="h-4 w-4" />
|
icon: <ScanEye className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarLogsAction",
|
title: "sidebarLogsAction",
|
||||||
href: "/{orgId}/settings/logs/action",
|
href: "/{orgId}/settings/logs/action",
|
||||||
icon: <Logs className="h-4 w-4" />
|
icon: <Logs className="size-4 flex-none" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [])
|
: [])
|
||||||
@@ -158,7 +190,7 @@ export const orgNavSections = (
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: "sidebarLogs",
|
title: "sidebarLogs",
|
||||||
icon: <Logs className="h-4 w-4" />,
|
icon: <Logs className="size-4 flex-none" />,
|
||||||
items: logItems
|
items: logItems
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -170,14 +202,14 @@ export const orgNavSections = (
|
|||||||
{
|
{
|
||||||
title: "sidebarApiKeys",
|
title: "sidebarApiKeys",
|
||||||
href: "/{orgId}/settings/api-keys",
|
href: "/{orgId}/settings/api-keys",
|
||||||
icon: <KeyRound className="h-4 w-4" />
|
icon: <KeyRound className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build == "saas"
|
...(build == "saas"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarBilling",
|
title: "sidebarBilling",
|
||||||
href: "/{orgId}/settings/billing",
|
href: "/{orgId}/settings/billing",
|
||||||
icon: <CreditCard className="h-4 w-4" />
|
icon: <CreditCard className="size-4 flex-none" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -186,14 +218,14 @@ export const orgNavSections = (
|
|||||||
{
|
{
|
||||||
title: "sidebarEnterpriseLicenses",
|
title: "sidebarEnterpriseLicenses",
|
||||||
href: "/{orgId}/settings/license",
|
href: "/{orgId}/settings/license",
|
||||||
icon: <TicketCheck className="h-4 w-4" />
|
icon: <TicketCheck className="size-4 flex-none" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
title: "sidebarSettings",
|
title: "sidebarSettings",
|
||||||
href: "/{orgId}/settings/general",
|
href: "/{orgId}/settings/general",
|
||||||
icon: <Settings className="h-4 w-4" />
|
icon: <Settings className="size-4 flex-none" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -206,24 +238,24 @@ export const adminNavSections: SidebarNavSection[] = [
|
|||||||
{
|
{
|
||||||
title: "sidebarAllUsers",
|
title: "sidebarAllUsers",
|
||||||
href: "/admin/users",
|
href: "/admin/users",
|
||||||
icon: <Users className="h-4 w-4" />
|
icon: <Users className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarApiKeys",
|
title: "sidebarApiKeys",
|
||||||
href: "/admin/api-keys",
|
href: "/admin/api-keys",
|
||||||
icon: <KeyRound className="h-4 w-4" />
|
icon: <KeyRound className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarIdentityProviders",
|
title: "sidebarIdentityProviders",
|
||||||
href: "/admin/idp",
|
href: "/admin/idp",
|
||||||
icon: <Fingerprint className="h-4 w-4" />
|
icon: <Fingerprint className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build == "enterprise"
|
...(build == "enterprise"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarLicense",
|
title: "sidebarLicense",
|
||||||
href: "/admin/license",
|
href: "/admin/license",
|
||||||
icon: <TicketCheck className="h-4 w-4" />
|
icon: <TicketCheck className="size-4 flex-none" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [])
|
: [])
|
||||||
|
|||||||
634
src/components/ClientResourcesTable.tsx
Normal file
634
src/components/ClientResourcesTable.tsx
Normal 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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { Input } from "@app/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Credenza,
|
||||||
SelectContent,
|
CredenzaBody,
|
||||||
SelectItem,
|
CredenzaContent,
|
||||||
SelectTrigger,
|
CredenzaDescription,
|
||||||
SelectValue
|
CredenzaFooter,
|
||||||
} from "@app/components/ui/select";
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -18,15 +19,6 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList
|
CommandList
|
||||||
} from "@app/components/ui/command";
|
} 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 {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -36,29 +28,36 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Popover,
|
||||||
CredenzaBody,
|
PopoverContent,
|
||||||
CredenzaClose,
|
PopoverTrigger
|
||||||
CredenzaContent,
|
} from "@app/components/ui/popover";
|
||||||
CredenzaDescription,
|
import {
|
||||||
CredenzaFooter,
|
Select,
|
||||||
CredenzaHeader,
|
SelectContent,
|
||||||
CredenzaTitle
|
SelectItem,
|
||||||
} from "@app/components/Credenza";
|
SelectTrigger,
|
||||||
import { toast } from "@app/hooks/useToast";
|
SelectValue
|
||||||
import { useTranslations } from "next-intl";
|
} from "@app/components/ui/select";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { ListUsersResponse } from "@server/routers/user";
|
|
||||||
import { ListClientsResponse } from "@server/routers/client/listClients";
|
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AxiosResponse } from "axios";
|
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 { 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];
|
type Site = ListSitesResponse["sites"][0];
|
||||||
|
|
||||||
@@ -89,7 +88,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
// mode: z.enum(["host", "cidr", "port"]),
|
// mode: z.enum(["host", "cidr", "port"]),
|
||||||
mode: z.enum(["host", "cidr"]),
|
mode: z.enum(["host", "cidr"]),
|
||||||
destination: z.string().min(1),
|
destination: z.string().min(1),
|
||||||
siteId: z.int().positive(t("createInternalResourceDialogPleaseSelectSite")),
|
siteId: z
|
||||||
|
.int()
|
||||||
|
.positive(t("createInternalResourceDialogPleaseSelectSite")),
|
||||||
// protocol: z.enum(["tcp", "udp"]),
|
// protocol: z.enum(["tcp", "udp"]),
|
||||||
// proxyPort: z.int()
|
// proxyPort: z.int()
|
||||||
// .positive()
|
// .positive()
|
||||||
@@ -101,25 +102,31 @@ export default function CreateInternalResourceDialog({
|
|||||||
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
||||||
// .nullish(),
|
// .nullish(),
|
||||||
alias: z.string().nullish(),
|
alias: z.string().nullish(),
|
||||||
roles: z.array(
|
roles: z
|
||||||
z.object({
|
.array(
|
||||||
id: z.string(),
|
z.object({
|
||||||
text: z.string()
|
id: z.string(),
|
||||||
})
|
text: z.string()
|
||||||
).optional(),
|
})
|
||||||
users: z.array(
|
)
|
||||||
z.object({
|
.optional(),
|
||||||
id: z.string(),
|
users: z
|
||||||
text: z.string()
|
.array(
|
||||||
})
|
z.object({
|
||||||
).optional(),
|
id: z.string(),
|
||||||
clients: z.array(
|
text: z.string()
|
||||||
z.object({
|
})
|
||||||
id: z.string(),
|
)
|
||||||
text: z.string()
|
.optional(),
|
||||||
})
|
clients: z
|
||||||
).optional()
|
.array(
|
||||||
})
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
text: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
// .refine(
|
// .refine(
|
||||||
// (data) => {
|
// (data) => {
|
||||||
// if (data.mode === "port") {
|
// if (data.mode === "port") {
|
||||||
@@ -159,13 +166,47 @@ export default function CreateInternalResourceDialog({
|
|||||||
|
|
||||||
type FormData = z.infer<typeof formSchema>;
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]);
|
const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId }));
|
||||||
const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]);
|
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
|
||||||
const [allClients, setAllClients] = useState<{ id: string; text: string }[]>([]);
|
const { data: clientsResponse = [] } = useQuery(
|
||||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<number | null>(null);
|
orgQueries.clients({
|
||||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<number | null>(null);
|
orgId,
|
||||||
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<number | null>(null);
|
filters: {
|
||||||
const [hasMachineClients, setHasMachineClients] = useState(false);
|
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(
|
const availableSites = sites.filter(
|
||||||
(site) => site.type === "newt" && site.subnet
|
(site) => site.type === "newt" && site.subnet
|
||||||
@@ -208,50 +249,6 @@ export default function CreateInternalResourceDialog({
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [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) => {
|
const handleSubmit = async (data: FormData) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -265,10 +262,19 @@ export default function CreateInternalResourceDialog({
|
|||||||
// destinationPort: data.mode === "port" ? data.destinationPort : undefined,
|
// destinationPort: data.mode === "port" ? data.destinationPort : undefined,
|
||||||
destination: data.destination,
|
destination: data.destination,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
|
alias:
|
||||||
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
|
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) : [],
|
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({
|
toast({
|
||||||
title: t("createInternalResourceDialogSuccess"),
|
title: t("createInternalResourceDialogSuccess"),
|
||||||
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
|
description: t(
|
||||||
|
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||||
|
),
|
||||||
variant: "default"
|
variant: "default"
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -307,7 +315,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
title: t("createInternalResourceDialogError"),
|
title: t("createInternalResourceDialogError"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
error,
|
error,
|
||||||
t("createInternalResourceDialogFailedToCreateInternalResource")
|
t(
|
||||||
|
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
@@ -321,13 +331,19 @@ export default function CreateInternalResourceDialog({
|
|||||||
<Credenza open={open} onOpenChange={setOpen}>
|
<Credenza open={open} onOpenChange={setOpen}>
|
||||||
<CredenzaContent className="max-w-md">
|
<CredenzaContent className="max-w-md">
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>{t("createInternalResourceDialogNoSitesAvailable")}</CredenzaTitle>
|
<CredenzaTitle>
|
||||||
|
{t("createInternalResourceDialogNoSitesAvailable")}
|
||||||
|
</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
{t("createInternalResourceDialogNoSitesAvailableDescription")}
|
{t(
|
||||||
|
"createInternalResourceDialogNoSitesAvailableDescription"
|
||||||
|
)}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
<Button onClick={() => setOpen(false)}>{t("createInternalResourceDialogClose")}</Button>
|
<Button onClick={() => setOpen(false)}>
|
||||||
|
{t("createInternalResourceDialogClose")}
|
||||||
|
</Button>
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
@@ -338,9 +354,13 @@ export default function CreateInternalResourceDialog({
|
|||||||
<Credenza open={open} onOpenChange={setOpen}>
|
<Credenza open={open} onOpenChange={setOpen}>
|
||||||
<CredenzaContent className="max-w-2xl">
|
<CredenzaContent className="max-w-2xl">
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
|
<CredenzaTitle>
|
||||||
|
{t("createInternalResourceDialogCreateClientResource")}
|
||||||
|
</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
{t("createInternalResourceDialogCreateClientResourceDescription")}
|
{t(
|
||||||
|
"createInternalResourceDialogCreateClientResourceDescription"
|
||||||
|
)}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
@@ -353,7 +373,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
{/* Resource Properties Form */}
|
{/* Resource Properties Form */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
{t("createInternalResourceDialogResourceProperties")}
|
{t(
|
||||||
|
"createInternalResourceDialogResourceProperties"
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -361,7 +383,11 @@ export default function CreateInternalResourceDialog({
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("createInternalResourceDialogName")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"createInternalResourceDialogName"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -375,7 +401,11 @@ export default function CreateInternalResourceDialog({
|
|||||||
name="siteId"
|
name="siteId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col">
|
<FormItem className="flex flex-col">
|
||||||
<FormLabel>{t("createInternalResourceDialogSite")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"createInternalResourceDialogSite"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -384,43 +414,71 @@ export default function CreateInternalResourceDialog({
|
|||||||
role="combobox"
|
role="combobox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground"
|
!field.value &&
|
||||||
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? availableSites.find(
|
? availableSites.find(
|
||||||
(site) => site.siteId === field.value
|
(
|
||||||
|
site
|
||||||
|
) =>
|
||||||
|
site.siteId ===
|
||||||
|
field.value
|
||||||
)?.name
|
)?.name
|
||||||
: t("createInternalResourceDialogSelectSite")}
|
: t(
|
||||||
|
"createInternalResourceDialogSelectSite"
|
||||||
|
)}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full p-0">
|
<PopoverContent className="w-full p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={t("createInternalResourceDialogSearchSites")} />
|
<CommandInput
|
||||||
|
placeholder={t(
|
||||||
|
"createInternalResourceDialogSearchSites"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{t("createInternalResourceDialogNoSitesFound")}</CommandEmpty>
|
<CommandEmpty>
|
||||||
|
{t(
|
||||||
|
"createInternalResourceDialogNoSitesFound"
|
||||||
|
)}
|
||||||
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{availableSites.map((site) => (
|
{availableSites.map(
|
||||||
<CommandItem
|
(
|
||||||
key={site.siteId}
|
site
|
||||||
value={site.name}
|
) => (
|
||||||
onSelect={() => {
|
<CommandItem
|
||||||
field.onChange(site.siteId);
|
key={
|
||||||
}}
|
site.siteId
|
||||||
>
|
}
|
||||||
<Check
|
value={
|
||||||
className={cn(
|
site.name
|
||||||
"mr-2 h-4 w-4",
|
}
|
||||||
field.value === site.siteId
|
onSelect={() => {
|
||||||
? "opacity-100"
|
field.onChange(
|
||||||
: "opacity-0"
|
site.siteId
|
||||||
)}
|
);
|
||||||
/>
|
}}
|
||||||
{site.name}
|
>
|
||||||
</CommandItem>
|
<Check
|
||||||
))}
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
field.value ===
|
||||||
|
site.siteId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
site.name
|
||||||
|
}
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
@@ -431,14 +489,20 @@ export default function CreateInternalResourceDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="mode"
|
name="mode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("createInternalResourceDialogMode")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"createInternalResourceDialogMode"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -448,15 +512,23 @@ export default function CreateInternalResourceDialog({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{/* <SelectItem value="port">{t("createInternalResourceDialogModePort")}</SelectItem> */}
|
{/* <SelectItem value="port">{t("createInternalResourceDialogModePort")}</SelectItem> */}
|
||||||
<SelectItem value="host">{t("createInternalResourceDialogModeHost")}</SelectItem>
|
<SelectItem value="host">
|
||||||
<SelectItem value="cidr">{t("createInternalResourceDialogModeCidr")}</SelectItem>
|
{t(
|
||||||
|
"createInternalResourceDialogModeHost"
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="cidr">
|
||||||
|
{t(
|
||||||
|
"createInternalResourceDialogModeCidr"
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/*
|
{/*
|
||||||
{mode === "port" && (
|
{mode === "port" && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -521,7 +593,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
{/* Target Configuration Form */}
|
{/* Target Configuration Form */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
{t("createInternalResourceDialogTargetConfiguration")}
|
{t(
|
||||||
|
"createInternalResourceDialogTargetConfiguration"
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -530,14 +604,22 @@ export default function CreateInternalResourceDialog({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("createInternalResourceDialogDestination")}
|
{t(
|
||||||
|
"createInternalResourceDialogDestination"
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{mode === "host" && t("createInternalResourceDialogDestinationHostDescription")}
|
{mode === "host" &&
|
||||||
{mode === "cidr" && t("createInternalResourceDialogDestinationCidrDescription")}
|
t(
|
||||||
|
"createInternalResourceDialogDestinationHostDescription"
|
||||||
|
)}
|
||||||
|
{mode === "cidr" &&
|
||||||
|
t(
|
||||||
|
"createInternalResourceDialogDestinationCidrDescription"
|
||||||
|
)}
|
||||||
{/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */}
|
{/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -584,12 +666,23 @@ export default function CreateInternalResourceDialog({
|
|||||||
name="alias"
|
name="alias"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("createInternalResourceDialogAlias")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"createInternalResourceDialogAlias"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} value={field.value ?? ""} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={
|
||||||
|
field.value ?? ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("createInternalResourceDialogAliasDescription")}
|
{t(
|
||||||
|
"createInternalResourceDialogAliasDescription"
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -609,31 +702,53 @@ export default function CreateInternalResourceDialog({
|
|||||||
name="roles"
|
name="roles"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("roles")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("roles")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={activeRolesTagIndex}
|
activeTagIndex={
|
||||||
setActiveTagIndex={setActiveRolesTagIndex}
|
activeRolesTagIndex
|
||||||
placeholder={t("accessRoleSelect2")}
|
}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveRolesTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"accessRoleSelect2"
|
||||||
|
)}
|
||||||
size="sm"
|
size="sm"
|
||||||
tags={form.getValues().roles || []}
|
tags={
|
||||||
|
form.getValues()
|
||||||
|
.roles || []
|
||||||
|
}
|
||||||
setTags={(newRoles) => {
|
setTags={(newRoles) => {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"roles",
|
"roles",
|
||||||
newRoles as [Tag, ...Tag[]]
|
newRoles as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
enableAutocomplete={true}
|
enableAutocomplete={
|
||||||
autocompleteOptions={allRoles}
|
true
|
||||||
|
}
|
||||||
|
autocompleteOptions={
|
||||||
|
allRoles
|
||||||
|
}
|
||||||
allowDuplicates={false}
|
allowDuplicates={false}
|
||||||
restrictTagsToAutocompleteOptions={true}
|
restrictTagsToAutocompleteOptions={
|
||||||
|
true
|
||||||
|
}
|
||||||
sortTags={true}
|
sortTags={true}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("resourceRoleDescription")}
|
{t(
|
||||||
|
"resourceRoleDescription"
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -643,25 +758,45 @@ export default function CreateInternalResourceDialog({
|
|||||||
name="users"
|
name="users"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("users")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("users")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={activeUsersTagIndex}
|
activeTagIndex={
|
||||||
setActiveTagIndex={setActiveUsersTagIndex}
|
activeUsersTagIndex
|
||||||
placeholder={t("accessUserSelect")}
|
}
|
||||||
tags={form.getValues().users || []}
|
setActiveTagIndex={
|
||||||
|
setActiveUsersTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"accessUserSelect"
|
||||||
|
)}
|
||||||
|
tags={
|
||||||
|
form.getValues()
|
||||||
|
.users || []
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
setTags={(newUsers) => {
|
setTags={(newUsers) => {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"users",
|
"users",
|
||||||
newUsers as [Tag, ...Tag[]]
|
newUsers as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
enableAutocomplete={true}
|
enableAutocomplete={
|
||||||
autocompleteOptions={allUsers}
|
true
|
||||||
|
}
|
||||||
|
autocompleteOptions={
|
||||||
|
allUsers
|
||||||
|
}
|
||||||
allowDuplicates={false}
|
allowDuplicates={false}
|
||||||
restrictTagsToAutocompleteOptions={true}
|
restrictTagsToAutocompleteOptions={
|
||||||
|
true
|
||||||
|
}
|
||||||
sortTags={true}
|
sortTags={true}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -675,31 +810,62 @@ export default function CreateInternalResourceDialog({
|
|||||||
name="clients"
|
name="clients"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("clients")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("clients")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={activeClientsTagIndex}
|
activeTagIndex={
|
||||||
setActiveTagIndex={setActiveClientsTagIndex}
|
activeClientsTagIndex
|
||||||
placeholder={t("accessClientSelect") || "Select machine clients"}
|
}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveClientsTagIndex
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
t(
|
||||||
|
"accessClientSelect"
|
||||||
|
) ||
|
||||||
|
"Select machine clients"
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
tags={form.getValues().clients || []}
|
tags={
|
||||||
setTags={(newClients) => {
|
form.getValues()
|
||||||
|
.clients ||
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
setTags={(
|
||||||
|
newClients
|
||||||
|
) => {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"clients",
|
"clients",
|
||||||
newClients as [Tag, ...Tag[]]
|
newClients as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
enableAutocomplete={true}
|
enableAutocomplete={
|
||||||
autocompleteOptions={allClients}
|
true
|
||||||
allowDuplicates={false}
|
}
|
||||||
restrictTagsToAutocompleteOptions={true}
|
autocompleteOptions={
|
||||||
|
allClients
|
||||||
|
}
|
||||||
|
allowDuplicates={
|
||||||
|
false
|
||||||
|
}
|
||||||
|
restrictTagsToAutocompleteOptions={
|
||||||
|
true
|
||||||
|
}
|
||||||
sortTags={true}
|
sortTags={true}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("resourceClientDescription") || "Machine clients that can access this resource"}
|
{t(
|
||||||
|
"resourceClientDescription"
|
||||||
|
) ||
|
||||||
|
"Machine clients that can access this resource"}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import { ListClientsResponse } from "@server/routers/client/listClients";
|
|||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||||
|
|
||||||
type InternalResourceData = {
|
type InternalResourceData = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -81,32 +83,41 @@ export default function EditInternalResourceDialog({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const formSchema = z.object({
|
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"]),
|
mode: z.enum(["host", "cidr", "port"]),
|
||||||
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
||||||
// proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
|
// proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
|
||||||
destination: z.string().min(1),
|
destination: z.string().min(1),
|
||||||
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
||||||
alias: z.string().nullish(),
|
alias: z.string().nullish(),
|
||||||
roles: z.array(
|
roles: z
|
||||||
z.object({
|
.array(
|
||||||
id: z.string(),
|
z.object({
|
||||||
text: z.string()
|
id: z.string(),
|
||||||
})
|
text: z.string()
|
||||||
).optional(),
|
})
|
||||||
users: z.array(
|
)
|
||||||
z.object({
|
.optional(),
|
||||||
id: z.string(),
|
users: z
|
||||||
text: z.string()
|
.array(
|
||||||
})
|
z.object({
|
||||||
).optional(),
|
id: z.string(),
|
||||||
clients: z.array(
|
text: z.string()
|
||||||
z.object({
|
})
|
||||||
id: z.string(),
|
)
|
||||||
text: z.string()
|
.optional(),
|
||||||
})
|
clients: z
|
||||||
).optional()
|
.array(
|
||||||
})
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
text: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
// .refine(
|
// .refine(
|
||||||
// (data) => {
|
// (data) => {
|
||||||
// if (data.mode === "port") {
|
// if (data.mode === "port") {
|
||||||
@@ -146,14 +157,53 @@ export default function EditInternalResourceDialog({
|
|||||||
|
|
||||||
type FormData = z.infer<typeof formSchema>;
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]);
|
const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId }));
|
||||||
const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]);
|
|
||||||
const [allClients, setAllClients] = useState<{ id: string; text: string }[]>([]);
|
const allRoles = rolesResponse
|
||||||
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<number | null>(null);
|
.map((role) => ({
|
||||||
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<number | null>(null);
|
id: role.roleId.toString(),
|
||||||
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<number | null>(null);
|
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 [loadingRolesUsers, setLoadingRolesUsers] = useState(false);
|
||||||
const [hasMachineClients, setHasMachineClients] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@@ -173,140 +223,141 @@ export default function EditInternalResourceDialog({
|
|||||||
|
|
||||||
const mode = form.watch("mode");
|
const mode = form.watch("mode");
|
||||||
|
|
||||||
const fetchRolesAndUsers = async () => {
|
// const fetchRolesAndUsers = async () => {
|
||||||
setLoadingRolesUsers(true);
|
// setLoadingRolesUsers(true);
|
||||||
try {
|
// try {
|
||||||
const [
|
// const [
|
||||||
rolesResponse,
|
// // rolesResponse,
|
||||||
resourceRolesResponse,
|
// resourceRolesResponse,
|
||||||
usersResponse,
|
// usersResponse,
|
||||||
resourceUsersResponse,
|
// resourceUsersResponse,
|
||||||
clientsResponse
|
// clientsResponse
|
||||||
] = await Promise.all([
|
// ] = await Promise.all([
|
||||||
api.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`),
|
// // api.get<AxiosResponse<ListRolesResponse>>(
|
||||||
api.get<AxiosResponse<ListSiteResourceRolesResponse>>(
|
// // `/org/${orgId}/roles`
|
||||||
`/site-resource/${resource.id}/roles`
|
// // ),
|
||||||
),
|
// api.get<AxiosResponse<ListSiteResourceRolesResponse>>(
|
||||||
api.get<AxiosResponse<ListUsersResponse>>(`/org/${orgId}/users`),
|
// `/site-resource/${resource.id}/roles`
|
||||||
api.get<AxiosResponse<ListSiteResourceUsersResponse>>(
|
// ),
|
||||||
`/site-resource/${resource.id}/users`
|
// api.get<AxiosResponse<ListUsersResponse>>(
|
||||||
),
|
// `/org/${orgId}/users`
|
||||||
api.get<AxiosResponse<ListClientsResponse>>(`/org/${orgId}/clients?filter=machine&limit=1000`)
|
// ),
|
||||||
]);
|
// 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>>;
|
// let resourceClientsResponse: AxiosResponse<
|
||||||
try {
|
// AxiosResponse<ListSiteResourceClientsResponse>
|
||||||
resourceClientsResponse = await api.get<AxiosResponse<ListSiteResourceClientsResponse>>(
|
// >;
|
||||||
`/site-resource/${resource.id}/clients`
|
// try {
|
||||||
);
|
// resourceClientsResponse = await api.get<
|
||||||
} catch {
|
// AxiosResponse<ListSiteResourceClientsResponse>
|
||||||
resourceClientsResponse = {
|
// >(`/site-resource/${resource.id}/clients`);
|
||||||
data: {
|
// } catch {
|
||||||
data: {
|
// resourceClientsResponse = {
|
||||||
clients: []
|
// data: {
|
||||||
}
|
// data: {
|
||||||
},
|
// clients: []
|
||||||
status: 200,
|
// }
|
||||||
statusText: "OK",
|
// },
|
||||||
headers: {} as any,
|
// status: 200,
|
||||||
config: {} as any
|
// statusText: "OK",
|
||||||
} as any;
|
// headers: {} as any,
|
||||||
}
|
// config: {} as any
|
||||||
|
// } as any;
|
||||||
|
// }
|
||||||
|
|
||||||
setAllRoles(
|
// // setAllRoles(
|
||||||
rolesResponse.data.data.roles
|
// // rolesResponse.data.data.roles
|
||||||
.map((role) => ({
|
// // .map((role) => ({
|
||||||
id: role.roleId.toString(),
|
// // id: role.roleId.toString(),
|
||||||
text: role.name
|
// // text: role.name
|
||||||
}))
|
// // }))
|
||||||
.filter((role) => role.text !== "Admin")
|
// // .filter((role) => role.text !== "Admin")
|
||||||
);
|
// // );
|
||||||
|
|
||||||
form.setValue(
|
// form.setValue(
|
||||||
"roles",
|
// "roles",
|
||||||
resourceRolesResponse.data.data.roles
|
// resourceRolesResponse.data.data.roles
|
||||||
.map((i) => ({
|
// .map((i) => ({
|
||||||
id: i.roleId.toString(),
|
// id: i.roleId.toString(),
|
||||||
text: i.name
|
// text: i.name
|
||||||
}))
|
// }))
|
||||||
.filter((role) => role.text !== "Admin")
|
// .filter((role) => role.text !== "Admin")
|
||||||
);
|
// );
|
||||||
|
|
||||||
setAllUsers(
|
// form.setValue(
|
||||||
usersResponse.data.data.users.map((user) => ({
|
// "users",
|
||||||
id: user.id.toString(),
|
// resourceUsersResponse.data.data.users.map((i) => ({
|
||||||
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
// id: i.userId.toString(),
|
||||||
}))
|
// text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
||||||
);
|
// }))
|
||||||
|
// );
|
||||||
|
|
||||||
form.setValue(
|
// const machineClients = clientsResponse.data.data.clients
|
||||||
"users",
|
// .filter((client) => !client.userId)
|
||||||
resourceUsersResponse.data.data.users.map((i) => ({
|
// .map((client) => ({
|
||||||
id: i.userId.toString(),
|
// id: client.clientId.toString(),
|
||||||
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
// text: client.name
|
||||||
}))
|
// }));
|
||||||
);
|
|
||||||
|
|
||||||
const machineClients = clientsResponse.data.data.clients
|
// setAllClients(machineClients);
|
||||||
.filter((client) => !client.userId)
|
|
||||||
.map((client) => ({
|
|
||||||
id: client.clientId.toString(),
|
|
||||||
text: client.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
setAllClients(machineClients);
|
// const existingClients =
|
||||||
|
// resourceClientsResponse.data.data.clients.map(
|
||||||
const existingClients = resourceClientsResponse.data.data.clients.map((c: { clientId: number; name: string }) => ({
|
// (c: { clientId: number; name: string }) => ({
|
||||||
id: c.clientId.toString(),
|
// id: c.clientId.toString(),
|
||||||
text: c.name
|
// text: c.name
|
||||||
}));
|
// })
|
||||||
|
// );
|
||||||
|
|
||||||
form.setValue("clients", existingClients);
|
// form.setValue("clients", existingClients);
|
||||||
|
|
||||||
// Show clients tag input if there are machine clients OR existing client access
|
// // Show clients tag input if there are machine clients OR existing client access
|
||||||
setHasMachineClients(machineClients.length > 0 || existingClients.length > 0);
|
// setHasMachineClients(
|
||||||
} catch (error) {
|
// machineClients.length > 0 || existingClients.length > 0
|
||||||
console.error("Error fetching roles, users, and clients:", error);
|
// );
|
||||||
} finally {
|
// } catch (error) {
|
||||||
setLoadingRolesUsers(false);
|
// console.error("Error fetching roles, users, and clients:", error);
|
||||||
}
|
// } finally {
|
||||||
};
|
// setLoadingRolesUsers(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
if (open) {
|
// if (open) {
|
||||||
form.reset({
|
// fetchRolesAndUsers();
|
||||||
name: resource.name,
|
// }
|
||||||
mode: resource.mode || "host",
|
// }, [open, resource]);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: FormData) => {
|
const handleSubmit = async (data: FormData) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
// Update the site resource
|
// Update the site resource
|
||||||
await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, {
|
await api.post(
|
||||||
name: data.name,
|
`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`,
|
||||||
mode: data.mode,
|
{
|
||||||
// protocol: data.mode === "port" ? data.protocol : null,
|
name: data.name,
|
||||||
// proxyPort: data.mode === "port" ? data.proxyPort : null,
|
mode: data.mode,
|
||||||
// destinationPort: data.mode === "port" ? data.destinationPort : null,
|
// protocol: data.mode === "port" ? data.protocol : null,
|
||||||
destination: data.destination,
|
// proxyPort: data.mode === "port" ? data.proxyPort : null,
|
||||||
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : null,
|
// destinationPort: data.mode === "port" ? data.destinationPort : null,
|
||||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
destination: data.destination,
|
||||||
userIds: (data.users || []).map((u) => u.id),
|
alias:
|
||||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
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
|
// Update roles, users, and clients
|
||||||
// await Promise.all([
|
// await Promise.all([
|
||||||
@@ -323,7 +374,9 @@ export default function EditInternalResourceDialog({
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("editInternalResourceDialogSuccess"),
|
title: t("editInternalResourceDialogSuccess"),
|
||||||
description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"),
|
description: t(
|
||||||
|
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||||
|
),
|
||||||
variant: "default"
|
variant: "default"
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,7 +386,12 @@ export default function EditInternalResourceDialog({
|
|||||||
console.error("Error updating internal resource:", error);
|
console.error("Error updating internal resource:", error);
|
||||||
toast({
|
toast({
|
||||||
title: t("editInternalResourceDialogError"),
|
title: t("editInternalResourceDialogError"),
|
||||||
description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")),
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t(
|
||||||
|
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||||
|
)
|
||||||
|
),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -342,27 +400,64 @@ export default function EditInternalResourceDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<CredenzaContent className="max-w-2xl">
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>{t("editInternalResourceDialogEditClientResource")}</CredenzaTitle>
|
<CredenzaTitle>
|
||||||
|
{t("editInternalResourceDialogEditClientResource")}
|
||||||
|
</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
{t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })}
|
{t(
|
||||||
|
"editInternalResourceDialogUpdateResourceProperties",
|
||||||
|
{ resourceName: resource.name }
|
||||||
|
)}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<Form {...form}>
|
<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 */}
|
{/* Resource Properties Form */}
|
||||||
<div>
|
<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">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("editInternalResourceDialogName")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogName"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -376,9 +471,15 @@ export default function EditInternalResourceDialog({
|
|||||||
name="mode"
|
name="mode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("editInternalResourceDialogMode")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogMode"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -388,8 +489,16 @@ export default function EditInternalResourceDialog({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{/* <SelectItem value="port">{t("editInternalResourceDialogModePort")}</SelectItem> */}
|
{/* <SelectItem value="port">{t("editInternalResourceDialogModePort")}</SelectItem> */}
|
||||||
<SelectItem value="host">{t("editInternalResourceDialogModeHost")}</SelectItem>
|
<SelectItem value="host">
|
||||||
<SelectItem value="cidr">{t("editInternalResourceDialogModeCidr")}</SelectItem>
|
{t(
|
||||||
|
"editInternalResourceDialogModeHost"
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="cidr">
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogModeCidr"
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -448,20 +557,34 @@ export default function EditInternalResourceDialog({
|
|||||||
|
|
||||||
{/* Target Configuration Form */}
|
{/* Target Configuration Form */}
|
||||||
<div>
|
<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">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="destination"
|
name="destination"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("editInternalResourceDialogDestination")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogDestination"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{mode === "host" && t("editInternalResourceDialogDestinationHostDescription")}
|
{mode === "host" &&
|
||||||
{mode === "cidr" && t("editInternalResourceDialogDestinationCidrDescription")}
|
t(
|
||||||
|
"editInternalResourceDialogDestinationHostDescription"
|
||||||
|
)}
|
||||||
|
{mode === "cidr" &&
|
||||||
|
t(
|
||||||
|
"editInternalResourceDialogDestinationCidrDescription"
|
||||||
|
)}
|
||||||
{/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */}
|
{/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -499,12 +622,23 @@ export default function EditInternalResourceDialog({
|
|||||||
name="alias"
|
name="alias"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("editInternalResourceDialogAlias")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogAlias"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} value={field.value ?? ""} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
value={
|
||||||
|
field.value ?? ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("editInternalResourceDialogAliasDescription")}
|
{t(
|
||||||
|
"editInternalResourceDialogAliasDescription"
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -529,31 +663,57 @@ export default function EditInternalResourceDialog({
|
|||||||
name="roles"
|
name="roles"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("roles")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("roles")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={activeRolesTagIndex}
|
activeTagIndex={
|
||||||
setActiveTagIndex={setActiveRolesTagIndex}
|
activeRolesTagIndex
|
||||||
placeholder={t("accessRoleSelect2")}
|
}
|
||||||
|
setActiveTagIndex={
|
||||||
|
setActiveRolesTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"accessRoleSelect2"
|
||||||
|
)}
|
||||||
size="sm"
|
size="sm"
|
||||||
tags={form.getValues().roles || []}
|
tags={
|
||||||
setTags={(newRoles) => {
|
form.getValues()
|
||||||
|
.roles || []
|
||||||
|
}
|
||||||
|
setTags={(
|
||||||
|
newRoles
|
||||||
|
) => {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"roles",
|
"roles",
|
||||||
newRoles as [Tag, ...Tag[]]
|
newRoles as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
enableAutocomplete={true}
|
enableAutocomplete={
|
||||||
autocompleteOptions={allRoles}
|
true
|
||||||
allowDuplicates={false}
|
}
|
||||||
restrictTagsToAutocompleteOptions={true}
|
autocompleteOptions={
|
||||||
|
allRoles
|
||||||
|
}
|
||||||
|
allowDuplicates={
|
||||||
|
false
|
||||||
|
}
|
||||||
|
restrictTagsToAutocompleteOptions={
|
||||||
|
true
|
||||||
|
}
|
||||||
sortTags={true}
|
sortTags={true}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("resourceRoleDescription")}
|
{t(
|
||||||
|
"resourceRoleDescription"
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -563,25 +723,49 @@ export default function EditInternalResourceDialog({
|
|||||||
name="users"
|
name="users"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("users")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("users")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={activeUsersTagIndex}
|
activeTagIndex={
|
||||||
setActiveTagIndex={setActiveUsersTagIndex}
|
activeUsersTagIndex
|
||||||
placeholder={t("accessUserSelect")}
|
}
|
||||||
tags={form.getValues().users || []}
|
setActiveTagIndex={
|
||||||
|
setActiveUsersTagIndex
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"accessUserSelect"
|
||||||
|
)}
|
||||||
|
tags={
|
||||||
|
form.getValues()
|
||||||
|
.users || []
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
setTags={(newUsers) => {
|
setTags={(
|
||||||
|
newUsers
|
||||||
|
) => {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"users",
|
"users",
|
||||||
newUsers as [Tag, ...Tag[]]
|
newUsers as [
|
||||||
|
Tag,
|
||||||
|
...Tag[]
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
enableAutocomplete={true}
|
enableAutocomplete={
|
||||||
autocompleteOptions={allUsers}
|
true
|
||||||
allowDuplicates={false}
|
}
|
||||||
restrictTagsToAutocompleteOptions={true}
|
autocompleteOptions={
|
||||||
|
allUsers
|
||||||
|
}
|
||||||
|
allowDuplicates={
|
||||||
|
false
|
||||||
|
}
|
||||||
|
restrictTagsToAutocompleteOptions={
|
||||||
|
true
|
||||||
|
}
|
||||||
sortTags={true}
|
sortTags={true}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -589,42 +773,73 @@ export default function EditInternalResourceDialog({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{hasMachineClients && (
|
{hasMachineClients && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="clients"
|
name="clients"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("clients")}</FormLabel>
|
<FormLabel>
|
||||||
<FormControl>
|
{t("clients")}
|
||||||
<TagInput
|
</FormLabel>
|
||||||
{...field}
|
<FormControl>
|
||||||
activeTagIndex={activeClientsTagIndex}
|
<TagInput
|
||||||
setActiveTagIndex={setActiveClientsTagIndex}
|
{...field}
|
||||||
placeholder={t("accessClientSelect") || "Select machine clients"}
|
activeTagIndex={
|
||||||
size="sm"
|
activeClientsTagIndex
|
||||||
tags={form.getValues().clients || []}
|
}
|
||||||
setTags={(newClients) => {
|
setActiveTagIndex={
|
||||||
form.setValue(
|
setActiveClientsTagIndex
|
||||||
"clients",
|
}
|
||||||
newClients as [Tag, ...Tag[]]
|
placeholder={
|
||||||
);
|
t(
|
||||||
}}
|
"accessClientSelect"
|
||||||
enableAutocomplete={true}
|
) ||
|
||||||
autocompleteOptions={allClients}
|
"Select machine clients"
|
||||||
allowDuplicates={false}
|
}
|
||||||
restrictTagsToAutocompleteOptions={true}
|
size="sm"
|
||||||
sortTags={true}
|
tags={
|
||||||
/>
|
form.getValues()
|
||||||
</FormControl>
|
.clients ||
|
||||||
<FormMessage />
|
[]
|
||||||
<FormDescription>
|
}
|
||||||
{t("resourceClientDescription") || "Machine clients that can access this resource"}
|
setTags={(
|
||||||
</FormDescription>
|
newClients
|
||||||
</FormItem>
|
) => {
|
||||||
)}
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -645,7 +860,7 @@ export default function EditInternalResourceDialog({
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{t("editInternalResourceDialogSaveResource")}
|
{t("editInternalResourceDialogSaveResource")}
|
||||||
</Button>
|
</Button>
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
|
|||||||
700
src/components/MachineClientsTable.tsx
Normal file
700
src/components/MachineClientsTable.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
830
src/components/ProxyResourcesTable.tsx
Normal file
830
src/components/ProxyResourcesTable.tsx
Normal 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
@@ -3,19 +3,29 @@ import * as React from "react";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
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 = {
|
export type ReactQueryProviderProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ReactQueryProvider({ children }: ReactQueryProviderProps) {
|
export function TanstackQueryProvider({ children }: ReactQueryProviderProps) {
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
const [queryClient] = React.useState(
|
const [queryClient] = React.useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
retry: 2, // retry twice by default
|
retry: 2, // retry twice by default
|
||||||
staleTime: 5 * 60 * 1_000 // 5 minutes
|
staleTime: durationToMs(5, "minutes"),
|
||||||
|
meta: {
|
||||||
|
api
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
meta: { api }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
679
src/components/UserDevicesTable.tsx
Normal file
679
src/components/UserDevicesTable.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
Dispatch,
|
type Dispatch,
|
||||||
SetStateAction
|
type SetStateAction
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
type SetValue<T> = Dispatch<SetStateAction<T>>;
|
type SetValue<T> = Dispatch<SetStateAction<T>>;
|
||||||
|
|||||||
81
src/hooks/useStoredColumnVisibility.ts
Normal file
81
src/hooks/useStoredColumnVisibility.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { VisibilityState } from "@tanstack/react-table";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
COLUMN_VISIBILITY: "datatable-column-visibility",
|
||||||
|
getTableColumnVisibility: (tableId: string) =>
|
||||||
|
`datatable-${tableId}-column-visibility`
|
||||||
|
};
|
||||||
|
|
||||||
|
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 function useStoredColumnVisibility(
|
||||||
|
tableId: string,
|
||||||
|
defaultColumnVisibility?: Record<string, boolean>
|
||||||
|
) {
|
||||||
|
const [columnVisibility, setVisibility] = useState<VisibilityState>(() =>
|
||||||
|
getStoredColumnVisibility(tableId, defaultColumnVisibility)
|
||||||
|
);
|
||||||
|
|
||||||
|
const setColumnVisibility = useCallback(
|
||||||
|
(
|
||||||
|
updaterOrValue:
|
||||||
|
| VisibilityState
|
||||||
|
| ((old: VisibilityState) => VisibilityState)
|
||||||
|
) => {
|
||||||
|
if (typeof updaterOrValue === "function") {
|
||||||
|
setVisibility((oldValue) => {
|
||||||
|
const newValue = updaterOrValue(oldValue);
|
||||||
|
setStoredColumnVisibility(newValue, tableId);
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setVisibility(updaterOrValue);
|
||||||
|
setStoredColumnVisibility(updaterOrValue, tableId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tableId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [columnVisibility, setColumnVisibility] as const;
|
||||||
|
}
|
||||||
60
src/hooks/useStoredPageSize.ts
Normal file
60
src/hooks/useStoredPageSize.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
PAGE_SIZE: "datatable-page-size",
|
||||||
|
getTablePageSize: (tableId: string) => `datatable-${tableId}-page-size`
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// export function useStore
|
||||||
|
export function useStoredPageSize(tableId: string, defaultPageSize?: number) {
|
||||||
|
const [pageSize, setSize] = useState(() =>
|
||||||
|
getStoredPageSize(tableId, defaultPageSize)
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPageSize = useCallback(
|
||||||
|
(updaterOrValue: number | ((old: number) => number)) => {
|
||||||
|
if (typeof updaterOrValue === "function") {
|
||||||
|
setSize((oldValue) => {
|
||||||
|
const newValue = updaterOrValue(oldValue);
|
||||||
|
setStoredPageSize(newValue, tableId);
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSize(updaterOrValue);
|
||||||
|
setStoredPageSize(updaterOrValue, tableId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tableId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [pageSize, setPageSize] as const;
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
|
||||||
import { durationToMs } from "./durationToMs";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { remote } from "./api";
|
import type { ListClientsResponse } from "@server/routers/client";
|
||||||
|
import type { ListRolesResponse } from "@server/routers/role";
|
||||||
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
|
import type {
|
||||||
|
ListSiteResourceRolesResponse,
|
||||||
|
ListSiteResourceUsersResponse
|
||||||
|
} from "@server/routers/siteResource";
|
||||||
|
import type { ListUsersResponse } from "@server/routers/user";
|
||||||
import type ResponseT from "@server/types/Response";
|
import type ResponseT from "@server/types/Response";
|
||||||
|
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import z from "zod";
|
||||||
|
import { remote } from "./api";
|
||||||
|
import { durationToMs } from "./durationToMs";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -65,3 +75,90 @@ export const productUpdatesQueries = {
|
|||||||
// because we don't need to listen for new versions there
|
// because we don't need to listen for new versions there
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clientFilterSchema = z.object({
|
||||||
|
filter: z.enum(["machine", "user"]),
|
||||||
|
limit: z.int().prefault(1000).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const orgQueries = {
|
||||||
|
clients: ({
|
||||||
|
orgId,
|
||||||
|
filters
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
filters: z.infer<typeof clientFilterSchema>;
|
||||||
|
}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ORG", orgId, "CLIENTS", filters] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const sp = new URLSearchParams({
|
||||||
|
...filters,
|
||||||
|
limit: (filters.limit ?? 1000).toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListClientsResponse>
|
||||||
|
>(`/org/${orgId}/clients?${sp.toString()}`, { signal });
|
||||||
|
|
||||||
|
return res.data.data.clients;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
users: ({ orgId }: { orgId: string }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ORG", orgId, "USERS"] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListUsersResponse>
|
||||||
|
>(`/org/${orgId}/users`, { signal });
|
||||||
|
|
||||||
|
return res.data.data.users;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
roles: ({ orgId }: { orgId: string }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ORG", orgId, "ROLES"] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListRolesResponse>
|
||||||
|
>(`/org/${orgId}/roles`, { signal });
|
||||||
|
|
||||||
|
return res.data.data.roles;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
sites: ({ orgId }: { orgId: string }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ORG", orgId, "SITES"] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListSitesResponse>
|
||||||
|
>(`/org/${orgId}/sites`, { signal });
|
||||||
|
return res.data.data.sites;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resourceQueries = {
|
||||||
|
resourceUsers: ({ resourceId }: { resourceId: number }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["RESOURCES", resourceId, "USERS"] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListSiteResourceUsersResponse>
|
||||||
|
>(`/site-resource/${resourceId}/users`, { signal });
|
||||||
|
return res.data.data.users;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
resourceRoles: ({ resourceId }: { resourceId: number }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["RESOURCES", resourceId, "ROLES"] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListSiteResourceRolesResponse>
|
||||||
|
>(`/site-resource/${resourceId}/roles`, { signal });
|
||||||
|
|
||||||
|
return res.data.data.roles;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|||||||
13
src/types/tanstack-query.d.ts
vendored
Normal file
13
src/types/tanstack-query.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import "@tanstack/react-query";
|
||||||
|
import type { AxiosInstance } from "axios";
|
||||||
|
|
||||||
|
interface Meta extends Record<string, unknown> {
|
||||||
|
api: AxiosInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tanstack/react-query" {
|
||||||
|
interface Register {
|
||||||
|
queryMeta: Meta;
|
||||||
|
mutationMeta: Meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user