Merge branch 'dev' into clients-user

This commit is contained in:
miloschwartz
2025-11-06 16:55:16 -08:00
5 changed files with 581 additions and 156 deletions

View File

@@ -2143,5 +2143,19 @@
"deviceDeleted": "Device deleted", "deviceDeleted": "Device deleted",
"deviceDeletedDescription": "The device has been successfully deleted.", "deviceDeletedDescription": "The device has been successfully deleted.",
"errorDeletingDevice": "Error deleting device", "errorDeletingDevice": "Error deleting device",
"failedToDeleteDevice": "Failed to delete device" "failedToDeleteDevice": "Failed to delete device",
"showColumns": "Show Columns",
"hideColumns": "Hide Columns",
"columnVisibility": "Column Visibility",
"toggleColumn": "Toggle {columnName} column",
"allColumns": "All Columns",
"defaultColumns": "Default Columns",
"customizeView": "Customize View",
"viewOptions": "View Options",
"selectAll": "Select All",
"selectNone": "Select None",
"selectedResources": "Selected Resources",
"enableSelected": "Enable Selected",
"disableSelected": "Disable Selected",
"checkSelectedStatus": "Check Status of Selected"
} }

View File

@@ -352,20 +352,38 @@ export async function validateOidcCallback(
if (!userOrgInfo.length) { if (!userOrgInfo.length) {
if (existingUser) { if (existingUser) {
// delete the user // get existing user orgs
// cascade will also delete org users const existingUserOrgs = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, existingUser.userId),
eq(userOrgs.autoProvisioned, false)
)
);
await db if (!existingUserOrgs.length) {
.delete(users) // delete the user
.where(eq(users.userId, existingUser.userId)); // await db
// .delete(users)
// .where(eq(users.userId, existingUser.userId));
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
`No policies matched for ${userIdentifier}. This user must be added to an organization before logging in.`
)
);
}
} else {
// no orgs to provision and user doesn't exist
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
`No policies matched for ${userIdentifier}. This user must be added to an organization before logging in.`
)
);
} }
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
`No policies matched for ${userIdentifier}. This user must be added to an organization before logging in.`
)
);
} }
const orgUserCounts: { orgId: string; userCount: number }[] = []; const orgUserCounts: { orgId: string; userCount: number }[] = [];

View File

