Merge branch 'dev' of github.com:fosrl/pangolin into dev

This commit is contained in:
Owen
2026-02-15 11:09:11 -08:00
64 changed files with 4463 additions and 1411 deletions

View File

@@ -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
}}
/>
</>
);

View File

@@ -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>

View File

@@ -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
}}
/>
</>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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]);

View File

@@ -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>
);
}

View File

@@ -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>
)}
/>

View File

@@ -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

View File

@@ -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>

View 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>
);
}

View File

@@ -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
})
);

View 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>
);
}

View File

@@ -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 }),

View File

@@ -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")
}
]}
/>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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}`);

View File

@@ -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"
/>
);
}

View File

@@ -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"
/>
</>
);

View File

@@ -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 }

View File

@@ -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")
}
]}
/>

View File

@@ -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];

View 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>
);
}

View File

@@ -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>

View 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
};
}

View File

@@ -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}`;
}

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
export type SortOrder = "asc" | "desc" | "indeterminate";

View 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 `*`"
);
}
}