mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-19 11:26:37 +00:00
Merge branch 'dev' of github.com:fosrl/pangolin into dev
This commit is contained in:
@@ -7,10 +7,11 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListClientsResponse } from "@server/routers/client";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Pagination } from "@server/types/Pagination";
|
||||
|
||||
type ClientsPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -19,17 +20,25 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
const t = await getTranslations();
|
||||
|
||||
const params = await props.params;
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let machineClients: ListClientsResponse["clients"] = [];
|
||||
let pagination: Pagination = {
|
||||
page: 1,
|
||||
total: 0,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const machineRes = await internal.get<
|
||||
AxiosResponse<ListClientsResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/clients?filter=machine`,
|
||||
`/org/${params.orgId}/clients?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
machineClients = machineRes.data.data.clients;
|
||||
const responseData = machineRes.data.data;
|
||||
machineClients = responseData.clients;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
function formatSize(mb: number): string {
|
||||
@@ -80,6 +89,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
<MachineClientsTable
|
||||
machineClients={machineClientRows}
|
||||
orgId={params.orgId}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -602,7 +602,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.biometricsEnabled
|
||||
.biometricsEnabled ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -622,7 +623,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.diskEncrypted
|
||||
.diskEncrypted ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -642,7 +644,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.firewallEnabled
|
||||
.firewallEnabled ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -663,7 +666,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.autoUpdatesEnabled
|
||||
.autoUpdatesEnabled ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -683,7 +687,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.tpmAvailable
|
||||
.tpmAvailable ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -707,7 +712,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.windowsAntivirusEnabled
|
||||
.windowsAntivirusEnabled ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -727,7 +733,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.macosSipEnabled
|
||||
.macosSipEnabled ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -751,7 +758,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.macosGatekeeperEnabled
|
||||
.macosGatekeeperEnabled ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -775,7 +783,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.macosFirewallStealthMode
|
||||
.macosFirewallStealthMode ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -796,7 +805,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.linuxAppArmorEnabled
|
||||
.linuxAppArmorEnabled ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
@@ -817,7 +827,8 @@ export default function GeneralPage() {
|
||||
)
|
||||
? formatPostureValue(
|
||||
client.posture
|
||||
.linuxSELinuxEnabled
|
||||
.linuxSELinuxEnabled ===
|
||||
true
|
||||
)
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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/UserDevicesTable";
|
||||
import UserDevicesTable from "@app/components/UserDevicesTable";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { type ListUserDevicesResponse } from "@server/routers/client";
|
||||
import type { Pagination } from "@server/types/Pagination";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type ClientsPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -17,15 +19,26 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
const t = await getTranslations();
|
||||
|
||||
const params = await props.params;
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let userClients: ListClientsResponse["clients"] = [];
|
||||
let userClients: ListUserDevicesResponse["devices"] = [];
|
||||
|
||||
let pagination: Pagination = {
|
||||
page: 1,
|
||||
total: 0,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>(
|
||||
`/org/${params.orgId}/clients?filter=user`,
|
||||
const userRes = await internal.get<
|
||||
AxiosResponse<ListUserDevicesResponse>
|
||||
>(
|
||||
`/org/${params.orgId}/user-devices?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
userClients = userRes.data.data.clients;
|
||||
const responseData = userRes.data.data;
|
||||
userClients = responseData.devices;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
function formatSize(mb: number): string {
|
||||
@@ -39,31 +52,29 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
}
|
||||
|
||||
const mapClientToRow = (
|
||||
client: ListClientsResponse["clients"][0]
|
||||
client: ListUserDevicesResponse["devices"][number]
|
||||
): ClientRow => {
|
||||
// Build fingerprint object if any fingerprint data exists
|
||||
const hasFingerprintData =
|
||||
(client as any).fingerprintPlatform ||
|
||||
(client as any).fingerprintOsVersion ||
|
||||
(client as any).fingerprintKernelVersion ||
|
||||
(client as any).fingerprintArch ||
|
||||
(client as any).fingerprintSerialNumber ||
|
||||
(client as any).fingerprintUsername ||
|
||||
(client as any).fingerprintHostname ||
|
||||
(client as any).deviceModel;
|
||||
client.fingerprintPlatform ||
|
||||
client.fingerprintOsVersion ||
|
||||
client.fingerprintKernelVersion ||
|
||||
client.fingerprintArch ||
|
||||
client.fingerprintSerialNumber ||
|
||||
client.fingerprintUsername ||
|
||||
client.fingerprintHostname ||
|
||||
client.deviceModel;
|
||||
|
||||
const fingerprint = hasFingerprintData
|
||||
? {
|
||||
platform: (client as any).fingerprintPlatform || null,
|
||||
osVersion: (client as any).fingerprintOsVersion || null,
|
||||
kernelVersion:
|
||||
(client as any).fingerprintKernelVersion || null,
|
||||
arch: (client as any).fingerprintArch || null,
|
||||
deviceModel: (client as any).deviceModel || null,
|
||||
serialNumber:
|
||||
(client as any).fingerprintSerialNumber || null,
|
||||
username: (client as any).fingerprintUsername || null,
|
||||
hostname: (client as any).fingerprintHostname || null
|
||||
platform: client.fingerprintPlatform,
|
||||
osVersion: client.fingerprintOsVersion,
|
||||
kernelVersion: client.fingerprintKernelVersion,
|
||||
arch: client.fingerprintArch,
|
||||
deviceModel: client.deviceModel,
|
||||
serialNumber: client.fingerprintSerialNumber,
|
||||
username: client.fingerprintUsername,
|
||||
hostname: client.fingerprintHostname
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -71,19 +82,19 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
name: client.name,
|
||||
id: client.clientId,
|
||||
subnet: client.subnet.split("/")[0],
|
||||
mbIn: formatSize(client.megabytesIn || 0),
|
||||
mbOut: formatSize(client.megabytesOut || 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,
|
||||
olmUpdateAvailable: Boolean(client.olmUpdateAvailable),
|
||||
userId: client.userId,
|
||||
username: client.username,
|
||||
userEmail: client.userEmail,
|
||||
niceId: client.niceId,
|
||||
agent: client.agent,
|
||||
archived: client.archived || false,
|
||||
blocked: client.blocked || false,
|
||||
archived: Boolean(client.archived),
|
||||
blocked: Boolean(client.blocked),
|
||||
approvalState: client.approvalState,
|
||||
fingerprint
|
||||
};
|
||||
@@ -101,6 +112,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
<UserDevicesTable
|
||||
userClients={userClientRows}
|
||||
orgId={params.orgId}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { redirect } from "next/navigation";
|
||||
|
||||
export interface ClientResourcesPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
export default async function ClientResourcesPage(
|
||||
@@ -22,22 +22,24 @@ export default async function ClientResourcesPage(
|
||||
) {
|
||||
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) {}
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
let pagination: ListResourcesResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
||||
siteResources = res.data.data.siteResources;
|
||||
>(
|
||||
`/org/${params.orgId}/site-resources?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
siteResources = responseData.siteResources;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
let org = null;
|
||||
@@ -89,9 +91,10 @@ export default async function ClientResourcesPage(
|
||||
<ClientResourcesTable
|
||||
internalResources={internalResourceRows}
|
||||
orgId={params.orgId}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</OrgProvider>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { cache } from "react";
|
||||
|
||||
export interface ProxyResourcesPageProps {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
export default async function ProxyResourcesPage(
|
||||
@@ -24,14 +24,22 @@ export default async function ProxyResourcesPage(
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
let pagination: ListResourcesResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
`/org/${params.orgId}/resources`,
|
||||
`/org/${params.orgId}/resources?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
resources = res.data.data.resources;
|
||||
const responseData = res.data.data;
|
||||
resources = responseData.resources;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
@@ -104,9 +112,10 @@ export default async function ProxyResourcesPage(
|
||||
<ProxyResourcesTable
|
||||
resources={resourceRows}
|
||||
orgId={params.orgId}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</OrgProvider>
|
||||
|
||||
@@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server";
|
||||
|
||||
type SitesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SitesPage(props: SitesPageProps) {
|
||||
const params = await props.params;
|
||||
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let sites: ListSitesResponse["sites"] = [];
|
||||
let pagination: ListSitesResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${params.orgId}/sites`,
|
||||
`/org/${params.orgId}/sites?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
sites = res.data.data.sites;
|
||||
const responseData = res.data.data;
|
||||
sites = responseData.sites;
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
const t = await getTranslations();
|
||||
@@ -60,8 +71,6 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <SitesSplashCard /> */}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title={t("siteManageSites")}
|
||||
description={t("siteDescription")}
|
||||
@@ -69,7 +78,15 @@ export default async function SitesPage(props: SitesPageProps) {
|
||||
|
||||
<SitesBanner />
|
||||
|
||||
<SitesTable sites={siteRows} orgId={params.orgId} />
|
||||
<SitesTable
|
||||
sites={siteRows}
|
||||
orgId={params.orgId}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
74
src/app/auth/delete-account/DeleteAccountClient.tsx
Normal file
74
src/app/auth/delete-account/DeleteAccountClient.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog";
|
||||
import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
|
||||
type DeleteAccountClientProps = {
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export default function DeleteAccountClient({
|
||||
displayName
|
||||
}: DeleteAccountClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
function handleUseDifferentAccount() {
|
||||
api.post("/auth/logout")
|
||||
.catch((e) => {
|
||||
console.error(t("logoutError"), e);
|
||||
toast({
|
||||
title: t("logoutError"),
|
||||
description: formatAxiosError(e, t("logoutError"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.push(
|
||||
"/auth/login?internal_redirect=/auth/delete-account"
|
||||
);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserProfileCard
|
||||
identifier={displayName}
|
||||
description={t("signingAs")}
|
||||
onUseDifferentAccount={handleUseDifferentAccount}
|
||||
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("deleteAccountDescription")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<DeleteAccountConfirmDialog
|
||||
open={isDialogOpen}
|
||||
setOpen={setIsDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/auth/delete-account/page.tsx
Normal file
28
src/app/auth/delete-account/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { build } from "@server/build";
|
||||
import { cache } from "react";
|
||||
import DeleteAccountClient from "./DeleteAccountClient";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DeleteAccountPage() {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
const displayName = getUserDisplayName({ user });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold">{t("deleteAccount")}</h1>
|
||||
<DeleteAccountClient displayName={displayName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
|
||||
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||
|
||||
type ApplyInternalRedirectProps = {
|
||||
orgId: string;
|
||||
@@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const path = consumeInternalRedirectPath();
|
||||
if (path) {
|
||||
router.replace(`/${orgId}${path}`);
|
||||
const target = getInternalRedirectTarget(orgId);
|
||||
if (target) {
|
||||
router.replace(target);
|
||||
}
|
||||
}, [orgId, router]);
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import {
|
||||
approvalFiltersSchema,
|
||||
approvalQueries,
|
||||
type ApprovalItem
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Ban, Check, Loader, RefreshCw } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -54,12 +54,20 @@ export function ApprovalFeed({
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage
|
||||
} = useInfiniteQuery({
|
||||
...approvalQueries.listApprovals(orgId, filters),
|
||||
enabled: isPaidUser(tierMatrix.deviceApprovals)
|
||||
});
|
||||
|
||||
const approvals = data?.approvals ?? [];
|
||||
const approvals = data?.pages.flatMap((data) => data.approvals) ?? [];
|
||||
|
||||
// Show empty state if no approvals are enabled for any role
|
||||
if (!hasApprovalsEnabled) {
|
||||
@@ -115,13 +123,13 @@ export function ApprovalFeed({
|
||||
onClick={() => {
|
||||
refetch();
|
||||
}}
|
||||
disabled={isFetching}
|
||||
disabled={isFetching || isLoading}
|
||||
className="lg:static gap-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"size-4",
|
||||
isFetching && "animate-spin"
|
||||
(isFetching || isLoading) && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
{t("refresh")}
|
||||
@@ -145,13 +153,30 @@ export function ApprovalFeed({
|
||||
))}
|
||||
|
||||
{approvals.length === 0 && (
|
||||
<li className="flex justify-center items-center p-4 text-muted-foreground">
|
||||
{t("approvalListEmpty")}
|
||||
<li className="flex justify-center items-center p-4 text-muted-foreground gap-2">
|
||||
{isLoading
|
||||
? t("loadingApprovals")
|
||||
: t("approvalListEmpty")}
|
||||
|
||||
{isLoading && (
|
||||
<Loader className="size-4 flex-none animate-spin" />
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="self-center"
|
||||
size="lg"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
{t("approvalLoadMore")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { startTransition, useActionState, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -13,6 +9,11 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import z from "zod";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
@@ -21,19 +22,19 @@ import {
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "./Settings";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||
import { Input } from "./ui/input";
|
||||
import { ExternalLink, InfoIcon, XIcon } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
@@ -45,13 +46,36 @@ export type AuthPageCustomizationProps = {
|
||||
const AuthPageFormSchema = z.object({
|
||||
logoUrl: z.union([
|
||||
z.literal(""),
|
||||
z.url("Must be a valid URL").superRefine(async (url, ctx) => {
|
||||
z.string().superRefine(async (urlOrPath, ctx) => {
|
||||
const parseResult = z.url().safeParse(urlOrPath);
|
||||
if (!parseResult.success) {
|
||||
if (build !== "enterprise") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be a valid URL"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
validateLocalPath(urlOrPath);
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||
});
|
||||
} finally {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(urlOrPath, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(url, { method: "GET" });
|
||||
return fetch(urlOrPath, { method: "GET" });
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
@@ -271,12 +295,25 @@ export default function AuthPageBrandingForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t("brandingLogoURL")}
|
||||
{build === "enterprise"
|
||||
? t(
|
||||
"brandingLogoURLOrPath"
|
||||
)
|
||||
: t("brandingLogoURL")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{build === "enterprise"
|
||||
? t(
|
||||
"brandingLogoPathDescription"
|
||||
)
|
||||
: t(
|
||||
"brandingLogoURLDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,11 @@ import CreateInternalResourceDialog from "@app/components/CreateInternalResource
|
||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
@@ -51,18 +56,22 @@ export type InternalResourceRow = {
|
||||
type ClientResourcesTableProps = {
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
defaultSort?: {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
};
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function ClientResourcesTable({
|
||||
internalResources,
|
||||
orgId,
|
||||
defaultSort
|
||||
pagination,
|
||||
rowCount
|
||||
}: ClientResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
@@ -122,19 +131,7 @@ export default function ClientResourcesTable({
|
||||
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>
|
||||
);
|
||||
}
|
||||
header: () => <span className="p-3">{t("name")}</span>
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
@@ -180,9 +177,24 @@ export default function ClientResourcesTable({
|
||||
accessorKey: "mode",
|
||||
friendlyName: t("editInternalResourceDialogMode"),
|
||||
header: () => (
|
||||
<span className="p-3">
|
||||
{t("editInternalResourceDialogMode")}
|
||||
</span>
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "host",
|
||||
label: t("editInternalResourceDialogModeHost")
|
||||
},
|
||||
{
|
||||
value: "cidr",
|
||||
label: t("editInternalResourceDialogModeCidr")
|
||||
}
|
||||
]}
|
||||
selectedValue={searchParams.get("mode") ?? undefined}
|
||||
onValueChange={(value) => handleFilterChange("mode", value)}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("editInternalResourceDialogMode")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
@@ -300,6 +312,37 @@ export default function ClientResourcesTable({
|
||||
}
|
||||
];
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
value: string | undefined | null
|
||||
) {
|
||||
searchParams.delete(column);
|
||||
searchParams.delete("page");
|
||||
|
||||
if (value) {
|
||||
searchParams.set(column, value);
|
||||
}
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedInternalResource && (
|
||||
@@ -327,19 +370,20 @@ export default function ClientResourcesTable({
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
<ControlledDataTable
|
||||
columns={internalColumns}
|
||||
data={internalResources}
|
||||
persistPageSize="internal-resources"
|
||||
rows={internalResources}
|
||||
tableId="internal-resources"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchColumn="name"
|
||||
onAdd={() => setIsCreateDialogOpen(true)}
|
||||
addButtonText={t("resourceAdd")}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
defaultSort={defaultSort}
|
||||
enableColumnVisibility={true}
|
||||
persistColumnVisibility="internal-resources"
|
||||
onPaginationChange={handlePaginationChange}
|
||||
pagination={pagination}
|
||||
rowCount={rowCount}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
enableColumnVisibility
|
||||
columnVisibility={{
|
||||
niceId: false,
|
||||
aliasAddress: false
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@app/components/ui/command";
|
||||
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
@@ -61,16 +62,19 @@ export function ColumnFilter({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span className="truncate">
|
||||
{selectedOption
|
||||
? selectedOption.label
|
||||
: placeholder}
|
||||
</span>
|
||||
|
||||
{selectedOption && (
|
||||
<Badge className="truncate" variant="secondary">
|
||||
{selectedOption
|
||||
? selectedOption.label
|
||||
: placeholder}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[200px]" align="start">
|
||||
<PopoverContent className="p-0 w-50" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
|
||||
126
src/components/ColumnFilterButton.tsx
Normal file
126
src/components/ColumnFilterButton.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ColumnFilterButtonProps {
|
||||
options: FilterOption[];
|
||||
selectedValue?: string;
|
||||
onValueChange: (value: string | undefined) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function ColumnFilterButton({
|
||||
options,
|
||||
selectedValue,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No options found",
|
||||
className,
|
||||
label
|
||||
}: ColumnFilterButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedOption = options.find(
|
||||
(option) => option.value === selectedValue
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2",
|
||||
!selectedValue && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{label}
|
||||
|
||||
<Funnel className="size-4 flex-none" />
|
||||
|
||||
{selectedOption && (
|
||||
<Badge className="truncate" variant="secondary">
|
||||
{selectedOption.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-50" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{/* Clear filter option */}
|
||||
{selectedValue && (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
onValueChange(undefined);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Clear filter
|
||||
</CommandItem>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
onValueChange(
|
||||
selectedValue === option.value
|
||||
? undefined
|
||||
: option.value
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValue === option.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -255,10 +255,7 @@ export default function CreateInternalResourceDialog({
|
||||
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
|
||||
const { data: clientsResponse = [] } = useQuery(
|
||||
orgQueries.clients({
|
||||
orgId,
|
||||
filters: {
|
||||
filter: "machine"
|
||||
}
|
||||
orgId
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
414
src/components/DeleteAccountConfirmDialog.tsx
Normal file
414
src/components/DeleteAccountConfirmDialog.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import type {
|
||||
DeleteMyAccountPreviewResponse,
|
||||
DeleteMyAccountCodeRequestedResponse,
|
||||
DeleteMyAccountSuccessResponse
|
||||
} from "@server/routers/auth/deleteMyAccount";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
type DeleteAccountConfirmDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export default function DeleteAccountConfirmDialog({
|
||||
open,
|
||||
setOpen
|
||||
}: DeleteAccountConfirmDialogProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const passwordSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
password: z.string().min(1, { message: t("passwordRequired") })
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const codeSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<0 | 1 | 2>(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [preview, setPreview] =
|
||||
useState<DeleteMyAccountPreviewResponse | null>(null);
|
||||
const [passwordValue, setPasswordValue] = useState("");
|
||||
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: { password: "" }
|
||||
});
|
||||
|
||||
const codeForm = useForm<z.infer<typeof codeSchema>>({
|
||||
resolver: zodResolver(codeSchema),
|
||||
defaultValues: { code: "" }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open && step === 0 && !preview) {
|
||||
setLoadingPreview(true);
|
||||
api.post<AxiosResponse<DeleteMyAccountPreviewResponse>>(
|
||||
"/auth/delete-my-account",
|
||||
{}
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.data?.data?.preview) {
|
||||
setPreview(res.data.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("deleteAccountError")
|
||||
)
|
||||
});
|
||||
setOpen(false);
|
||||
})
|
||||
.finally(() => setLoadingPreview(false));
|
||||
}
|
||||
}, [open, step, preview, api, setOpen, t]);
|
||||
|
||||
function reset() {
|
||||
setStep(0);
|
||||
setPreview(null);
|
||||
setPasswordValue("");
|
||||
passwordForm.reset();
|
||||
codeForm.reset();
|
||||
}
|
||||
|
||||
async function handleContinueToPassword() {
|
||||
setStep(1);
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(
|
||||
values: z.infer<typeof passwordSchema>
|
||||
) {
|
||||
setLoading(true);
|
||||
setPasswordValue(values.password);
|
||||
try {
|
||||
const res = await api.post<
|
||||
| AxiosResponse<DeleteMyAccountCodeRequestedResponse>
|
||||
| AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||
>("/auth/delete-my-account", { password: values.password });
|
||||
|
||||
const data = res.data?.data;
|
||||
|
||||
if (data && "codeRequested" in data && data.codeRequested) {
|
||||
setStep(2);
|
||||
} else if (data && "success" in data && data.success) {
|
||||
toast({
|
||||
title: t("deleteAccountSuccess"),
|
||||
description: t("deleteAccountSuccessMessage")
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(err, t("deleteAccountError"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCodeSubmit(values: z.infer<typeof codeSchema>) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||
>("/auth/delete-my-account", {
|
||||
password: passwordValue,
|
||||
code: values.code
|
||||
});
|
||||
|
||||
if (res.data?.data?.success) {
|
||||
toast({
|
||||
title: t("deleteAccountSuccess"),
|
||||
description: t("deleteAccountSuccessMessage")
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(err, t("deleteAccountError"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("deleteAccountConfirmTitle")}
|
||||
</CredenzaTitle>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
{step === 0 && (
|
||||
<>
|
||||
{loadingPreview ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("loading")}...
|
||||
</p>
|
||||
) : preview ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("deleteAccountConfirmMessage")}
|
||||
</p>
|
||||
<div className="rounded-md bg-muted p-3 space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t(
|
||||
"deleteAccountPreviewAccount"
|
||||
)}
|
||||
</p>
|
||||
{preview.orgs.length > 0 && (
|
||||
<>
|
||||
<p className="text-sm font-medium mt-2">
|
||||
{t(
|
||||
"deleteAccountPreviewOrgs"
|
||||
)}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
{preview.orgs.map(
|
||||
(org) => (
|
||||
<li
|
||||
key={
|
||||
org.orgId
|
||||
}
|
||||
>
|
||||
{org.name ||
|
||||
org.orgId}
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-bold text-destructive">
|
||||
{t("cannotbeUndone")}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
id="delete-account-password-form"
|
||||
onSubmit={passwordForm.handleSubmit(
|
||||
handlePasswordSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("otpAuthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...codeForm}>
|
||||
<form
|
||||
id="delete-account-code-form"
|
||||
onSubmit={codeForm.handleSubmit(
|
||||
handleCodeSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={codeForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
pattern={
|
||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||
}
|
||||
onChange={(
|
||||
value: string
|
||||
) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
0
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
1
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
2
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
3
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
4
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
5
|
||||
}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
{step === 0 && preview && !loadingPreview && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleContinueToPassword}
|
||||
>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
form="delete-account-password-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
form="delete-account-code-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -277,10 +277,7 @@ export default function EditInternalResourceDialog({
|
||||
orgQueries.roles({ orgId }),
|
||||
orgQueries.users({ orgId }),
|
||||
orgQueries.clients({
|
||||
orgId,
|
||||
filters: {
|
||||
filter: "machine"
|
||||
}
|
||||
orgId
|
||||
}),
|
||||
resourceQueries.siteResourceUsers({ siteResourceId: resource.id }),
|
||||
resourceQueries.siteResourceRoles({ siteResourceId: resource.id }),
|
||||
|
||||
@@ -16,13 +16,23 @@ import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
CircleSlash
|
||||
CircleSlash,
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ChevronsUpDownIcon
|
||||
} 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 type { PaginationState } from "@tanstack/react-table";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -48,14 +58,24 @@ export type ClientRow = {
|
||||
type ClientTableProps = {
|
||||
machineClients: ClientRow[];
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function MachineClientsTable({
|
||||
machineClients,
|
||||
orgId
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount
|
||||
}: ClientTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
@@ -65,6 +85,7 @@ export default function MachineClientsTable({
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const defaultMachineColumnVisibility = {
|
||||
subnet: false,
|
||||
@@ -182,22 +203,8 @@ export default function MachineClientsTable({
|
||||
{
|
||||
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>
|
||||
);
|
||||
},
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="px-3">{t("name")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
@@ -224,38 +231,35 @@ export default function MachineClientsTable({
|
||||
{
|
||||
accessorKey: "niceId",
|
||||
friendlyName: "Identifier",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
header: () => <span className="px-3">{t("identifier")}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: "Connectivity",
|
||||
header: ({ column }) => {
|
||||
friendlyName: t("online"),
|
||||
header: () => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "true",
|
||||
label: t("connected")
|
||||
},
|
||||
{
|
||||
value: "false",
|
||||
label: t("disconnected")
|
||||
}
|
||||
]}
|
||||
selectedValue={
|
||||
searchParams.get("online") ?? undefined
|
||||
}
|
||||
>
|
||||
Connectivity
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("online", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("online")}
|
||||
className="p-3"
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -279,38 +283,52 @@ export default function MachineClientsTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
friendlyName: "Data In",
|
||||
header: ({ column }) => {
|
||||
friendlyName: t("dataIn"),
|
||||
header: () => {
|
||||
const dataInOrder = getSortDirection(
|
||||
"megabytesIn",
|
||||
searchParams
|
||||
);
|
||||
|
||||
const Icon =
|
||||
dataInOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: dataInOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
onClick={() => toggleSort("megabytesIn")}
|
||||
>
|
||||
Data In
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
{t("dataIn")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbOut",
|
||||
friendlyName: "Data Out",
|
||||
header: ({ column }) => {
|
||||
friendlyName: t("dataOut"),
|
||||
header: () => {
|
||||
const dataOutOrder = getSortDirection(
|
||||
"megabytesOut",
|
||||
searchParams
|
||||
);
|
||||
|
||||
const Icon =
|
||||
dataOutOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: dataOutOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
onClick={() => toggleSort("megabytesOut")}
|
||||
>
|
||||
Data Out
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
{t("dataOut")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -318,21 +336,7 @@ export default function MachineClientsTable({
|
||||
{
|
||||
accessorKey: "client",
|
||||
friendlyName: t("agent"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("agent")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
header: () => <span className="px-3">{t("agent")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
@@ -356,22 +360,8 @@ export default function MachineClientsTable({
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
friendlyName: t("address"),
|
||||
header: () => <span className="px-3">{t("address")}</span>
|
||||
}
|
||||
];
|
||||
|
||||
@@ -455,7 +445,56 @@ export default function MachineClientsTable({
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [hasRowsWithoutUserId, t]);
|
||||
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
value: string | null | undefined | string[]
|
||||
) {
|
||||
searchParams.delete(column);
|
||||
searchParams.delete("page");
|
||||
|
||||
if (typeof value === "string") {
|
||||
searchParams.set(column, value);
|
||||
} else if (value) {
|
||||
for (const val of value) {
|
||||
searchParams.append(column, val);
|
||||
}
|
||||
}
|
||||
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -478,20 +517,25 @@ export default function MachineClientsTable({
|
||||
title="Delete Client"
|
||||
/>
|
||||
)}
|
||||
<DataTable
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
data={machineClients || []}
|
||||
persistPageSize="machine-clients"
|
||||
rows={machineClients}
|
||||
tableId="machine-clients"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchColumn="name"
|
||||
onAdd={() =>
|
||||
router.push(`/${orgId}/settings/clients/machine/create`)
|
||||
startNavigation(() =>
|
||||
router.push(`/${orgId}/settings/clients/machine/create`)
|
||||
)
|
||||
}
|
||||
pagination={pagination}
|
||||
rowCount={rowCount}
|
||||
addButtonText={t("createClient")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
enableColumnVisibility={true}
|
||||
persistColumnVisibility="machine-clients"
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
onSearch={handleSearchChange}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
enableColumnVisibility
|
||||
columnVisibility={defaultMachineColumnVisibility}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
@@ -518,30 +562,10 @@ export default function MachineClientsTable({
|
||||
value: "blocked"
|
||||
}
|
||||
],
|
||||
filterFn: (
|
||||
row: ClientRow,
|
||||
selectedValues: (string | number | boolean)[]
|
||||
) => {
|
||||
if (selectedValues.length === 0) return true;
|
||||
const rowArchived = row.archived || false;
|
||||
const rowBlocked = row.blocked || false;
|
||||
const isActive = !rowArchived && !rowBlocked;
|
||||
|
||||
if (selectedValues.includes("active") && isActive)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("archived") &&
|
||||
rowArchived
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("blocked") &&
|
||||
rowBlocked
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
onValueChange(selectedValues: string[]) {
|
||||
handleFilterChange("status", selectedValues);
|
||||
},
|
||||
defaultValues: ["active"] // Default to showing active clients
|
||||
values: searchParams.getAll("status")
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function OrgSelector({
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command className="rounded-lg">
|
||||
<CommandInput
|
||||
placeholder={t("searchProgress")}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="border-0 focus:ring-0"
|
||||
/>
|
||||
<CommandEmpty className="py-6 text-center">
|
||||
|
||||
@@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react";
|
||||
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { build } from "@server/build";
|
||||
import { useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Disable2FaForm from "./Disable2FaForm";
|
||||
@@ -187,6 +189,20 @@ export default function ProfileIcon() {
|
||||
<DropdownMenuSeparator />
|
||||
<LocaleSwitcher />
|
||||
<DropdownMenuSeparator />
|
||||
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/auth/delete-account"
|
||||
className="flex cursor-pointer items-center"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("deleteAccount")}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => logout()}>
|
||||
{/* <LogOut className="mr-2 h-4 w-4" /> */}
|
||||
<span>{t("logout")}</span>
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -14,13 +13,14 @@ import {
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
@@ -32,14 +32,24 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
useOptimistic,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type ComponentRef
|
||||
} from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
|
||||
export type TargetHealth = {
|
||||
targetId: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
||||
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||
};
|
||||
|
||||
export type ResourceRow = {
|
||||
@@ -117,18 +127,22 @@ function StatusIcon({
|
||||
type ProxyResourcesTableProps = {
|
||||
resources: ResourceRow[];
|
||||
orgId: string;
|
||||
defaultSort?: {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
};
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function ProxyResourcesTable({
|
||||
resources,
|
||||
orgId,
|
||||
defaultSort
|
||||
pagination,
|
||||
rowCount
|
||||
}: ProxyResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
@@ -140,6 +154,7 @@ export default function ProxyResourcesTable({
|
||||
useState<ResourceRow | null>();
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const refreshData = () => {
|
||||
startTransition(() => {
|
||||
@@ -174,23 +189,24 @@ export default function ProxyResourcesTable({
|
||||
};
|
||||
|
||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||
await api
|
||||
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||
try {
|
||||
await api.post<AxiosResponse<UpdateResourceResponse>>(
|
||||
`resource/${resourceId}`,
|
||||
{
|
||||
enabled: val
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourcesErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourcesErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourcesErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourcesErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
|
||||
@@ -236,7 +252,7 @@ export default function ProxyResourcesTable({
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[280px]">
|
||||
<DropdownMenuContent align="start" className="min-w-70">
|
||||
{monitoredTargets.length > 0 && (
|
||||
<>
|
||||
{monitoredTargets.map((target) => (
|
||||
@@ -302,38 +318,14 @@ export default function ProxyResourcesTable({
|
||||
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>
|
||||
);
|
||||
}
|
||||
header: () => <span className="p-3">{t("name")}</span>
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
header: () => <span className="p-3">{t("identifier")}</span>,
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.nice || "-"}</span>;
|
||||
}
|
||||
@@ -359,19 +351,33 @@ export default function ProxyResourcesTable({
|
||||
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>
|
||||
);
|
||||
},
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "healthy", label: t("resourcesTableHealthy") },
|
||||
{
|
||||
value: "degraded",
|
||||
label: t("resourcesTableDegraded")
|
||||
},
|
||||
{ value: "offline", label: t("resourcesTableOffline") },
|
||||
{
|
||||
value: "no_targets",
|
||||
label: t("resourcesTableNoTargets")
|
||||
},
|
||||
{ value: "unknown", label: t("resourcesTableUnknown") }
|
||||
]}
|
||||
selectedValue={
|
||||
searchParams.get("healthStatus") ?? undefined
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("healthStatus", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("status")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return <TargetStatusCell targets={resourceRow.targets} />;
|
||||
@@ -419,19 +425,23 @@ export default function ProxyResourcesTable({
|
||||
{
|
||||
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>
|
||||
);
|
||||
},
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "protected", label: t("protected") },
|
||||
{ value: "not_protected", label: t("notProtected") },
|
||||
{ value: "none", label: t("none") }
|
||||
]}
|
||||
selectedValue={searchParams.get("authState") ?? undefined}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("authState", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("authentication")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
@@ -456,20 +466,28 @@ export default function ProxyResourcesTable({
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("enabled"),
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("enabled") },
|
||||
{ value: "false", label: t("disabled") }
|
||||
]}
|
||||
selectedValue={booleanSearchFilterSchema.parse(
|
||||
searchParams.get("enabled")
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("enabled", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("enabled")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
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)
|
||||
}
|
||||
<ResourceEnabledForm
|
||||
resource={row.original}
|
||||
onToggleResourceEnabled={toggleResourceEnabled}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -525,6 +543,42 @@ export default function ProxyResourcesTable({
|
||||
}
|
||||
];
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
value: string | undefined | null
|
||||
) {
|
||||
searchParams.delete(column);
|
||||
searchParams.delete("page");
|
||||
|
||||
if (value) {
|
||||
searchParams.set(column, value);
|
||||
}
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedResource && (
|
||||
@@ -547,21 +601,25 @@ export default function ProxyResourcesTable({
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
<ControlledDataTable
|
||||
columns={proxyColumns}
|
||||
data={resources}
|
||||
persistPageSize="proxy-resources"
|
||||
rows={resources}
|
||||
tableId="proxy-resources"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchColumn="name"
|
||||
pagination={pagination}
|
||||
rowCount={rowCount}
|
||||
onSearch={handleSearchChange}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
onAdd={() =>
|
||||
router.push(`/${orgId}/settings/resources/proxy/create`)
|
||||
startNavigation(() =>
|
||||
router.push(`/${orgId}/settings/resources/proxy/create`)
|
||||
)
|
||||
}
|
||||
addButtonText={t("resourceAdd")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
defaultSort={defaultSort}
|
||||
enableColumnVisibility={true}
|
||||
persistColumnVisibility="proxy-resources"
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
enableColumnVisibility
|
||||
columnVisibility={{ niceId: false }}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
@@ -569,3 +627,43 @@ export default function ProxyResourcesTable({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ResourceEnabledFormProps = {
|
||||
resource: ResourceRow;
|
||||
onToggleResourceEnabled: (
|
||||
val: boolean,
|
||||
resourceId: number
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
function ResourceEnabledForm({
|
||||
resource,
|
||||
onToggleResourceEnabled
|
||||
}: ResourceEnabledFormProps) {
|
||||
const enabled = resource.http
|
||||
? !!resource.domainId && resource.enabled
|
||||
: resource.enabled;
|
||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
|
||||
|
||||
const formRef = useRef<ComponentRef<"form">>(null);
|
||||
|
||||
async function submitAction(formData: FormData) {
|
||||
const newEnabled = !(formData.get("enabled") === "on");
|
||||
setOptimisticEnabled(newEnabled);
|
||||
await onToggleResourceEnabled(newEnabled, resource.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={submitAction} ref={formRef}>
|
||||
<Switch
|
||||
checked={optimisticEnabled}
|
||||
disabled={
|
||||
(resource.http && !resource.domainId) ||
|
||||
optimisticEnabled !== enabled
|
||||
}
|
||||
name="enabled"
|
||||
onCheckedChange={() => formRef.current?.requestSubmit()}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const target = getInternalRedirectTarget(targetOrgId);
|
||||
const target =
|
||||
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
|
||||
router.replace(target);
|
||||
} catch {
|
||||
router.replace(`/${targetOrgId}`);
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
createSite?: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
columnVisibility?: Record<string, boolean>;
|
||||
enableColumnVisibility?: boolean;
|
||||
}
|
||||
|
||||
export function SitesDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
createSite,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
columnVisibility,
|
||||
enableColumnVisibility
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="sites-table"
|
||||
title={t("sites")}
|
||||
searchPlaceholder={t("searchSitesProgress")}
|
||||
searchColumn="name"
|
||||
onAdd={createSite}
|
||||
addButtonText={t("siteAdd")}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
columnVisibility={columnVisibility}
|
||||
enableColumnVisibility={enableColumnVisibility}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { Column, ColumnDef } from "@tanstack/react-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { SitesDataTable } from "@app/components/SitesDataTable";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
MoreHorizontal,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState, useEffect } 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 { parseDataSize } from "@app/lib/dataSize";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
import { type PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowRight,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "./ui/controlled-data-table";
|
||||
|
||||
export type SiteRow = {
|
||||
id: number;
|
||||
@@ -52,79 +57,91 @@ export type SiteRow = {
|
||||
|
||||
type SitesTableProps = {
|
||||
sites: SiteRow[];
|
||||
pagination: PaginationState;
|
||||
orgId: string;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
export default function SitesTable({
|
||||
sites,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount
|
||||
}: SitesTableProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||
const [rows, setRows] = useState<SiteRow[]>(sites);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
// Update local state when props change (e.g., after refresh)
|
||||
useEffect(() => {
|
||||
setRows(sites);
|
||||
}, [sites]);
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
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);
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
value: string | undefined | null
|
||||
) {
|
||||
const sp = new URLSearchParams(searchParams);
|
||||
sp.delete(column);
|
||||
sp.delete("page");
|
||||
|
||||
if (value) {
|
||||
sp.set(column, value);
|
||||
}
|
||||
};
|
||||
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
||||
}
|
||||
|
||||
const deleteSite = (siteId: number) => {
|
||||
api.delete(`/site/${siteId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("siteErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorDelete"),
|
||||
description: formatAxiosError(e, t("siteErrorDelete"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
function refreshData() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const newRows = rows.filter((row) => row.id !== siteId);
|
||||
|
||||
setRows(newRows);
|
||||
});
|
||||
};
|
||||
function deleteSite(siteId: number) {
|
||||
startTransition(async () => {
|
||||
await api
|
||||
.delete(`/site/${siteId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("siteErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorDelete"),
|
||||
description: formatAxiosError(e, t("siteErrorDelete"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ExtendedColumnDef<SiteRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
header: () => {
|
||||
return <span className="p-3">{t("name")}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -132,18 +149,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
header: () => {
|
||||
return <span className="p-3">{t("identifier")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.nice || "-"}</span>;
|
||||
@@ -152,17 +159,24 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: t("online"),
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("online") },
|
||||
{ value: "false", label: t("offline") }
|
||||
]}
|
||||
selectedValue={booleanSearchFilterSchema.parse(
|
||||
searchParams.get("online")
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("online", value)
|
||||
}
|
||||
>
|
||||
{t("online")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("online")}
|
||||
className="p-3"
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -194,58 +208,59 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
friendlyName: t("dataIn"),
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
const dataInOrder = getSortDirection(
|
||||
"megabytesIn",
|
||||
searchParams
|
||||
);
|
||||
const Icon =
|
||||
dataInOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: dataInOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
onClick={() => toggleSort("megabytesIn")}
|
||||
>
|
||||
{t("dataIn")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
sortingFn: (rowA, rowB) =>
|
||||
parseDataSize(rowA.original.mbIn) -
|
||||
parseDataSize(rowB.original.mbIn)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "mbOut",
|
||||
friendlyName: t("dataOut"),
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
const dataOutOrder = getSortDirection(
|
||||
"megabytesOut",
|
||||
searchParams
|
||||
);
|
||||
|
||||
const Icon =
|
||||
dataOutOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: dataOutOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
onClick={() => toggleSort("megabytesOut")}
|
||||
>
|
||||
{t("dataOut")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
sortingFn: (rowA, rowB) =>
|
||||
parseDataSize(rowA.original.mbOut) -
|
||||
parseDataSize(rowB.original.mbOut)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
friendlyName: t("type"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("type")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
header: () => {
|
||||
return <span className="p-3">{t("type")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
@@ -290,18 +305,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
{
|
||||
accessorKey: "exitNode",
|
||||
friendlyName: t("exitNode"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("exitNode")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
header: () => {
|
||||
return <span className="p-3">{t("exitNode")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
@@ -354,18 +359,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "address",
|
||||
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Address
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
header: () => {
|
||||
return <span className="p-3">{t("address")}</span>;
|
||||
},
|
||||
cell: ({ row }: { row: any }) => {
|
||||
const originalRow = row.original;
|
||||
@@ -428,6 +423,30 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
}
|
||||
];
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedSite && (
|
||||
@@ -444,27 +463,42 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
</div>
|
||||
}
|
||||
buttonText={t("siteConfirmDelete")}
|
||||
onConfirm={async () => deleteSite(selectedSite!.id)}
|
||||
onConfirm={async () =>
|
||||
startTransition(() => deleteSite(selectedSite!.id))
|
||||
}
|
||||
string={selectedSite.name}
|
||||
title={t("siteDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SitesDataTable
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
createSite={() =>
|
||||
router.push(`/${orgId}/settings/sites/create`)
|
||||
rows={sites}
|
||||
tableId="sites-table"
|
||||
searchPlaceholder={t("searchSitesProgress")}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(`/${orgId}/settings/sites/create`)
|
||||
)
|
||||
}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
addButtonText={t("siteAdd")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
rowCount={rowCount}
|
||||
columnVisibility={{
|
||||
niceId: false,
|
||||
nice: false,
|
||||
exitNode: false,
|
||||
address: false
|
||||
}}
|
||||
enableColumnVisibility={true}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { durationToMs } from "@app/lib/durationToMs";
|
||||
import {
|
||||
keepPreviousData,
|
||||
QueryClient,
|
||||
QueryClientProvider
|
||||
} from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import * as React from "react";
|
||||
|
||||
export type ReactQueryProviderProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -22,7 +24,8 @@ export function TanstackQueryProvider({ children }: ReactQueryProviderProps) {
|
||||
staleTime: 0,
|
||||
meta: {
|
||||
api
|
||||
}
|
||||
},
|
||||
placeholderData: keepPreviousData
|
||||
},
|
||||
mutations: {
|
||||
meta: { api }
|
||||
|
||||
@@ -2,34 +2,41 @@
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint";
|
||||
import { build } from "@server/build";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
MoreHorizontal,
|
||||
CircleSlash
|
||||
ChevronsUpDownIcon,
|
||||
CircleSlash,
|
||||
MoreHorizontal
|
||||
} 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 { useDebouncedCallback } from "use-debounce";
|
||||
import ClientDownloadBanner from "./ClientDownloadBanner";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { build } from "@server/build";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -65,9 +72,15 @@ export type ClientRow = {
|
||||
type ClientTableProps = {
|
||||
userClients: ClientRow[];
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
export default function UserDevicesTable({
|
||||
userClients,
|
||||
pagination,
|
||||
rowCount
|
||||
}: ClientTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -77,6 +90,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const defaultUserColumnVisibility = {
|
||||
@@ -188,8 +206,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
try {
|
||||
// Fetch approvalId for this client using clientId query parameter
|
||||
const approvalsRes = await api.get<{
|
||||
data: { approvals: Array<{ approvalId: number; clientId: number }> };
|
||||
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
|
||||
data: {
|
||||
approvals: Array<{ approvalId: number; clientId: number }>;
|
||||
};
|
||||
}>(
|
||||
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
|
||||
);
|
||||
|
||||
const approval = approvalsRes.data.data.approvals[0];
|
||||
|
||||
@@ -202,9 +224,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, {
|
||||
decision: "approved"
|
||||
});
|
||||
await api.put(
|
||||
`/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
|
||||
{
|
||||
decision: "approved"
|
||||
}
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("accessApprovalUpdated"),
|
||||
@@ -230,8 +255,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
try {
|
||||
// Fetch approvalId for this client using clientId query parameter
|
||||
const approvalsRes = await api.get<{
|
||||
data: { approvals: Array<{ approvalId: number; clientId: number }> };
|
||||
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
|
||||
data: {
|
||||
approvals: Array<{ approvalId: number; clientId: number }>;
|
||||
};
|
||||
}>(
|
||||
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
|
||||
);
|
||||
|
||||
const approval = approvalsRes.data.data.approvals[0];
|
||||
|
||||
@@ -244,9 +273,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, {
|
||||
decision: "denied"
|
||||
});
|
||||
await api.put(
|
||||
`/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
|
||||
{
|
||||
decision: "denied"
|
||||
}
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("accessApprovalUpdated"),
|
||||
@@ -279,21 +311,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
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>
|
||||
);
|
||||
},
|
||||
header: () => <span className="px-3">{t("name")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const fingerprintInfo = r.fingerprint
|
||||
@@ -343,40 +361,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
{
|
||||
accessorKey: "niceId",
|
||||
friendlyName: t("identifier"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
header: () => <span className="px-3">{t("identifier")}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "userEmail",
|
||||
friendlyName: t("users"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("users")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
header: () => <span className="px-3">{t("users")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return r.userId ? (
|
||||
@@ -398,20 +388,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: t("connectivity"),
|
||||
header: ({ column }) => {
|
||||
friendlyName: t("online"),
|
||||
header: () => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "true",
|
||||
label: t("connected")
|
||||
},
|
||||
{
|
||||
value: "false",
|
||||
label: t("disconnected")
|
||||
}
|
||||
]}
|
||||
selectedValue={
|
||||
searchParams.get("online") ?? undefined
|
||||
}
|
||||
>
|
||||
{t("online")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("online", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("online")}
|
||||
className="p-3"
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
@@ -436,18 +437,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
{
|
||||
accessorKey: "mbIn",
|
||||
friendlyName: t("dataIn"),
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
const dataInOrder = getSortDirection(
|
||||
"megabytesIn",
|
||||
searchParams
|
||||
);
|
||||
|
||||
const Icon =
|
||||
dataInOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: dataInOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
onClick={() => toggleSort("megabytesIn")}
|
||||
>
|
||||
{t("dataIn")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -455,18 +463,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
{
|
||||
accessorKey: "mbOut",
|
||||
friendlyName: t("dataOut"),
|
||||
header: ({ column }) => {
|
||||
header: () => {
|
||||
const dataOutOrder = getSortDirection(
|
||||
"megabytesOut",
|
||||
searchParams
|
||||
);
|
||||
|
||||
const Icon =
|
||||
dataOutOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: dataOutOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
onClick={() => toggleSort("megabytesOut")}
|
||||
>
|
||||
{t("dataOut")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -474,21 +489,52 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
{
|
||||
accessorKey: "client",
|
||||
friendlyName: t("agent"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "macos",
|
||||
label: "Pangolin macOS"
|
||||
},
|
||||
{
|
||||
value: "ios",
|
||||
label: "Pangolin iOS"
|
||||
},
|
||||
{
|
||||
value: "ipados",
|
||||
label: "Pangolin iPadOS"
|
||||
},
|
||||
{
|
||||
value: "android",
|
||||
label: "Pangolin Android"
|
||||
},
|
||||
{
|
||||
value: "windows",
|
||||
label: "Pangolin Windows"
|
||||
},
|
||||
{
|
||||
value: "cli",
|
||||
label: "Pangolin CLI"
|
||||
},
|
||||
{
|
||||
value: "olm",
|
||||
label: "Olm CLI"
|
||||
},
|
||||
{
|
||||
value: "unknown",
|
||||
label: t("unknown")
|
||||
}
|
||||
>
|
||||
{t("agent")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
]}
|
||||
selectedValue={searchParams.get("agent") ?? undefined}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("agent", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("agent")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
@@ -514,21 +560,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
{
|
||||
accessorKey: "subnet",
|
||||
friendlyName: t("address"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("address")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
header: () => <span className="px-3">{t("address")}</span>
|
||||
}
|
||||
];
|
||||
|
||||
@@ -548,20 +580,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{clientRow.approvalState === "pending" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => approveDevice(clientRow)}
|
||||
>
|
||||
<span>{t("approve")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => denyDevice(clientRow)}
|
||||
>
|
||||
<span>{t("deny")}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{clientRow.approvalState === "pending" &&
|
||||
build !== "oss" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
approveDevice(clientRow)
|
||||
}
|
||||
>
|
||||
<span>{t("approve")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
denyDevice(clientRow)
|
||||
}
|
||||
>
|
||||
<span>{t("deny")}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (clientRow.archived) {
|
||||
@@ -621,7 +658,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
});
|
||||
|
||||
return baseColumns;
|
||||
}, [hasRowsWithoutUserId, t]);
|
||||
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
const allOptions = [
|
||||
@@ -652,12 +689,59 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
}
|
||||
];
|
||||
|
||||
if (build === "oss") {
|
||||
return allOptions.filter(
|
||||
(option) =>
|
||||
option.value !== "pending" && option.value !== "denied"
|
||||
);
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
}, [t]);
|
||||
|
||||
const statusFilterDefaultValues = useMemo(() => {
|
||||
return ["active", "pending"];
|
||||
}, []);
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
value: string | null | undefined | string[]
|
||||
) {
|
||||
searchParams.delete(column);
|
||||
searchParams.delete("page");
|
||||
|
||||
if (typeof value === "string") {
|
||||
searchParams.set(column, value);
|
||||
} else if (value) {
|
||||
for (const val of value) {
|
||||
searchParams.append(column, val);
|
||||
}
|
||||
}
|
||||
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -682,17 +766,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
)}
|
||||
<ClientDownloadBanner />
|
||||
|
||||
<DataTable
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
data={userClients || []}
|
||||
persistPageSize="user-clients"
|
||||
rows={userClients || []}
|
||||
tableId="user-clients"
|
||||
searchPlaceholder={t("resourcesSearch")}
|
||||
searchColumn="name"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
enableColumnVisibility={true}
|
||||
persistColumnVisibility="user-clients"
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
enableColumnVisibility
|
||||
columnVisibility={defaultUserColumnVisibility}
|
||||
onSearch={handleSearchChange}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
pagination={pagination}
|
||||
rowCount={rowCount}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
filters={[
|
||||
@@ -702,41 +788,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
multiSelect: true,
|
||||
displayMode: "calculated",
|
||||
options: statusFilterOptions,
|
||||
filterFn: (
|
||||
row: ClientRow,
|
||||
selectedValues: (string | number | boolean)[]
|
||||
) => {
|
||||
if (selectedValues.length === 0) return true;
|
||||
const rowArchived = row.archived;
|
||||
const rowBlocked = row.blocked;
|
||||
const approvalState = row.approvalState;
|
||||
const isActive = !rowArchived && !rowBlocked && approvalState !== "pending" && approvalState !== "denied";
|
||||
|
||||
if (selectedValues.includes("active") && isActive)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("pending") &&
|
||||
approvalState === "pending"
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("denied") &&
|
||||
approvalState === "denied"
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("archived") &&
|
||||
rowArchived
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("blocked") &&
|
||||
rowBlocked
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
onValueChange: (selectedValues: string[]) => {
|
||||
handleFilterChange("status", selectedValues);
|
||||
},
|
||||
defaultValues: statusFilterDefaultValues
|
||||
values: searchParams.getAll("status")
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];
|
||||
|
||||
|
||||
592
src/components/ui/controlled-data-table.tsx
Normal file
592
src/components/ui/controlled-data-table.tsx
Normal file
@@ -0,0 +1,592 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
PaginationState,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
|
||||
|
||||
import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
// Extended ColumnDef type that includes optional friendlyName for column visibility dropdown
|
||||
export type ExtendedColumnDef<TData, TValue = unknown> = ColumnDef<
|
||||
TData,
|
||||
TValue
|
||||
> & {
|
||||
friendlyName?: string;
|
||||
};
|
||||
|
||||
type FilterOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type DataTableFilter = {
|
||||
id: string;
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
multiSelect?: boolean;
|
||||
onValueChange: (selectedValues: string[]) => void;
|
||||
values?: string[];
|
||||
displayMode?: "label" | "calculated"; // How to display the filter button text
|
||||
};
|
||||
|
||||
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
|
||||
|
||||
type ControlledDataTableProps<TData, TValue> = {
|
||||
columns: ExtendedColumnDef<TData, TValue>[];
|
||||
rows: TData[];
|
||||
tableId: string;
|
||||
addButtonText?: string;
|
||||
onAdd?: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
isNavigatingToAddPage?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
filters?: DataTableFilter[];
|
||||
filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
|
||||
columnVisibility?: Record<string, boolean>;
|
||||
enableColumnVisibility?: boolean;
|
||||
onSearch?: (input: string) => void;
|
||||
searchQuery?: string;
|
||||
onPaginationChange: DataTablePaginationUpdateFn;
|
||||
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
|
||||
stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions")
|
||||
rowCount: number;
|
||||
pagination: PaginationState;
|
||||
};
|
||||
|
||||
export function ControlledDataTable<TData, TValue>({
|
||||
columns,
|
||||
rows,
|
||||
addButtonText,
|
||||
onAdd,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
searchPlaceholder = "Search...",
|
||||
filters,
|
||||
filterDisplayMode = "label",
|
||||
columnVisibility: defaultColumnVisibility,
|
||||
enableColumnVisibility = false,
|
||||
tableId,
|
||||
pagination,
|
||||
stickyLeftColumn,
|
||||
onSearch,
|
||||
searchQuery,
|
||||
onPaginationChange,
|
||||
stickyRightColumn,
|
||||
rowCount,
|
||||
isNavigatingToAddPage
|
||||
}: ControlledDataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useStoredColumnVisibility(
|
||||
tableId,
|
||||
defaultColumnVisibility
|
||||
);
|
||||
|
||||
// TODO: filters
|
||||
const activeFilters = useMemo(() => {
|
||||
const initial: Record<string, string[]> = {};
|
||||
filters?.forEach((filter) => {
|
||||
initial[filter.id] = filter.values || [];
|
||||
});
|
||||
return initial;
|
||||
}, [filters]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: (state) => {
|
||||
const newState =
|
||||
typeof state === "function" ? state(pagination) : state;
|
||||
onPaginationChange(newState);
|
||||
},
|
||||
manualFiltering: true,
|
||||
manualPagination: true,
|
||||
rowCount,
|
||||
state: {
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
pagination
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate display text for a filter based on selected values
|
||||
const getFilterDisplayText = (filter: DataTableFilter): string => {
|
||||
const selectedValues = activeFilters[filter.id] || [];
|
||||
|
||||
if (selectedValues.length === 0) {
|
||||
return filter.label;
|
||||
}
|
||||
|
||||
const selectedOptions = filter.options.filter((option) =>
|
||||
selectedValues.includes(option.value)
|
||||
);
|
||||
|
||||
if (selectedOptions.length === 0) {
|
||||
return filter.label;
|
||||
}
|
||||
|
||||
if (selectedOptions.length === 1) {
|
||||
return selectedOptions[0].label;
|
||||
}
|
||||
|
||||
// Multiple selections: always join with "and"
|
||||
return selectedOptions.map((opt) => opt.label).join(" or ");
|
||||
};
|
||||
|
||||
const handleFilterChange = (
|
||||
filterId: string,
|
||||
optionValue: string,
|
||||
checked: boolean
|
||||
) => {
|
||||
const currentValues = activeFilters[filterId] || [];
|
||||
const filter = filters?.find((f) => f.id === filterId);
|
||||
|
||||
if (!filter) return;
|
||||
|
||||
let newValues: string[];
|
||||
|
||||
if (filter.multiSelect) {
|
||||
// Multi-select: add or remove the value
|
||||
if (checked) {
|
||||
newValues = [...currentValues, optionValue];
|
||||
} else {
|
||||
newValues = currentValues.filter((v) => v !== optionValue);
|
||||
}
|
||||
} else {
|
||||
// Single-select: replace the value
|
||||
newValues = checked ? [optionValue] : [];
|
||||
}
|
||||
|
||||
filter.onValueChange(newValues);
|
||||
};
|
||||
|
||||
// Helper function to check if a column should be sticky
|
||||
const isStickyColumn = (
|
||||
columnId: string | undefined,
|
||||
accessorKey: string | undefined,
|
||||
position: "left" | "right"
|
||||
): boolean => {
|
||||
if (position === "left" && stickyLeftColumn) {
|
||||
return (
|
||||
columnId === stickyLeftColumn ||
|
||||
accessorKey === stickyLeftColumn
|
||||
);
|
||||
}
|
||||
if (position === "right" && stickyRightColumn) {
|
||||
return (
|
||||
columnId === stickyRightColumn ||
|
||||
accessorKey === stickyRightColumn
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get sticky column classes
|
||||
const getStickyClasses = (
|
||||
columnId: string | undefined,
|
||||
accessorKey: string | undefined
|
||||
): string => {
|
||||
if (isStickyColumn(columnId, accessorKey, "left")) {
|
||||
return "md:sticky md:left-0 z-10 bg-card [mask-image:linear-gradient(to_left,transparent_0%,black_20px)]";
|
||||
}
|
||||
if (isStickyColumn(columnId, accessorKey, "right")) {
|
||||
return "sticky right-0 z-10 w-auto min-w-fit bg-card [mask-image:linear-gradient(to_right,transparent_0%,black_20px)]";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<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-4">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
{onSearch && (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) =>
|
||||
onSearch(e.currentTarget.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>
|
||||
)}
|
||||
|
||||
{filters && filters.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{filters.map((filter) => {
|
||||
const selectedValues =
|
||||
activeFilters[filter.id] || [];
|
||||
const hasActiveFilters =
|
||||
selectedValues.length > 0;
|
||||
const displayMode =
|
||||
filter.displayMode || filterDisplayMode;
|
||||
const displayText =
|
||||
displayMode === "calculated"
|
||||
? getFilterDisplayText(filter)
|
||||
: filter.label;
|
||||
|
||||
return (
|
||||
<DropdownMenu key={filter.id}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size="sm"
|
||||
className="h-9"
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
{displayText}
|
||||
{displayMode === "label" &&
|
||||
hasActiveFilters && (
|
||||
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
|
||||
{
|
||||
selectedValues.length
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-48"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{filter.label}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{filter.options.map(
|
||||
(option) => {
|
||||
const isChecked =
|
||||
selectedValues.includes(
|
||||
option.value
|
||||
);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={option.id}
|
||||
checked={
|
||||
isChecked
|
||||
}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
handleFilterChange(
|
||||
filter.id,
|
||||
option.value,
|
||||
checked
|
||||
);
|
||||
}}
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
{onRefresh && (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
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>
|
||||
)}
|
||||
{onAdd && addButtonText && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
loading={isNavigatingToAddPage}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{addButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const columnId = header.column.id;
|
||||
const accessorKey = (
|
||||
header.column.columnDef as any
|
||||
).accessorKey as string | undefined;
|
||||
const stickyClasses =
|
||||
getStickyClasses(
|
||||
columnId,
|
||||
accessorKey
|
||||
);
|
||||
const isRightSticky =
|
||||
isStickyColumn(
|
||||
columnId,
|
||||
accessorKey,
|
||||
"right"
|
||||
);
|
||||
const hasHideableColumns =
|
||||
enableColumnVisibility &&
|
||||
table
|
||||
.getAllColumns()
|
||||
.some((col) =>
|
||||
col.getCanHide()
|
||||
);
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={`whitespace-nowrap ${stickyClasses}`}
|
||||
>
|
||||
{header.isPlaceholder ? null : isRightSticky &&
|
||||
hasHideableColumns ? (
|
||||
<div className="flex flex-col items-end pr-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 mb-1"
|
||||
>
|
||||
<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 className="h-0 opacity-0 pointer-events-none overflow-hidden">
|
||||
{flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
const columnId =
|
||||
cell.column.id;
|
||||
const accessorKey = (
|
||||
cell.column
|
||||
.columnDef as any
|
||||
).accessorKey as
|
||||
| string
|
||||
| undefined;
|
||||
const stickyClasses =
|
||||
getStickyClasses(
|
||||
columnId,
|
||||
accessorKey
|
||||
);
|
||||
const isRightSticky =
|
||||
isStickyColumn(
|
||||
columnId,
|
||||
accessorKey,
|
||||
"right"
|
||||
);
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`whitespace-nowrap ${stickyClasses} ${isRightSticky ? "text-right" : ""}`}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{rowCount > 0 && (
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
totalCount={rowCount}
|
||||
onPageSizeChange={(pageSize) =>
|
||||
onPaginationChange({
|
||||
...pagination,
|
||||
pageSize
|
||||
})
|
||||
}
|
||||
onPageChange={(pageIndex) => {
|
||||
onPaginationChange({
|
||||
...pagination,
|
||||
pageIndex
|
||||
});
|
||||
}}
|
||||
isServerPagination
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.pageIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -151,11 +151,20 @@ type DataTableFilter = {
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
multiSelect?: boolean;
|
||||
filterFn: (row: any, selectedValues: (string | number | boolean)[]) => boolean;
|
||||
filterFn: (
|
||||
row: any,
|
||||
selectedValues: (string | number | boolean)[]
|
||||
) => boolean;
|
||||
defaultValues?: (string | number | boolean)[];
|
||||
displayMode?: "label" | "calculated"; // How to display the filter button text
|
||||
};
|
||||
|
||||
export type DataTablePaginationState = PaginationState & {
|
||||
pageCount: number;
|
||||
};
|
||||
|
||||
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ExtendedColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
@@ -178,6 +187,11 @@ type DataTableProps<TData, TValue> = {
|
||||
defaultPageSize?: number;
|
||||
columnVisibility?: Record<string, boolean>;
|
||||
enableColumnVisibility?: boolean;
|
||||
manualFiltering?: boolean;
|
||||
onSearch?: (input: string) => void;
|
||||
searchQuery?: string;
|
||||
pagination?: DataTablePaginationState;
|
||||
onPaginationChange?: DataTablePaginationUpdateFn;
|
||||
persistColumnVisibility?: boolean | string;
|
||||
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
|
||||
stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions")
|
||||
@@ -203,7 +217,12 @@ export function DataTable<TData, TValue>({
|
||||
columnVisibility: defaultColumnVisibility,
|
||||
enableColumnVisibility = false,
|
||||
persistColumnVisibility = false,
|
||||
manualFiltering = false,
|
||||
pagination: paginationState,
|
||||
stickyLeftColumn,
|
||||
onSearch,
|
||||
searchQuery,
|
||||
onPaginationChange,
|
||||
stickyRightColumn
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
@@ -248,22 +267,25 @@ export function DataTable<TData, TValue>({
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
initialColumnVisibility
|
||||
);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
const [_pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
const pagination = paginationState ?? _pagination;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
defaultTab || tabs?.[0]?.id || ""
|
||||
);
|
||||
const [activeFilters, setActiveFilters] = useState<Record<string, (string | number | boolean)[]>>(
|
||||
() => {
|
||||
const initial: Record<string, (string | number | boolean)[]> = {};
|
||||
filters?.forEach((filter) => {
|
||||
initial[filter.id] = filter.defaultValues || [];
|
||||
});
|
||||
return initial;
|
||||
}
|
||||
);
|
||||
const [activeFilters, setActiveFilters] = useState<
|
||||
Record<string, (string | number | boolean)[]>
|
||||
>(() => {
|
||||
const initial: Record<string, (string | number | boolean)[]> = {};
|
||||
filters?.forEach((filter) => {
|
||||
initial[filter.id] = filter.defaultValues || [];
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
// Track initial values to avoid storing defaults on first render
|
||||
const initialPageSize = useRef(pageSize);
|
||||
@@ -309,12 +331,18 @@ export function DataTable<TData, TValue>({
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: onPaginationChange
|
||||
? (state) => {
|
||||
const newState =
|
||||
typeof state === "function" ? state(pagination) : state;
|
||||
onPaginationChange(newState);
|
||||
}
|
||||
: setPagination,
|
||||
manualFiltering,
|
||||
manualPagination: Boolean(paginationState),
|
||||
pageCount: paginationState?.pageCount,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: pageSize,
|
||||
pageIndex: 0
|
||||
},
|
||||
pagination,
|
||||
columnVisibility: initialColumnVisibility
|
||||
},
|
||||
state: {
|
||||
@@ -368,11 +396,11 @@ export function DataTable<TData, TValue>({
|
||||
setActiveFilters((prev) => {
|
||||
const currentValues = prev[filterId] || [];
|
||||
const filter = filters?.find((f) => f.id === filterId);
|
||||
|
||||
|
||||
if (!filter) return prev;
|
||||
|
||||
let newValues: (string | number | boolean)[];
|
||||
|
||||
|
||||
if (filter.multiSelect) {
|
||||
// Multi-select: add or remove the value
|
||||
if (checked) {
|
||||
@@ -397,7 +425,7 @@ export function DataTable<TData, TValue>({
|
||||
// Calculate display text for a filter based on selected values
|
||||
const getFilterDisplayText = (filter: DataTableFilter): string => {
|
||||
const selectedValues = activeFilters[filter.id] || [];
|
||||
|
||||
|
||||
if (selectedValues.length === 0) {
|
||||
return filter.label;
|
||||
}
|
||||
@@ -477,12 +505,15 @@ export function DataTable<TData, TValue>({
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
table.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
defaultValue={searchQuery}
|
||||
value={onSearch ? undefined : globalFilter}
|
||||
onChange={(e) => {
|
||||
onSearch
|
||||
? onSearch(e.currentTarget.value)
|
||||
: table.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" />
|
||||
@@ -490,13 +521,17 @@ export function DataTable<TData, TValue>({
|
||||
{filters && filters.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{filters.map((filter) => {
|
||||
const selectedValues = activeFilters[filter.id] || [];
|
||||
const hasActiveFilters = selectedValues.length > 0;
|
||||
const displayMode = filter.displayMode || filterDisplayMode;
|
||||
const displayText = displayMode === "calculated"
|
||||
? getFilterDisplayText(filter)
|
||||
: filter.label;
|
||||
|
||||
const selectedValues =
|
||||
activeFilters[filter.id] || [];
|
||||
const hasActiveFilters =
|
||||
selectedValues.length > 0;
|
||||
const displayMode =
|
||||
filter.displayMode || filterDisplayMode;
|
||||
const displayText =
|
||||
displayMode === "calculated"
|
||||
? getFilterDisplayText(filter)
|
||||
: filter.label;
|
||||
|
||||
return (
|
||||
<DropdownMenu key={filter.id}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -507,37 +542,54 @@ export function DataTable<TData, TValue>({
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
{displayText}
|
||||
{displayMode === "label" && hasActiveFilters && (
|
||||
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
|
||||
{selectedValues.length}
|
||||
</span>
|
||||
)}
|
||||
{displayMode === "label" &&
|
||||
hasActiveFilters && (
|
||||
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
|
||||
{
|
||||
selectedValues.length
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-48"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{filter.label}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{filter.options.map((option) => {
|
||||
const isChecked = selectedValues.includes(option.value);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={option.id}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
handleFilterChange(
|
||||
filter.id,
|
||||
option.value,
|
||||
{filter.options.map(
|
||||
(option) => {
|
||||
const isChecked =
|
||||
selectedValues.includes(
|
||||
option.value
|
||||
);
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={option.id}
|
||||
checked={
|
||||
isChecked
|
||||
}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
) =>
|
||||
handleFilterChange(
|
||||
filter.id,
|
||||
option.value,
|
||||
checked
|
||||
)
|
||||
}
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -795,12 +847,14 @@ export function DataTable<TData, TValue>({
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.pageIndex}
|
||||
/>
|
||||
{table.getRowModel().rows?.length > 0 && (
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.pageIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
36
src/hooks/useNavigationContext.ts
Normal file
36
src/hooks/useNavigationContext.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
|
||||
export function useNavigationContext() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const path = usePathname();
|
||||
const [isNavigating, startTransition] = useTransition();
|
||||
|
||||
function navigate({
|
||||
searchParams: params,
|
||||
pathname = path,
|
||||
replace = false
|
||||
}: {
|
||||
pathname?: string;
|
||||
searchParams?: URLSearchParams;
|
||||
replace?: boolean;
|
||||
}) {
|
||||
startTransition(() => {
|
||||
const fullPath = pathname + (params ? `?${params.toString()}` : "");
|
||||
|
||||
if (replace) {
|
||||
router.replace(fullPath);
|
||||
} else {
|
||||
router.push(fullPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
pathname: path,
|
||||
searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable
|
||||
navigate,
|
||||
isNavigating
|
||||
};
|
||||
}
|
||||
@@ -41,11 +41,12 @@ export function consumeInternalRedirectPath(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full redirect target for an org: either `/${orgId}` or
|
||||
* `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the
|
||||
* stored value.
|
||||
* Returns the full redirect target if a valid internal_redirect was stored
|
||||
* (consumes the stored value). Returns null if none was stored or expired.
|
||||
* Paths starting with /auth/ are returned as-is; others are prefixed with orgId.
|
||||
*/
|
||||
export function getInternalRedirectTarget(orgId: string): string {
|
||||
export function getInternalRedirectTarget(orgId: string): string | null {
|
||||
const path = consumeInternalRedirectPath();
|
||||
return path ? `/${orgId}${path}` : `/${orgId}`;
|
||||
if (!path) return null;
|
||||
return path.startsWith("/auth/") ? path : `/${orgId}${path}`;
|
||||
}
|
||||
|
||||
@@ -16,11 +16,16 @@ import type {
|
||||
import type { ListTargetsResponse } from "@server/routers/target";
|
||||
import type { ListUsersResponse } from "@server/routers/user";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import {
|
||||
infiniteQueryOptions,
|
||||
keepPreviousData,
|
||||
queryOptions
|
||||
} from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import z from "zod";
|
||||
import { remote } from "./api";
|
||||
import { durationToMs } from "./durationToMs";
|
||||
import { wait } from "./wait";
|
||||
|
||||
export type ProductUpdate = {
|
||||
link: string | null;
|
||||
@@ -86,8 +91,7 @@ export const productUpdatesQueries = {
|
||||
};
|
||||
|
||||
export const clientFilterSchema = z.object({
|
||||
filter: z.enum(["machine", "user"]),
|
||||
limit: z.int().prefault(1000).optional()
|
||||
pageSize: z.int().prefault(1000).optional()
|
||||
});
|
||||
|
||||
export const orgQueries = {
|
||||
@@ -96,14 +100,13 @@ export const orgQueries = {
|
||||
filters
|
||||
}: {
|
||||
orgId: string;
|
||||
filters: z.infer<typeof clientFilterSchema>;
|
||||
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()
|
||||
pageSize: (filters?.pageSize ?? 1000).toString()
|
||||
});
|
||||
|
||||
const res = await meta!.api.get<
|
||||
@@ -188,19 +191,16 @@ export const logAnalyticsFiltersSchema = z.object({
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.optional(),
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
timeEnd: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
error: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.optional(),
|
||||
resourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive())
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
resourceId: z.coerce.number().optional().catch(undefined)
|
||||
});
|
||||
|
||||
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
|
||||
@@ -361,22 +361,50 @@ export const approvalQueries = {
|
||||
orgId: string,
|
||||
filters: z.infer<typeof approvalFiltersSchema>
|
||||
) =>
|
||||
queryOptions({
|
||||
infiniteQueryOptions({
|
||||
queryKey: ["APPROVALS", orgId, filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
queryFn: async ({ signal, pageParam, meta }) => {
|
||||
const sp = new URLSearchParams();
|
||||
|
||||
if (filters.approvalState) {
|
||||
sp.set("approvalState", filters.approvalState);
|
||||
}
|
||||
if (pageParam) {
|
||||
sp.set("cursorPending", pageParam.cursorPending.toString());
|
||||
sp.set(
|
||||
"cursorTimestamp",
|
||||
pageParam.cursorTimestamp.toString()
|
||||
);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<{ approvals: ApprovalItem[] }>
|
||||
AxiosResponse<{
|
||||
approvals: ApprovalItem[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
cursorPending: number | null;
|
||||
cursorTimestamp: number | null;
|
||||
};
|
||||
}>
|
||||
>(`/org/${orgId}/approvals?${sp.toString()}`, {
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
}
|
||||
},
|
||||
initialPageParam: null as {
|
||||
cursorPending: number;
|
||||
cursorTimestamp: number;
|
||||
} | null,
|
||||
placeholderData: keepPreviousData,
|
||||
getNextPageParam: ({ pagination }) =>
|
||||
pagination.cursorPending != null &&
|
||||
pagination.cursorTimestamp != null
|
||||
? {
|
||||
cursorPending: pagination.cursorPending,
|
||||
cursorTimestamp: pagination.cursorTimestamp
|
||||
}
|
||||
: null
|
||||
}),
|
||||
pendingCount: (orgId: string) =>
|
||||
queryOptions({
|
||||
@@ -388,6 +416,12 @@ export const approvalQueries = {
|
||||
signal
|
||||
});
|
||||
return res.data.data.count;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
if (query.state.data) {
|
||||
return durationToMs(30, "seconds");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
52
src/lib/sortColumn.ts
Normal file
52
src/lib/sortColumn.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { SortOrder } from "@app/lib/types/sort";
|
||||
|
||||
export function getNextSortOrder(
|
||||
column: string,
|
||||
searchParams: URLSearchParams
|
||||
) {
|
||||
const sp = new URLSearchParams(searchParams);
|
||||
|
||||
let nextDirection: SortOrder = "indeterminate";
|
||||
|
||||
if (sp.get("sort_by") === column) {
|
||||
nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate";
|
||||
}
|
||||
|
||||
switch (nextDirection) {
|
||||
case "indeterminate": {
|
||||
nextDirection = "asc";
|
||||
break;
|
||||
}
|
||||
case "asc": {
|
||||
nextDirection = "desc";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
nextDirection = "indeterminate";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sp.delete("sort_by");
|
||||
sp.delete("order");
|
||||
|
||||
if (nextDirection !== "indeterminate") {
|
||||
sp.set("sort_by", column);
|
||||
sp.set("order", nextDirection);
|
||||
}
|
||||
|
||||
return sp;
|
||||
}
|
||||
|
||||
export function getSortDirection(
|
||||
column: string,
|
||||
searchParams: URLSearchParams
|
||||
) {
|
||||
let currentDirection: SortOrder = "indeterminate";
|
||||
|
||||
if (searchParams.get("sort_by") === column) {
|
||||
currentDirection =
|
||||
(searchParams.get("order") as SortOrder) ?? "indeterminate";
|
||||
}
|
||||
return currentDirection;
|
||||
}
|
||||
1
src/lib/types/sort.ts
Normal file
1
src/lib/types/sort.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type SortOrder = "asc" | "desc" | "indeterminate";
|
||||
16
src/lib/validateLocalPath.ts
Normal file
16
src/lib/validateLocalPath.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function validateLocalPath(value: string) {
|
||||
try {
|
||||
const url = new URL("https://pangoling.net" + value);
|
||||
if (
|
||||
url.pathname !== value ||
|
||||
value.includes("..") ||
|
||||
value.includes("*")
|
||||
) {
|
||||
throw new Error("Invalid Path");
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
"should be a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user