@@ -6,7 +6,9 @@ import {
userResources, userResources,
roleResources, roleResources,
resourcePassword, resourcePassword,
resourcePincode resourcePincode,
targets,
targetHealthCheck,
} from "@server/db"; } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -40,6 +42,59 @@ const listResourcesSchema = z.object({
.pipe(z.number().int().nonnegative()) .pipe(z.number().int().nonnegative())
}); });
// (resource fields + a single joined target)
type JoinedRow = {
resourceId: number;
niceId: string;
name: string;
ssl: boolean;
fullDomain: string | null;
passwordId: number | null;
sso: boolean;
pincodeId: number | null;
whitelist: boolean;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId: string | null;
headerAuthId: number | null;
targetId: number | null;
targetIp: string | null;
targetPort: number | null;
targetEnabled: boolean | null;
hcHealth: string | null;
hcEnabled: boolean | null;
};
// grouped by resource with targets[])
export type ResourceWithTargets = {
resourceId: number;
name: string;
ssl: boolean;
fullDomain: string | null;
passwordId: number | null;
sso: boolean;
pincodeId: number | null;
whitelist: boolean;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId: string | null;
niceId: string;
headerAuthId: number | null;
targets: Array<{
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus?: 'healthy' | 'unhealthy' | 'unknown';
}>;
};
function queryResources(accessibleResourceIds: number[], orgId: string) { function queryResources(accessibleResourceIds: number[], orgId: string) {
return db return db
.select({ .select({
@@ -57,7 +112,15 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
enabled: resources.enabled, enabled: resources.enabled,
domainId: resources.domainId, domainId: resources.domainId,
niceId: resources.niceId, niceId: resources.niceId,
headerAuthId: resourceHeaderAuth.headerAuthId headerAuthId: resourceHeaderAuth.headerAuthId,
targetId: targets.targetId,
targetIp: targets.ip,
targetPort: targets.port,
targetEnabled: targets.enabled,
hcHealth: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled,
}) })
.from(resources) .from(resources)
.leftJoin( .leftJoin(
@@ -72,6 +135,11 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
resourceHeaderAuth, resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId) eq(resourceHeaderAuth.resourceId, resources.resourceId)
) )
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where( .where(
and( and(
inArray(resources.resourceId, accessibleResourceIds), inArray(resources.resourceId, accessibleResourceIds),
@@ -81,7 +149,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
} }
export type ListResourcesResponse = { export type ListResourcesResponse = {
resources: NonNullable<Awaited<ReturnType<typeof queryResources>>>; resources: ResourceWithTargets[];
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
@@ -146,7 +214,7 @@ export async function listResources(
); );
} }
let accessibleResources; let accessibleResources: Array<{ resourceId: number }>;
if (req.user) { if (req.user) {
accessibleResources = await db accessibleResources = await db
.select({ .select({
@@ -183,9 +251,56 @@ export async function listResources(
const baseQuery = queryResources(accessibleResourceIds, orgId); const baseQuery = queryResources(accessibleResourceIds, orgId);
const resourcesList = await baseQuery!.limit(limit).offset(offset); const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>();
for (const row of rows) {
let entry = map.get(row.resourceId);
if (!entry) {
entry = {
resourceId: row.resourceId,
niceId: row.niceId,
name: row.name,
ssl: row.ssl,
fullDomain: row.fullDomain,
passwordId: row.passwordId,
sso: row.sso,
pincodeId: row.pincodeId,
whitelist: row.whitelist,
http: row.http,
protocol: row.protocol,
proxyPort: row.proxyPort,
enabled: row.enabled,
domainId: row.domainId,
headerAuthId: row.headerAuthId,
targets: [],
};
map.set(row.resourceId, entry);
}
if (row.targetId != null && row.targetIp && row.targetPort != null && row.targetEnabled != null) {
let healthStatus: 'healthy' | 'unhealthy' | 'unknown' = 'unknown';
if (row.hcEnabled && row.hcHealth) {
healthStatus = row.hcHealth as 'healthy' | 'unhealthy' | 'unknown';
}
entry.targets.push({
targetId: row.targetId,
ip: row.targetIp,
port: row.targetPort,
enabled: row.targetEnabled,
healthStatus: healthStatus,
});
}
}
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
const totalCountResult = await countQuery; const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count; const totalCount = totalCountResult[0]?.count ?? 0;
return response<ListResourcesResponse>(res, { return response<ListResourcesResponse>(res, {
data: { data: {

View File

@@ -43,7 +43,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
await authCookieHeader() await authCookieHeader()
); );
resources = res.data.data.resources; resources = res.data.data.resources;
} catch (e) { } } catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
try { try {
@@ -51,7 +51,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
AxiosResponse<ListAllSiteResourcesByOrgResponse> AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader()); >(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources; siteResources = res.data.data.siteResources;
} catch (e) { } } catch (e) {}
let org = null; let org = null;
try { try {
@@ -88,11 +88,18 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
resource.passwordId !== null || resource.passwordId !== null ||
resource.whitelist || resource.whitelist ||
resource.headerAuthId resource.headerAuthId
? "protected" ? "protected"
: "not_protected", : "not_protected",
enabled: resource.enabled, enabled: resource.enabled,
domainId: resource.domainId || undefined, domainId: resource.domainId || undefined,
ssl: resource.ssl ssl: resource.ssl,
targets: resource.targets?.map((target) => ({
targetId: target.targetId,
ip: target.ip,
port: target.port,
enabled: target.enabled,
healthStatus: target.healthStatus
}))
}; };
}); });
@@ -104,7 +111,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
orgId: params.orgId, orgId: params.orgId,
siteName: siteResource.siteName, siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null, siteAddress: siteResource.siteAddress || null,
mode: siteResource.mode || "port" as any, mode: siteResource.mode || ("port" as any),
protocol: siteResource.protocol, protocol: siteResource.protocol,
proxyPort: siteResource.proxyPort, proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId, siteId: siteResource.siteId,

View File

@@ -30,7 +30,16 @@ import {
ShieldOff, ShieldOff,
ShieldCheck, ShieldCheck,
RefreshCw, RefreshCw,
Columns Columns,
Settings2,
Plus,
Search,
ChevronDown,
Clock,
Wifi,
WifiOff,
CheckCircle2,
XCircle
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -49,7 +58,6 @@ import { useTranslations } from "next-intl";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search } from "lucide-react";
import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import { import {
Table, Table,
@@ -70,6 +78,14 @@ import EditInternalResourceDialog from "@app/components/EditInternalResourceDial
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown";
};
export type ResourceRow = { export type ResourceRow = {
id: number; id: number;
nice: string | null; nice: string | null;
@@ -83,8 +99,64 @@ export type ResourceRow = {
enabled: boolean; enabled: boolean;
domainId?: string; domainId?: string;
ssl: boolean; ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
}; };
function getOverallHealthStatus(
targets?: TargetHealth[]
): "online" | "degraded" | "offline" | "unknown" {
if (!targets || targets.length === 0) {
return "unknown";
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
if (monitoredTargets.length === 0) {
return "unknown";
}
const healthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "healthy"
).length;
const unhealthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "unhealthy"
).length;
if (healthyCount === monitoredTargets.length) {
return "online";
} else if (unhealthyCount === monitoredTargets.length) {
return "offline";
} else {
return "degraded";
}
}
function StatusIcon({
status,
className = ""
}: {
status: "online" | "degraded" | "offline" | "unknown";
className?: string;
}) {
const iconClass = `h-4 w-4 ${className}`;
switch (status) {
case "online":
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case "offline":
return <XCircle className={`${iconClass} text-destructive`} />;
case "unknown":
return <Clock className={`${iconClass} text-gray-400`} />;
default:
return null;
}
}
export type InternalResourceRow = { export type InternalResourceRow = {
id: number; id: number;
name: string; name: string;
@@ -232,6 +304,7 @@ export default function ResourcesTable({
const [proxySorting, setProxySorting] = useState<SortingState>( const [proxySorting, setProxySorting] = useState<SortingState>(
defaultSort ? [defaultSort] : [] defaultSort ? [defaultSort] : []
); );
const [proxyColumnFilters, setProxyColumnFilters] = const [proxyColumnFilters, setProxyColumnFilters] =
useState<ColumnFiltersState>([]); useState<ColumnFiltersState>([]);
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]); const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
@@ -243,12 +316,14 @@ export default function ResourcesTable({
useState<ColumnFiltersState>([]); useState<ColumnFiltersState>([]);
const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]); const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [proxyColumnVisibility, setProxyColumnVisibility] = useState<VisibilityState>( const [proxyColumnVisibility, setProxyColumnVisibility] =
() => getStoredColumnVisibility("proxy-resources", {}) useState<VisibilityState>(() =>
); getStoredColumnVisibility("proxy-resources", {})
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>( );
() => getStoredColumnVisibility("internal-resources", {}) const [internalColumnVisibility, setInternalColumnVisibility] =
); useState<VisibilityState>(() =>
getStoredColumnVisibility("internal-resources", {})
);
const currentView = searchParams.get("view") || defaultView; const currentView = searchParams.get("view") || defaultView;
@@ -408,6 +483,106 @@ export default function ResourcesTable({
}); });
} }
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
const overallStatus = getOverallHealthStatus(targets);
if (!targets || targets.length === 0) {
return (
<div className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm text-muted-foreground">
No targets
</span>
</div>
);
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
const unknownTargets = targets.filter(
(t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown"
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8"
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === "online" && "Healthy"}
{overallStatus === "degraded" && "Degraded"}
{overallStatus === "offline" && "Offline"}
{overallStatus === "unknown" && "Unknown"}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[280px]">
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status={
target.healthStatus ===
"healthy"
? "online"
: "offline"
}
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span
className={`capitalize ${
target.healthStatus === "healthy"
? "text-green-500"
: "text-destructive"
}`}
>
{target.healthStatus}
</span>
</DropdownMenuItem>
))}
</>
)}
{unknownTargets.length > 0 && (
<>
{unknownTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status="unknown"
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled
? "Disabled"
: "Not monitored"}
</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
const proxyColumns: ColumnDef<ResourceRow>[] = [ const proxyColumns: ColumnDef<ResourceRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
@@ -443,7 +618,7 @@ export default function ResourcesTable({
}, },
{ {
accessorKey: "protocol", accessorKey: "protocol",
header: () => (<span className="p-3">{t("protocol")}</span>), header: () => <span className="p-3">{t("protocol")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
@@ -457,9 +632,41 @@ export default function ResourcesTable({
); );
} }
}, },
{
id: "status",
accessorKey: "status",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} />;
},
sortingFn: (rowA, rowB) => {
const statusA = getOverallHealthStatus(rowA.original.targets);
const statusB = getOverallHealthStatus(rowB.original.targets);
const statusOrder = {
online: 3,
degraded: 2,
offline: 1,
unknown: 0
};
return statusOrder[statusA] - statusOrder[statusB];
}
},
{ {
accessorKey: "domain", accessorKey: "domain",
header: () => (<span className="p-3">{t("access")}</span>), header: () => <span className="p-3">{t("access")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
@@ -522,7 +729,7 @@ export default function ResourcesTable({
}, },
{ {
accessorKey: "enabled", accessorKey: "enabled",
header: () => (<span className="p-3">{t("enabled")}</span>), header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<Switch <Switch
defaultChecked={ defaultChecked={
@@ -541,7 +748,7 @@ export default function ResourcesTable({
}, },
{ {
id: "actions", id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>), header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
@@ -609,7 +816,7 @@ export default function ResourcesTable({
}, },
{ {
accessorKey: "siteName", accessorKey: "siteName",
header: () => (<span className="p-3">{t("siteName")}</span>), header: () => <span className="p-3">{t("siteName")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
@@ -626,7 +833,11 @@ export default function ResourcesTable({
}, },
{ {
accessorKey: "mode", accessorKey: "mode",
header: () => (<span className="p-3">{t("editInternalResourceDialogMode")}</span>), header: () => (
<span className="p-3">
{t("editInternalResourceDialogMode")}
</span>
),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
const modeLabels: Record<"host" | "cidr" | "port", string> = { const modeLabels: Record<"host" | "cidr" | "port", string> = {
@@ -639,13 +850,20 @@ export default function ResourcesTable({
}, },
{ {
accessorKey: "destination", accessorKey: "destination",
header: () => (<span className="p-3">{t("resourcesTableDestination")}</span>), header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span>
),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
let displayText: string; let displayText: string;
let copyText: string; let copyText: string;
if (resourceRow.mode === "port" && resourceRow.protocol && resourceRow.proxyPort && resourceRow.destinationPort) { if (
resourceRow.mode === "port" &&
resourceRow.protocol &&
resourceRow.proxyPort &&
resourceRow.destinationPort
) {
const protocol = resourceRow.protocol.toUpperCase(); const protocol = resourceRow.protocol.toUpperCase();
// For port mode: site part uses alias or site address, destination part uses destination IP // For port mode: site part uses alias or site address, destination part uses destination IP
// If site address has CIDR notation, extract just the IP address // If site address has CIDR notation, extract just the IP address
@@ -658,25 +876,33 @@ export default function ResourcesTable({
copyText = `${siteDisplay}:${resourceRow.proxyPort}`; copyText = `${siteDisplay}:${resourceRow.proxyPort}`;
} else if (resourceRow.mode === "host") { } else if (resourceRow.mode === "host") {
// For host mode: use alias if available, otherwise use destination // For host mode: use alias if available, otherwise use destination
const destinationDisplay = resourceRow.alias || resourceRow.destination; const destinationDisplay =
resourceRow.alias || resourceRow.destination;
displayText = destinationDisplay; displayText = destinationDisplay;
copyText = destinationDisplay; copyText = destinationDisplay;
} else if (resourceRow.mode === "cidr") { } else if (resourceRow.mode === "cidr") {
displayText = resourceRow.destination; displayText = resourceRow.destination;
copyText = resourceRow.destination; copyText = resourceRow.destination;
} else { } else {
const destinationDisplay = resourceRow.alias || resourceRow.destination; const destinationDisplay =
resourceRow.alias || resourceRow.destination;
displayText = destinationDisplay; displayText = destinationDisplay;
copyText = destinationDisplay; copyText = destinationDisplay;
} }
return <CopyToClipboard text={copyText} isLink={false} displayText={displayText} />; return (
<CopyToClipboard
text={copyText}
isLink={false}
displayText={displayText}
/>
);
} }
}, },
{ {
id: "actions", id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>), header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
@@ -788,7 +1014,10 @@ export default function ResourcesTable({
}, [proxyColumnVisibility]); }, [proxyColumnVisibility]);
useEffect(() => { useEffect(() => {
setStoredColumnVisibility(internalColumnVisibility, "internal-resources"); setStoredColumnVisibility(
internalColumnVisibility,
"internal-resources"
);
}, [internalColumnVisibility]); }, [internalColumnVisibility]);
return ( return (
@@ -861,80 +1090,122 @@ export default function ResourcesTable({
)} )}
</div> </div>
<div className="flex items-center gap-2 sm:justify-end"> <div className="flex items-center gap-2 sm:justify-end">
{currentView === "proxy" && proxyTable.getAllColumns().some((column) => column.getCanHide()) && ( {currentView === "proxy" &&
<DropdownMenu> proxyTable
<DropdownMenuTrigger asChild> .getAllColumns()
<Button variant="outline"> .some((column) =>
<Columns className="mr-0 sm:mr-2 h-4 w-4" /> column.getCanHide()
<span className="hidden sm:inline"> ) && (
{t("columns") || "Columns"} <DropdownMenu>
</span> <DropdownMenuTrigger asChild>
</Button> <Button variant="outline">
</DropdownMenuTrigger> <Columns className="mr-0 sm:mr-2 h-4 w-4" />
<DropdownMenuContent align="end" className="w-48"> <span className="hidden sm:inline">
<DropdownMenuLabel> {t("columns") ||
{t("toggleColumns") || "Toggle columns"} "Columns"}
</DropdownMenuLabel> </span>
<DropdownMenuSeparator /> </Button>
{proxyTable </DropdownMenuTrigger>
.getAllColumns() <DropdownMenuContent
.filter((column) => column.getCanHide()) align="end"
.map((column) => { className="w-48"
return ( >
<DropdownMenuCheckboxItem <DropdownMenuLabel>
key={column.id} {t("toggleColumns") ||
className="capitalize" "Toggle columns"}
checked={column.getIsVisible()} </DropdownMenuLabel>
onCheckedChange={(value) => <DropdownMenuSeparator />
column.toggleVisibility(!!value) {proxyTable
} .getAllColumns()
> .filter((column) =>
{typeof column.columnDef.header === "string" column.getCanHide()
? column.columnDef.header )
: column.id} .map((column) => {
</DropdownMenuCheckboxItem> return (
); <DropdownMenuCheckboxItem
})} key={column.id}
</DropdownMenuContent> className="capitalize"
</DropdownMenu> checked={column.getIsVisible()}
)} onCheckedChange={(
{currentView === "internal" && internalTable.getAllColumns().some((column) => column.getCanHide()) && ( value
<DropdownMenu> ) =>
<DropdownMenuTrigger asChild> column.toggleVisibility(
<Button variant="outline"> !!value
<Columns className="mr-0 sm:mr-2 h-4 w-4" /> )
<span className="hidden sm:inline"> }
{t("columns") || "Columns"} >
</span> {typeof column
</Button> .columnDef
</DropdownMenuTrigger> .header ===
<DropdownMenuContent align="end" className="w-48"> "string"
<DropdownMenuLabel> ? column
{t("toggleColumns") || "Toggle columns"} .columnDef
</DropdownMenuLabel> .header
<DropdownMenuSeparator /> : column.id}
{internalTable </DropdownMenuCheckboxItem>
.getAllColumns() );
.filter((column) => column.getCanHide()) })}
.map((column) => { </DropdownMenuContent>
return ( </DropdownMenu>
<DropdownMenuCheckboxItem )}
key={column.id} {currentView === "internal" &&
className="capitalize" internalTable
checked={column.getIsVisible()} .getAllColumns()
onCheckedChange={(value) => .some((column) =>
column.toggleVisibility(!!value) column.getCanHide()
} ) && (
> <DropdownMenu>
{typeof column.columnDef.header === "string" <DropdownMenuTrigger asChild>
? column.columnDef.header <Button variant="outline">
: column.id} <Columns className="mr-0 sm:mr-2 h-4 w-4" />
</DropdownMenuCheckboxItem> <span className="hidden sm:inline">
); {t("columns") ||
})} "Columns"}
</DropdownMenuContent> </span>
</DropdownMenu> </Button>
)} </DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t("toggleColumns") ||
"Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{internalTable
.getAllColumns()
.filter((column) =>
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(
value
) =>
column.toggleVisibility(
!!value
)
}
>
{typeof column
.columnDef
.header ===
"string"
? column
.columnDef
.header
: column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
<div> <div>
<Button <Button
variant="outline" variant="outline"
@@ -961,24 +1232,24 @@ export default function ResourcesTable({
.map((headerGroup) => ( .map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers {headerGroup.headers
.filter((header) => header.column.getIsVisible()) .filter((header) =>
.map( header.column.getIsVisible()
(header) => ( )
<TableHead .map((header) => (
key={header.id} <TableHead
> key={header.id}
{header.isPlaceholder >
? null {header.isPlaceholder
: flexRender( ? null
header : flexRender(
.column header
.columnDef .column
.header, .columnDef
header.getContext() .header,
)} header.getContext()
</TableHead> )}
) </TableHead>
)} ))}
</TableRow> </TableRow>
))} ))}
</TableHeader> </TableHeader>
@@ -1066,24 +1337,24 @@ export default function ResourcesTable({
.map((headerGroup) => ( .map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers {headerGroup.headers
.filter((header) => header.column.getIsVisible()) .filter((header) =>
.map( header.column.getIsVisible()
(header) => ( )
<TableHead .map((header) => (
key={header.id} <TableHead
> key={header.id}
{header.isPlaceholder >
? null {header.isPlaceholder
: flexRender( ? null
header : flexRender(
.column header
.columnDef .column
.header, .columnDef
header.getContext() .header,
)} header.getContext()
</TableHead> )}
) </TableHead>
)} ))}
</TableRow> </TableRow>
))} ))}
</TableHeader> </TableHeader>