Merge branch 'dev' into feat/login-page-customization

This commit is contained in:
Fred KISSIE
2025-12-05 22:38:07 +01:00
275 changed files with 21920 additions and 6990 deletions

View File

@@ -35,6 +35,9 @@ export function IdpDataTable<TData, TValue>({
}}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { IdpDataTable } from "@app/components/AdminIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
@@ -77,9 +78,10 @@ export default function IdpTable({ idps }: Props) {
}
};
const columns: ColumnDef<IdpRow>[] = [
const columns: ExtendedColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
@@ -96,6 +98,8 @@ export default function IdpTable({ idps }: Props) {
},
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -112,6 +116,7 @@ export default function IdpTable({ idps }: Props) {
},
{
accessorKey: "type",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
@@ -133,10 +138,12 @@ export default function IdpTable({ idps }: Props) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -169,9 +176,7 @@ export default function IdpTable({ idps }: Props) {
</DropdownMenu>
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
variant={"outline"}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />

View File

@@ -32,6 +32,9 @@ export function UsersDataTable<TData, TValue>({
searchColumn="email"
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="username"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { UsersDataTable } from "@app/components/AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
@@ -18,6 +19,18 @@ import {
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Credenza,
CredenzaContent,
CredenzaDescription,
CredenzaHeader,
CredenzaTitle,
CredenzaBody,
CredenzaFooter,
CredenzaClose
} from "@app/components/Credenza";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { AxiosResponse } from "axios";
export type GlobalUserRow = {
id: string;
@@ -36,6 +49,12 @@ type Props = {
users: GlobalUserRow[];
};
type AdminGeneratePasswordResetCodeResponse = {
token: string;
email: string;
url: string;
};
export default function UsersTable({ users }: Props) {
const router = useRouter();
const t = useTranslations();
@@ -47,6 +66,11 @@ export default function UsersTable({ users }: Props) {
const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false);
const [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] =
useState(false);
const [passwordResetCodeData, setPasswordResetCodeData] =
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
const refreshData = async () => {
console.log("Data refreshed");
@@ -85,9 +109,33 @@ export default function UsersTable({ users }: Props) {
});
};
const columns: ColumnDef<GlobalUserRow>[] = [
const generatePasswordResetCode = async (userId: string) => {
setIsGeneratingCode(true);
try {
const res = await api.post<
AxiosResponse<AdminGeneratePasswordResetCodeResponse>
>(`/user/${userId}/generate-password-reset-code`);
if (res.data?.data) {
setPasswordResetCodeData(res.data.data);
setIsPasswordResetCodeDialogOpen(true);
}
} catch (e) {
console.error("Failed to generate password reset code", e);
toast({
variant: "destructive",
title: t("error"),
description: formatAxiosError(e, t("errorOccurred"))
});
} finally {
setIsGeneratingCode(false);
}
};
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
{
accessorKey: "id",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
@@ -103,6 +151,8 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "username",
enableHiding: false,
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
@@ -119,6 +169,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "email",
friendlyName: t("email"),
header: ({ column }) => {
return (
<Button
@@ -135,6 +186,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -151,6 +203,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
@@ -167,6 +220,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "twoFactorEnabled",
friendlyName: t("twoFactor"),
header: ({ column }) => {
return (
<Button
@@ -187,7 +241,7 @@ export default function UsersTable({ users }: Props) {
<div className="flex flex-row items-center gap-2">
<span>
{userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? (
userRow.twoFactorSetupRequested ? (
<span className="text-green-500">
{t("enabled")}
</span>
@@ -201,46 +255,49 @@ export default function UsersTable({ users }: Props) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{r.type !== "internal" && (
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
generatePasswordResetCode(r.id);
}}
>
{t("delete")}
{t("generatePasswordResetCode")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
size="sm"
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
</>
)}
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
);
}
}
@@ -288,6 +345,58 @@ export default function UsersTable({ users }: Props) {
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
<Credenza
open={isPasswordResetCodeDialogOpen}
onOpenChange={setIsPasswordResetCodeDialogOpen}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("passwordResetCodeGenerated")}
</CredenzaTitle>
<CredenzaDescription>
{t("passwordResetCodeGeneratedDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{passwordResetCodeData && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block">
{t("email")}
</label>
<CopyToClipboard
text={passwordResetCodeData.email}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("passwordResetCode")}
</label>
<CopyToClipboard
text={passwordResetCodeData.token}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("passwordResetUrl")}
</label>
<CopyToClipboard
text={passwordResetCodeData.url}
isLink={true}
/>
</div>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -59,6 +59,9 @@ export function ApiKeysDataTable<TData, TValue>({
addButtonText={t('apiKeysAdd')}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -85,9 +86,11 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
});
};
const columns: ColumnDef<ApiKeyRow>[] = [
const columns: ExtendedColumnDef<ApiKeyRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -104,7 +107,8 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
},
{
accessorKey: "key",
header: t("key"),
friendlyName: t("key"),
header: () => (<span className="p-3">{t("key")}</span>),
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
@@ -112,7 +116,8 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
},
{
accessorKey: "createdAt",
header: t("createdAt"),
friendlyName: t("createdAt"),
header: () => (<span className="p-3">{t("createdAt")}</span>),
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>;
@@ -120,10 +125,12 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -153,14 +160,14 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-end">
<Link href={`/admin/api-keys/${r.id}`}>
<Button variant={"secondary"} className="ml-2" size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
<Link href={`/admin/api-keys/${r.id}`}>
<Button
variant={"outline"}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
@@ -178,13 +185,9 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
}}
dialog={
<div>
<p>
{t("apiKeysQuestionRemove")}
</p>
<p>{t("apiKeysQuestionRemove")}</p>
<p>
{t("apiKeysMessageRemove")}
</p>
<p>{t("apiKeysMessageRemove")}</p>
</div>
}
buttonText={t("apiKeysDeleteConfirm")}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
@@ -30,9 +31,10 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
const [isRefreshing, startTransition] = useTransition();
const router = useRouter();
const columns: ColumnDef<BlueprintRow>[] = [
const columns: ExtendedColumnDef<BlueprintRow>[] = [
{
accessorKey: "createdAt",
friendlyName: t("appliedAt"),
header: ({ column }) => {
return (
<Button
@@ -61,6 +63,8 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
},
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -78,6 +82,7 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
{
accessorKey: "source",
friendlyName: t("source"),
header: ({ column }) => {
return (
<Button
@@ -160,9 +165,8 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
},
{
id: "actions",
header: () => {
return null;
},
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
return (
<div className="flex justify-end">
@@ -188,6 +192,9 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
title={t("blueprints")}
searchPlaceholder={t("searchBlueprintProgress")}
searchColumn="name"
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
onAdd={() => {
router.push(`/${orgId}/settings/blueprints/create`);
}}

View File

@@ -0,0 +1,348 @@
"use client";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown";
};
export type ResourceRow = {
id: number;
nice: string | null;
name: string;
orgId: string;
domain: string;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
};
export type InternalResourceRow = {
id: number;
name: string;
orgId: string;
siteName: string;
siteAddress: string | null;
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr";
// protocol: string | null;
// proxyPort: number | null;
siteId: number;
siteNiceId: string;
destination: string;
// destinationPort: number | null;
alias: string | null;
};
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
defaultSort?: {
id: string;
desc: boolean;
};
};
export default function ClientResourcesTable({
internalResources,
orgId,
defaultSort
}: ClientResourcesTableProps) {
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId }));
const [isRefreshing, startTransition] = useTransition();
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteInternalResource = async (
resourceId: number,
siteId: number
) => {
try {
await api
.delete(`/org/${orgId}/site/${siteId}/resource/${resourceId}`)
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
} catch (e) {
console.error(t("resourceErrorDelete"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("v"))
});
}
};
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "siteName",
friendlyName: t("site"),
header: () => <span className="p-3">{t("site")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
>
<Button variant="outline">
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<span className="p-3">
{t("editInternalResourceDialogMode")}
</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<"host" | "cidr" | "port", string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<CopyToClipboard
text={resourceRow.destination}
isLink={false}
displayText={resourceRow.destination}
/>
);
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.mode === "host" && resourceRow.alias ? (
<CopyToClipboard
text={resourceRow.alias}
isLink={false}
displayText={resourceRow.alias}
/>
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
];
return (
<>
{selectedInternalResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedInternalResource(null);
}}
dialog={
<div>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () =>
deleteInternalResource(
selectedInternalResource!.id,
selectedInternalResource!.siteId
)
}
string={selectedInternalResource.name}
title={t("resourceDelete")}
/>
)}
<DataTable
columns={internalColumns}
data={internalResources}
persistPageSize="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")}
onRefresh={refreshData}
isRefreshing={isRefreshing}
defaultSort={defaultSort}
enableColumnVisibility={true}
persistColumnVisibility="internal-resources"
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
{editingResource && (
<EditInternalResourceDialog
open={isEditDialogOpen}
setOpen={setIsEditDialogOpen}
resource={editingResource}
orgId={orgId}
onSuccess={() => {
router.refresh();
setEditingResource(null);
}}
/>
)}
<CreateInternalResourceDialog
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
sites={sites}
onSuccess={() => {
router.refresh();
}}
/>
</>
);
}

View File

@@ -5,12 +5,23 @@ import {
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
type TabFilter = {
id: string;
label: string;
filterFn: (row: any) => boolean;
};
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onRefresh?: () => void;
isRefreshing?: boolean;
addClient?: () => void;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
hideHeader?: boolean;
tabs?: TabFilter[];
defaultTab?: string;
}
export function ClientsDataTable<TData, TValue>({
@@ -18,20 +29,29 @@ export function ClientsDataTable<TData, TValue>({
data,
addClient,
onRefresh,
isRefreshing
isRefreshing,
columnVisibility,
enableColumnVisibility,
hideHeader = false,
tabs,
defaultTab
}: DataTableProps<TData, TValue>) {
return (
<DataTable
columns={columns}
data={data}
data={data || []}
persistPageSize="clients-table"
title="Clients"
title={hideHeader ? undefined : "Clients"}
searchPlaceholder="Search clients..."
searchColumn="name"
onAdd={addClient}
onRefresh={onRefresh}
onAdd={hideHeader ? undefined : addClient}
onRefresh={hideHeader ? undefined : onRefresh}
isRefreshing={isRefreshing}
addButtonText="Add Client"
addButtonText={hideHeader ? undefined : "Add Client"}
columnVisibility={columnVisibility}
enableColumnVisibility={enableColumnVisibility}
tabs={tabs}
defaultTab={defaultTab}
/>
);
}

View File

@@ -1,350 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ClientsDataTable } from "@app/components/ClientsDataTable";
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 { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
};
type ClientTableProps = {
clients: ClientRow[];
orgId: string;
};
export default function ClientsTable({ clients, orgId }: ClientTableProps) {
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const [rows, setRows] = useState<ClientRow[]>(clients);
const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false);
const t = useTranslations();
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== clientId);
setRows(newRows);
});
};
const columns: ColumnDef<ClientRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Olm</span>
{originalRow.olmVersion && (
<span className="text-xs text-gray-500">
v{originalRow.olmVersion}
</span>
)}
</div>
</Badge>
{originalRow.olmUpdateAvailable && (
<InfoPopup
info={t("olmUpdateAvailableInfo")}
/>
)}
</div>
);
}
},
{
accessorKey: "subnet",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const clientRow = row.original;
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<DropdownMenuItem>
View settings
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"secondary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>
{t("deleteClientQuestion")}
</p>
<p>
{t("clientMessageRemove")}
</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<ClientsDataTable
columns={columns}
data={rows}
addClient={() => {
router.push(`/${orgId}/settings/clients/create`);
}}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);
}

View File

@@ -7,6 +7,7 @@ import {
getFilteredRowModel,
VisibilityState
} from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@/components/ui/button";
import {
Credenza,
@@ -181,17 +182,19 @@ const DockerContainersTable: FC<{
[getExposedPorts]
);
const columns: ColumnDef<Container>[] = [
const columns: ExtendedColumnDef<Container>[] = [
{
accessorKey: "name",
header: t("containerName"),
friendlyName: t("containerName"),
header: () => (<span className="p-3">{t("containerName")}</span>),
cell: ({ row }) => (
<div className="font-medium">{row.original.name}</div>
)
},
{
accessorKey: "image",
header: t("containerImage"),
friendlyName: t("containerImage"),
header: () => (<span className="p-3">{t("containerImage")}</span>),
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{row.original.image}
@@ -200,7 +203,8 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "state",
header: t("containerState"),
friendlyName: t("containerState"),
header: () => (<span className="p-3">{t("containerState")}</span>),
cell: ({ row }) => (
<Badge
variant={
@@ -215,7 +219,8 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "networks",
header: t("containerNetworks"),
friendlyName: t("containerNetworks"),
header: () => (<span className="p-3">{t("containerNetworks")}</span>),
cell: ({ row }) => {
const networks = Object.keys(row.original.networks);
return (
@@ -233,7 +238,8 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "hostname",
header: t("containerHostnameIp"),
friendlyName: t("containerHostnameIp"),
header: () => (<span className="p-3">{t("containerHostnameIp")}</span>),
enableHiding: false,
cell: ({ row }) => (
<div className="text-sm font-mono">
@@ -243,7 +249,8 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "labels",
header: t("containerLabels"),
friendlyName: t("containerLabels"),
header: () => (<span className="p-3">{t("containerLabels")}</span>),
cell: ({ row }) => {
const labels = row.original.labels || {};
const labelEntries = Object.entries(labels);
@@ -295,7 +302,7 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "ports",
header: t("containerPorts"),
header: () => (<span className="p-3">{t("containerPorts")}</span>),
enableHiding: false,
cell: ({ row }) => {
const ports = getExposedPorts(row.original);
@@ -353,7 +360,7 @@ const DockerContainersTable: FC<{
},
{
id: "actions",
header: t("containerActions"),
header: () => (<span className="p-3">{t("containerActions")}</span>),
cell: ({ row }) => {
const ports = getExposedPorts(row.original);
return (

View File

@@ -26,24 +26,21 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
const t = useTranslations();
return (
<div className="flex items-center space-x-2 max-w-full">
<div className="flex items-center space-x-2 min-w-0 max-w-full">
{isLink ? (
<Link
href={text}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:underline text-sm"
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
className="truncate hover:underline text-sm min-w-0 max-w-full"
title={text} // Shows full text on hover
>
{displayValue}
</Link>
) : (
<span
className="truncate text-sm"
className="truncate text-sm min-w-0 max-w-full"
style={{
maxWidth: "100%",
display: "block",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
@@ -55,7 +52,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
)}
<button
type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy}
>
{!copied ? (

File diff suppressed because it is too large Load Diff

View File

@@ -155,7 +155,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
// );
return (
<div className={cn("px-0 mb-4 space-y-4", className)} {...props}>
<div className={cn("px-0 mb-4 space-y-4 overflow-x-hidden min-w-0", className)} {...props}>
{children}
</div>
);

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
import { Badge } from "@app/components/ui/badge";
import { DNSRecordsDataTable } from "./DNSRecordsDataTable";
@@ -50,9 +51,10 @@ export default function DNSRecordsTable({
}
};
const columns: ColumnDef<DNSRecordRow>[] = [
const columns: ExtendedColumnDef<DNSRecordRow>[] = [
{
accessorKey: "baseDomain",
friendlyName: t("recordName", { fallback: "Record name" }),
header: ({ column }) => {
return (
<div>{t("recordName", { fallback: "Record name" })}</div>
@@ -73,6 +75,7 @@ export default function DNSRecordsTable({
},
{
accessorKey: "recordType",
friendlyName: t("type"),
header: ({ column }) => {
return <div>{t("type")}</div>;
},
@@ -83,6 +86,7 @@ export default function DNSRecordsTable({
},
{
accessorKey: "ttl",
friendlyName: t("TTL"),
header: ({ column }) => {
return <div>{t("TTL")}</div>;
},
@@ -92,6 +96,7 @@ export default function DNSRecordsTable({
},
{
accessorKey: "value",
friendlyName: t("value"),
header: () => {
return <div>{t("value")}</div>;
},

View File

@@ -21,11 +21,13 @@ import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type DashboardLoginFormProps = {
redirect?: string;
idps?: LoginFormIDP[];
forceLogin?: boolean;
};
export default function DashboardLoginForm({
redirect,
idps
idps,
forceLogin
}: DashboardLoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
@@ -36,17 +38,18 @@ export default function DashboardLoginForm({
return t("loginStart");
}
const logoWidth = isUnlocked() ? env.branding.logo?.authPage?.width || 175 : 175;
const logoHeight = isUnlocked() ? env.branding.logo?.authPage?.height || 58 : 58;
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
return (
<Card className="shadow-md w-full max-w-md">
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo
height={logoHeight}
width={logoWidth}
/>
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
@@ -56,12 +59,13 @@ export default function DashboardLoginForm({
<LoginForm
redirect={redirect}
idps={idps}
onLogin={() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
forceLogin={forceLogin}
onLogin={(redirectUrl) => {
if (redirectUrl) {
const safe = cleanRedirect(redirectUrl);
router.replace(safe);
} else {
router.push("/");
router.replace("/");
}
}}
/>

View File

@@ -0,0 +1,144 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertTriangle, CheckCircle2, Monitor } from "lucide-react";
import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
type DeviceAuthMetadata = {
ip: string | null;
city: string | null;
deviceName: string | null;
applicationName: string;
createdAt: number;
};
type DeviceAuthConfirmationProps = {
metadata: DeviceAuthMetadata;
onConfirm: () => void;
onCancel: () => void;
loading: boolean;
};
export function DeviceAuthConfirmation({
metadata,
onConfirm,
onCancel,
loading
}: DeviceAuthConfirmationProps) {
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short"
});
};
const locationText =
metadata.city && metadata.ip
? `${metadata.city} ${metadata.ip}`
: metadata.ip || t("deviceUnknownLocation");
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
return (
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{t("deviceActivation")}</p>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<Alert variant="warning">
<AlertDescription>
{t("deviceAuthorizationRequested", {
location: locationText,
date: formatDate(metadata.createdAt)
})}
</AlertDescription>
</Alert>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 border rounded-md">
<Monitor className="h-5 w-5 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium">
{metadata.applicationName}
</p>
{metadata.deviceName && (
<p className="text-xs text-muted-foreground mt-1">
{t("deviceLabel", { deviceName: metadata.deviceName })}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{t("deviceWantsAccess")}
</p>
</div>
</div>
<div className="space-y-2 pt-2">
<p className="text-sm font-medium">{t("deviceExistingAccess")}</p>
<div className="space-y-1 pl-4">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>{t("deviceFullAccess")}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>
{t("deviceOrganizationsAccess")}
</span>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter className="gap-2">
<Button
variant="outline"
onClick={onCancel}
disabled={loading}
className="w-full"
>
{t("cancel")}
</Button>
<Button
className="w-full"
onClick={onConfirm}
disabled={loading}
loading={loading}
>
{t("deviceAuthorize", { applicationName: metadata.applicationName })}
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,303 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot
} from "@/components/ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { AlertTriangle } from "lucide-react";
import { DeviceAuthConfirmation } from "@/components/DeviceAuthConfirmation";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import BrandingLogo from "./BrandingLogo";
import { useTranslations } from "next-intl";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
const createFormSchema = (t: (key: string) => string) =>
z.object({
code: z.string().length(8, t("deviceCodeInvalidFormat"))
});
type DeviceAuthMetadata = {
ip: string | null;
city: string | null;
deviceName: string | null;
applicationName: string;
createdAt: number;
};
type DeviceLoginFormProps = {
userEmail: string;
userName?: string;
initialCode?: string;
};
export default function DeviceLoginForm({
userEmail,
userName,
initialCode = ""
}: DeviceLoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState<DeviceAuthMetadata | null>(null);
const [code, setCode] = useState<string>("");
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const formSchema = createFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
code: initialCode.replace(/-/g, "").toUpperCase()
}
});
async function onSubmit(data: z.infer<typeof formSchema>) {
setError(null);
setLoading(true);
try {
// split code and add dash if missing
if (!data.code.includes("-") && data.code.length === 8) {
data.code = data.code.slice(0, 4) + "-" + data.code.slice(4);
}
await new Promise((resolve) => setTimeout(resolve, 300));
// First check - get metadata
const res = await api.post(
"/device-web-auth/verify?forceLogin=true",
{
code: data.code.toUpperCase(),
verify: false
}
);
if (res.data.success && res.data.data.metadata) {
setMetadata(res.data.data.metadata);
setCode(data.code.toUpperCase());
} else {
setError(t("deviceCodeInvalidOrExpired"));
}
} catch (e: any) {
const errorMessage = formatAxiosError(e);
setError(errorMessage || t("deviceCodeInvalidOrExpired"));
} finally {
setLoading(false);
}
}
async function onConfirm() {
if (!code || !metadata) return;
setError(null);
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 300));
// Final verify
await api.post("/device-web-auth/verify", {
code: code,
verify: true
});
// Redirect to success page
router.push("/auth/login/device/success");
} catch (e: any) {
const errorMessage = formatAxiosError(e);
setError(errorMessage || t("deviceCodeVerifyFailed"));
setMetadata(null);
setCode("");
form.reset();
} finally {
setLoading(false);
}
}
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
function onCancel() {
setMetadata(null);
setCode("");
form.reset();
setError(null);
}
const profileLabel = (userName || userEmail || "").trim();
const profileInitial = profileLabel
? profileLabel.charAt(0).toUpperCase()
: "?";
async function handleUseDifferentAccount() {
try {
await api.post("/auth/logout");
} catch (logoutError) {
console.error(
"Failed to logout before switching account",
logoutError
);
} finally {
const currentSearch =
typeof window !== "undefined" ? window.location.search : "";
const redirectTarget = `/auth/login/device${currentSearch || ""}`;
router.push(
`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectTarget)}`
);
router.refresh();
}
}
if (metadata) {
return (
<DeviceAuthConfirmation
metadata={metadata}
onConfirm={onConfirm}
onCancel={onCancel}
loading={loading}
/>
);
}
return (
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">
{t("deviceActivation")}
</p>
</div>
</CardHeader>
<CardContent className="pt-6">
<div className="flex items-center gap-3 p-3 mb-4 border rounded-md">
<Avatar className="h-10 w-10">
<AvatarFallback>{profileInitial}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div>
<p className="text-sm font-medium">
{profileLabel || userEmail}
</p>
<p className="text-xs text-muted-foreground break-all">
{t(
"deviceLoginDeviceRequestingAccessToAccount"
)}
</p>
</div>
<Button
type="button"
variant="link"
className="h-auto px-0 text-xs"
onClick={handleUseDifferentAccount}
>
{t("deviceLoginUseDifferentAccount")}
</Button>
</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<div className="space-y-2">
<p className="text-sm text-muted-foreground text-center">
{t("deviceCodeEnterPrompt")}
</p>
</div>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={9}
{...field}
value={field.value
.replace(/-/g, "")
.toUpperCase()}
onChange={(value) => {
// Strip hyphens and convert to uppercase
const cleanedValue = value
.replace(/-/g, "")
.toUpperCase();
field.onChange(
cleanedValue
);
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
<InputOTPSlot index={7} />
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={loading}
loading={loading}
>
{t("continue")}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}

View File

@@ -33,6 +33,9 @@ export function DomainsDataTable<TData, TValue>({
onAdd={onAdd}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="baseDomain"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { DomainsDataTable } from "@app/components/DomainsDataTable";
import { Button } from "@app/components/ui/button";
import {
@@ -135,8 +136,31 @@ export default function DomainsTable({ domains, orgId }: Props) {
}
};
const statusColumn: ColumnDef<DomainRow> = {
const typeColumn: ExtendedColumnDef<DomainRow> = {
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>
);
},
cell: ({ row }) => {
const type = row.original.type;
return <Badge variant="secondary">{getTypeDisplay(type)}</Badge>;
}
};
const statusColumn: ExtendedColumnDef<DomainRow> = {
accessorKey: "verified",
friendlyName: t("status"),
header: ({ column }) => {
return (
<Button
@@ -170,9 +194,11 @@ export default function DomainsTable({ domains, orgId }: Props) {
}
};
const columns: ColumnDef<DomainRow>[] = [
const columns: ExtendedColumnDef<DomainRow>[] = [
{
accessorKey: "baseDomain",
enableHiding: false,
friendlyName: t("domain"),
header: ({ column }) => {
return (
<Button
@@ -187,41 +213,49 @@ export default function DomainsTable({ domains, orgId }: Props) {
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
}
},
...(env.env.flags.usePangolinDns ? [typeColumn] : []),
...(env.env.flags.usePangolinDns ? [statusColumn] : []),
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const domain = row.original;
const isRestarting = restartingDomains.has(domain.domainId);
return (
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{domain.failed && (
<Button
variant="outline"
size="sm"
onClick={() => restartDomain(domain.domainId)}
disabled={isRestarting}
>
@@ -235,50 +269,14 @@ export default function DomainsTable({ domains, orgId }: Props) {
: t("restart", { fallback: "Restart" })}
</Button>
)}
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<Button variant={"secondary"} size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
<Link
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
{/* <Button
variant="secondary"
size="sm"

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
@@ -36,18 +36,31 @@ import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Separator } from "@app/components/ui/separator";
import { ListRolesResponse } from "@server/routers/role";
import { ListUsersResponse } from "@server/routers/user";
import { ListSiteResourceRolesResponse } from "@server/routers/siteResource/listSiteResourceRoles";
import { ListSiteResourceUsersResponse } from "@server/routers/siteResource/listSiteResourceUsers";
import { ListSiteResourceClientsResponse } from "@server/routers/siteResource/listSiteResourceClients";
import { ListClientsResponse } from "@server/routers/client/listClients";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { AxiosResponse } from "axios";
import { UserType } from "@server/types/UserTypes";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { orgQueries, resourceQueries } from "@app/lib/queries";
type InternalResourceData = {
id: number;
name: string;
orgId: string;
siteName: string;
protocol: string;
proxyPort: number | null;
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr";
// protocol: string | null;
// proxyPort: number | null;
siteId: number;
destinationIp?: string;
destinationPort?: number;
destination: string;
// destinationPort?: number | null;
alias?: string | null;
};
type EditInternalResourceDialogProps = {
@@ -67,56 +80,252 @@ export default function EditInternalResourceDialog({
}: EditInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
const formSchema = z.object({
name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")),
protocol: z.enum(["tcp", "udp"]),
proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")),
destinationIp: z.string(),
destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax"))
name: z
.string()
.min(1, t("editInternalResourceDialogNameRequired"))
.max(255, t("editInternalResourceDialogNameMaxLength")),
mode: z.enum(["host", "cidr", "port"]),
// protocol: z.enum(["tcp", "udp"]).nullish(),
// proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
destination: z.string().min(1),
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
alias: z.string().nullish(),
roles: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional(),
users: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional(),
clients: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional()
});
// .refine(
// (data) => {
// if (data.mode === "port") {
// return data.protocol !== undefined && data.protocol !== null;
// }
// return true;
// },
// {
// message: t("editInternalResourceDialogProtocol") + " is required for port mode",
// path: ["protocol"]
// }
// )
// .refine(
// (data) => {
// if (data.mode === "port") {
// return data.proxyPort !== undefined && data.proxyPort !== null;
// }
// return true;
// },
// {
// message: t("editInternalResourceDialogSitePort") + " is required for port mode",
// path: ["proxyPort"]
// }
// )
// .refine(
// (data) => {
// if (data.mode === "port") {
// return data.destinationPort !== undefined && data.destinationPort !== null;
// }
// return true;
// },
// {
// message: t("targetPort") + " is required for port mode",
// path: ["destinationPort"]
// }
// );
type FormData = z.infer<typeof formSchema>;
const queries = useQueries({
queries: [
orgQueries.roles({ orgId }),
orgQueries.users({ orgId }),
orgQueries.clients({
orgId,
filters: {
filter: "machine"
}
}),
resourceQueries.resourceUsers({ resourceId: resource.id }),
resourceQueries.resourceRoles({ resourceId: resource.id }),
resourceQueries.resourceClients({ resourceId: resource.id })
],
combine: (results) => {
const [
rolesQuery,
usersQuery,
clientsQuery,
resourceUsersQuery,
resourceRolesQuery,
resourceClientsQuery
] = results;
const allRoles = (rolesQuery.data ?? [])
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin");
const allUsers = (usersQuery.data ?? []).map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}));
const machineClients = (clientsQuery.data ?? [])
.filter((client) => !client.userId)
.map((client) => ({
id: client.clientId.toString(),
text: client.name
}));
const existingClients = (resourceClientsQuery.data ?? []).map(
(c: { clientId: number; name: string }) => ({
id: c.clientId.toString(),
text: c.name
})
);
const formRoles = (resourceRolesQuery.data ?? [])
.map((i) => ({
id: i.roleId.toString(),
text: i.name
}))
.filter((role) => role.text !== "Admin");
const formUsers = (resourceUsersQuery.data ?? []).map((i) => ({
id: i.userId.toString(),
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}));
return {
allRoles,
allUsers,
machineClients,
existingClients,
formRoles,
formUsers,
hasMachineClients:
machineClients.length > 0 || existingClients.length > 0,
isLoading: results.some((query) => query.isLoading)
};
}
});
const {
allRoles,
allUsers,
machineClients,
existingClients,
formRoles,
formUsers,
hasMachineClients,
isLoading: loadingRolesUsers
} = queries;
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<
number | null
>(null);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: resource.name,
protocol: resource.protocol as "tcp" | "udp",
proxyPort: resource.proxyPort || undefined,
destinationIp: resource.destinationIp || "",
destinationPort: resource.destinationPort || undefined
mode: resource.mode || "host",
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
// proxyPort: resource.proxyPort ?? undefined,
destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
roles: [],
users: [],
clients: []
}
});
useEffect(() => {
if (open) {
form.reset({
name: resource.name,
protocol: resource.protocol as "tcp" | "udp",
proxyPort: resource.proxyPort || undefined,
destinationIp: resource.destinationIp || "",
destinationPort: resource.destinationPort || undefined
});
}
}, [open, resource, form]);
const mode = form.watch("mode");
const handleSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
// Update the site resource
await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, {
name: data.name,
protocol: data.protocol,
proxyPort: data.proxyPort,
destinationIp: data.destinationIp,
destinationPort: data.destinationPort
});
await api.post(
`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`,
{
name: data.name,
mode: data.mode,
// protocol: data.mode === "port" ? data.protocol : null,
// proxyPort: data.mode === "port" ? data.proxyPort : null,
// destinationPort: data.mode === "port" ? data.destinationPort : null,
destination: data.destination,
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id))
}
);
// Update roles, users, and clients
// await Promise.all([
// api.post(`/site-resource/${resource.id}/roles`, {
// roleIds: (data.roles || []).map((r) => parseInt(r.id))
// }),
// api.post(`/site-resource/${resource.id}/users`, {
// userIds: (data.users || []).map((u) => u.id)
// }),
// api.post(`/site-resource/${resource.id}/clients`, {
// clientIds: (data.clients || []).map((c) => parseInt(c.id))
// })
// ]);
await queryClient.invalidateQueries(
resourceQueries.resourceRoles({ resourceId: resource.id })
);
await queryClient.invalidateQueries(
resourceQueries.resourceUsers({ resourceId: resource.id })
);
await queryClient.invalidateQueries(
resourceQueries.resourceClients({ resourceId: resource.id })
);
toast({
title: t("editInternalResourceDialogSuccess"),
description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"),
description: t(
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
),
variant: "default"
});
@@ -126,7 +335,12 @@ export default function EditInternalResourceDialog({
console.error("Error updating internal resource:", error);
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")),
description: formatAxiosError(
error,
t(
"editInternalResourceDialogFailedToUpdateInternalResource"
)
),
variant: "destructive"
});
} finally {
@@ -134,28 +348,100 @@ export default function EditInternalResourceDialog({
}
};
const hasInitialized = useRef(false);
const previousResourceId = useRef<number | null>(null);
useEffect(() => {
if (open) {
const resourceChanged = previousResourceId.current !== resource.id;
if (resourceChanged) {
form.reset({
name: resource.name,
mode: resource.mode || "host",
destination: resource.destination || "",
alias: resource.alias ?? null,
roles: [],
users: [],
clients: []
});
previousResourceId.current = resource.id;
}
hasInitialized.current = false;
}
}, [open, resource.id, resource.name, resource.mode, resource.destination, resource.alias, form]);
useEffect(() => {
if (open && !loadingRolesUsers && !hasInitialized.current) {
hasInitialized.current = true;
form.setValue("roles", formRoles);
form.setValue("users", formUsers);
form.setValue("clients", existingClients);
}
}, [open, loadingRolesUsers, formRoles, formUsers, existingClients, form]);
return (
<Credenza open={open} onOpenChange={setOpen}>
<Credenza
open={open}
onOpenChange={(open) => {
if (!open) {
// reset only on close
form.reset({
name: resource.name,
mode: resource.mode || "host",
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
// proxyPort: resource.proxyPort ?? undefined,
destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
roles: [],
users: [],
clients: []
});
// Reset previous resource ID to ensure clean state on next open
previousResourceId.current = null;
}
setOpen(open);
}}
>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("editInternalResourceDialogEditClientResource")}</CredenzaTitle>
<CredenzaTitle>
{t("editInternalResourceDialogEditClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })}
{t(
"editInternalResourceDialogUpdateResourceProperties",
{ resourceName: resource.name }
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6" id="edit-internal-resource-form">
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
id="edit-internal-resource-form"
>
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogResourceProperties")}</h3>
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogResourceProperties"
)}
</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogName")}</FormLabel>
<FormLabel>
{t(
"editInternalResourceDialogName"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -164,72 +450,133 @@ export default function EditInternalResourceDialog({
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"editInternalResourceDialogMode"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<SelectContent>
{/* <SelectItem value="port">{t("editInternalResourceDialogModePort")}</SelectItem> */}
<SelectItem value="host">
{t(
"editInternalResourceDialogModeHost"
)}
</SelectItem>
<SelectItem value="cidr">
{t(
"editInternalResourceDialogModeCidr"
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* {mode === "port" && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)} */}
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogTargetConfiguration")}</h3>
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogTargetConfiguration"
)}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="destinationIp"
render={({ field }) => (
<FormItem>
<FormLabel>{t("targetAddr")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"editInternalResourceDialogDestination"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" &&
t(
"editInternalResourceDialogDestinationHostDescription"
)}
{mode === "cidr" &&
t(
"editInternalResourceDialogDestinationCidrDescription"
)}
{/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* {mode === "port" && (
<FormField
control={form.control}
name="destinationPort"
@@ -239,35 +586,263 @@ export default function EditInternalResourceDialog({
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)} */}
</div>
</div>
{/* Alias */}
{mode !== "cidr" && (
<div>
<FormField
control={form.control}
name="alias"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"editInternalResourceDialogAlias"
)}
</FormLabel>
<FormControl>
<Input
{...field}
value={
field.value ?? ""
}
/>
</FormControl>
<FormDescription>
{t(
"editInternalResourceDialogAliasDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* Access Control Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("resourceUsersRoles")}
</h3>
{loadingRolesUsers ? (
<div className="text-sm text-muted-foreground">
{t("loading")}
</div>
) : (
<div className="space-y-4">
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("roles")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={
form.getValues()
.roles || []
}
setTags={(
newRoles
) => {
form.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("users")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
form.getValues()
.users || []
}
size="sm"
setTags={(
newUsers
) => {
form.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{hasMachineClients && (
<FormField
control={form.control}
name="clients"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t(
"machineClients"
)}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeClientsTagIndex
}
setActiveTagIndex={
setActiveClientsTagIndex
}
placeholder={
t(
"accessClientSelect"
) ||
"Select machine clients"
}
size="sm"
tags={
form.getValues()
.clients ||
[]
}
setTags={(
newClients
) => {
form.setValue(
"clients",
newClients as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
machineClients
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
)}
</div>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("editInternalResourceDialogCancel")}
</Button>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("editInternalResourceDialogCancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="edit-internal-resource-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}
{t("editInternalResourceDialogSaveResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "./ui/button";
import { ArrowUpDown } from "lucide-react";
import CopyToClipboard from "./CopyToClipboard";
@@ -63,9 +64,10 @@ export default function GenerateLicenseKeysTable({
}
};
const columns: ColumnDef<GeneratedLicenseKey>[] = [
const columns: ExtendedColumnDef<GeneratedLicenseKey>[] = [
{
accessorKey: "licenseKey",
friendlyName: t("licenseKey"),
header: ({ column }) => {
return (
<Button
@@ -91,6 +93,7 @@ export default function GenerateLicenseKeysTable({
},
{
accessorKey: "instanceName",
friendlyName: t("instanceName"),
header: ({ column }) => {
return (
<Button
@@ -110,6 +113,7 @@ export default function GenerateLicenseKeysTable({
},
{
accessorKey: "valid",
friendlyName: t("valid"),
header: ({ column }) => {
return (
<Button
@@ -133,6 +137,7 @@ export default function GenerateLicenseKeysTable({
},
{
accessorKey: "type",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
@@ -155,6 +160,7 @@ export default function GenerateLicenseKeysTable({
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),
header: ({ column }) => {
return (
<Button

View File

@@ -81,17 +81,33 @@ export default function HealthCheckDialog({
hcMethod: z
.string()
.min(1, { message: t("healthCheckMethodRequired") }),
hcInterval: z.int()
hcInterval: z
.int()
.positive()
.min(5, { message: t("healthCheckIntervalMin") }),
hcTimeout: z.int()
hcTimeout: z
.int()
.positive()
.min(1, { message: t("healthCheckTimeoutMin") }),
hcStatus: z.int().positive().min(100).optional().nullable(),
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
hcHeaders: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
hcScheme: z.string().optional(),
hcHostname: z.string(),
hcPort: z.number().positive().gt(0).lte(65535),
hcPort: z
.string()
.min(1, { message: t("healthCheckPortInvalid") })
.refine(
(val) => {
const port = parseInt(val);
return port > 0 && port <= 65535;
},
{
message: t("healthCheckPortInvalid")
}
),
hcFollowRedirects: z.boolean(),
hcMode: z.string(),
hcUnhealthyInterval: z.int().positive().min(5),
@@ -128,7 +144,9 @@ export default function HealthCheckDialog({
hcHeaders: initialConfig?.hcHeaders,
hcScheme: getDefaultScheme(),
hcHostname: initialConfig?.hcHostname,
hcPort: initialConfig?.hcPort,
hcPort: initialConfig?.hcPort
? initialConfig.hcPort.toString()
: "",
hcFollowRedirects: initialConfig?.hcFollowRedirects,
hcMode: initialConfig?.hcMode,
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval,
@@ -142,10 +160,15 @@ export default function HealthCheckDialog({
try {
const currentValues = form.getValues();
const updatedValues = { ...currentValues, [fieldName]: value };
await onChanges({
// Convert hcPort from string to number before passing to parent
const configToSend: HealthCheckConfig = {
...updatedValues,
hcPort: parseInt(updatedValues.hcPort),
hcStatus: updatedValues.hcStatus || null
});
};
await onChanges(configToSend);
} catch (error) {
toast({
title: t("healthCheckError"),
@@ -213,14 +236,20 @@ export default function HealthCheckDialog({
{t("healthScheme")}
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
onValueChange={(
value
) => {
field.onChange(
value
);
handleFieldChange(
"hcScheme",
value
);
}}
defaultValue={field.value}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger>
@@ -284,10 +313,8 @@ export default function HealthCheckDialog({
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
e.target
.value;
field.onChange(
value
);
@@ -486,10 +513,6 @@ export default function HealthCheckDialog({
</FormItem>
)}
/>
<FormDescription>
{t("timeIsInSeconds")}
</FormDescription>
</div>
{/* Expected Response Codes */}
@@ -578,7 +601,9 @@ export default function HealthCheckDialog({
<HeadersInput
value={field.value}
onChange={(value) => {
field.onChange(value);
field.onChange(
value
);
handleFieldChange(
"hcHeaders",
value

View File

@@ -32,6 +32,9 @@ export function InvitationsDataTable<TData, TValue>({
searchColumn="email"
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="email"
stickyRightColumn="dots"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -66,14 +67,17 @@ export default function InvitationsTable({
}
};
const columns: ColumnDef<InvitationRow>[] = [
const columns: ExtendedColumnDef<InvitationRow>[] = [
{
accessorKey: "email",
header: t("email")
enableHiding: false,
friendlyName: t("email"),
header: () => (<span className="p-3">{t("email")}</span>)
},
{
accessorKey: "expiresAt",
header: t("expiresAt"),
friendlyName: t("expiresAt"),
header: () => (<span className="p-3">{t("expiresAt")}</span>),
cell: ({ row }) => {
const expiresAt = new Date(row.original.expiresAt);
const isExpired = expiresAt < new Date();
@@ -87,10 +91,13 @@ export default function InvitationsTable({
},
{
accessorKey: "role",
header: t("role")
friendlyName: t("role"),
header: () => (<span className="p-3">{t("role")}</span>)
},
{
id: "dots",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const invitation = row.original;
return (
@@ -119,13 +126,13 @@ export default function InvitationsTable({
</DropdownMenu>
<Button
variant={"secondary"}
variant={"outline"}
onClick={() => {
setIsRegenerateModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span>{t("inviteRegenerate")}</span>
{t("regenerate", { fallback: "Regenerate" })}
</Button>
</div>
);

View File

@@ -30,6 +30,7 @@ export async function Layout({
}: LayoutProps) {
const allCookies = await cookies();
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
const hasCookiePreference = sidebarStateCookie !== undefined;
const initialSidebarCollapsed =
sidebarStateCookie === "collapsed" ||
@@ -44,6 +45,7 @@ export async function Layout({
orgs={orgs}
navItems={navItems}
defaultSidebarCollapsed={initialSidebarCollapsed}
hasCookiePreference={hasCookiePreference}
/>
)}
@@ -72,7 +74,7 @@ export async function Layout({
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-20" // Add top padding only on desktop to account for fixed header
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
)}>
{children}
</div>

View File

@@ -43,17 +43,20 @@ interface LayoutSidebarProps {
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
defaultSidebarCollapsed: boolean;
hasCookiePreference: boolean;
}
export function LayoutSidebar({
orgId,
orgs,
navItems,
defaultSidebarCollapsed
defaultSidebarCollapsed,
hasCookiePreference
}: LayoutSidebarProps) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
defaultSidebarCollapsed
);
const [hasManualToggle, setHasManualToggle] = useState(hasCookiePreference);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
@@ -68,9 +71,26 @@ export function LayoutSidebar({
}
};
// Auto-collapse sidebar at 1650px or less, but only if no cookie preference exists
useEffect(() => {
setSidebarStateCookie(isSidebarCollapsed);
}, [isSidebarCollapsed]);
if (hasManualToggle) {
return; // Don't auto-collapse if user has manually toggled
}
const handleResize = () => {
// print inner width
if (typeof window !== "undefined") {
const shouldCollapse = window.innerWidth <= 1650;
setIsSidebarCollapsed(shouldCollapse);
}
};
// Set initial state based on window width
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [hasManualToggle]);
function loadFooterLinks(): { text: string; href?: string }[] | undefined {
if (!isUnlocked()) {
@@ -92,14 +112,14 @@ export function LayoutSidebar({
isSidebarCollapsed ? "w-16" : "w-64"
)}
>
<div className="p-4 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
isCollapsed={isSidebarCollapsed}
/>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<OrgSelector
orgId={orgId}
orgs={orgs}
isCollapsed={isSidebarCollapsed}
/>
</div>
<div className="px-2 pt-1">
{!isAdminPage && user.serverAdmin && (
<div className="pb-4">
@@ -138,8 +158,10 @@ export function LayoutSidebar({
</div>
</div>
<div className="p-4 flex flex-col gap-4 shrink-0">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
<div className="p-4 flex flex-col shrink-0">
<div className="mb-3">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
</div>
{build === "enterprise" && (
<div className="mb-3">
@@ -230,9 +252,12 @@ export function LayoutSidebar({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() =>
setIsSidebarCollapsed(!isSidebarCollapsed)
}
onClick={() => {
const newCollapsedState = !isSidebarCollapsed;
setIsSidebarCollapsed(newCollapsedState);
setHasManualToggle(true);
setSidebarStateCookie(newCollapsedState);
}}
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group z-1"
aria-label={
isSidebarCollapsed

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { DataTable } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import { Badge } from "@app/components/ui/badge";
@@ -30,9 +31,11 @@ export function LicenseKeysDataTable({
}: LicenseKeysDataTableProps) {
const t = useTranslations();
const columns: ColumnDef<LicenseKeyCache>[] = [
const columns: ExtendedColumnDef<LicenseKeyCache>[] = [
{
accessorKey: "licenseKey",
enableHiding: false,
friendlyName: t("licenseKey"),
header: ({ column }) => {
return (
<Button
@@ -58,6 +61,7 @@ export function LicenseKeysDataTable({
},
{
accessorKey: "valid",
friendlyName: t("valid"),
header: ({ column }) => {
return (
<Button
@@ -81,6 +85,7 @@ export function LicenseKeysDataTable({
},
{
accessorKey: "tier",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
@@ -103,6 +108,7 @@ export function LicenseKeysDataTable({
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),
header: ({ column }) => {
return (
<Button
@@ -123,10 +129,11 @@ export function LicenseKeysDataTable({
},
{
id: "delete",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="secondary"
<div className="flex items-center gap-2 justify-end">
<Button variant={"outline"}
onClick={() => onDelete(row.original)}
>
{t("delete")}
@@ -146,6 +153,9 @@ export function LicenseKeysDataTable({
searchColumn="licenseKey"
onAdd={onCreate}
addButtonText={t("licenseKeyAdd")}
enableColumnVisibility={true}
stickyLeftColumn="licenseKey"
stickyRightColumn="delete"
/>
);
}

View File

@@ -56,6 +56,10 @@ export default function LocaleSwitcher() {
{
value: "nb-NO",
label: "Norsk (Bokmål)"
},
{
value: "zh-TW",
label: "繁體中文"
}
]}
/>

View File

@@ -136,9 +136,6 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
}
}
console.log({
endDate
});
newSearch.set("timeEnd", endDate.toISOString());
}
router.replace(`${path}?${newSearch.toString()}`);
@@ -258,7 +255,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
<CardHeader className="flex flex-col gap-4">
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle className="text-muted-foreground">
<InfoSectionTitle>
{t("totalRequests")}
</InfoSectionTitle>
<InfoSectionContent>
@@ -266,7 +263,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle className="text-muted-foreground">
<InfoSectionTitle>
{t("totalBlocked")}
</InfoSectionTitle>
<InfoSectionContent>
@@ -283,7 +280,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
<Card className="w-full h-full flex flex-col gap-8">
<CardHeader>
<h3 className="font-medium">{t("requestsByDay")}</h3>
<h3 className="font-semibold">{t("requestsByDay")}</h3>
</CardHeader>
<CardContent>
<RequestChart
@@ -296,7 +293,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
<div className="grid lg:grid-cols-2 gap-5">
<Card className="w-full h-full">
<CardHeader>
<h3 className="font-medium">
<h3 className="font-semibold">
{t("requestsByCountry")}
</h3>
</CardHeader>
@@ -313,7 +310,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
<Card className="w-full h-full">
<CardHeader>
<h3 className="font-medium">{t("topCountries")}</h3>
<h3 className="font-semibold">{t("topCountries")}</h3>
</CardHeader>
<CardContent className="flex h-full flex-col gap-4">
<TopCountriesList
@@ -465,7 +462,7 @@ function TopCountriesList(props: TopCountriesListProps) {
return (
<div className="h-full flex flex-col gap-2">
{props.countries.length > 0 && (
<div className="grid grid-cols-7 text-sm text-muted-foreground font-medium h-4">
<div className="grid grid-cols-7 text-sm text-muted-foreground font-semibold h-4">
<div className="col-span-5">{t("countries")}</div>
<div className="text-end">{t("total")}</div>
<div className="text-end">%</div>
@@ -474,7 +471,7 @@ function TopCountriesList(props: TopCountriesListProps) {
{/* `aspect-475/335` is the same aspect ratio as the world map component */}
<ol className="w-full overflow-auto grid gap-1 aspect-475/335">
{props.countries.length === 0 && (
<div className="flex items-center justify-center size-full text-muted-foreground font-mono gap-1">
<div className="flex items-center justify-center size-full text-muted-foreground gap-1">
{props.isLoading ? (
<>
<LoaderIcon className="size-4 animate-spin" />{" "}

View File

@@ -257,7 +257,10 @@ export function LogDataTable<TData, TValue>({
? {}
: { getPaginationRowModel: getPaginationRowModel() }),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
// Disable client-side sorting for server-side pagination since data is already sorted on server
...(isServerPagination
? {}
: { getSortedRowModel: getSortedRowModel() }),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
@@ -269,6 +272,7 @@ export function LogDataTable<TData, TValue>({
}
: {}),
initialState: {
sorting: defaultSort ? [defaultSort] : [],
pagination: {
pageSize: pageSize,
pageIndex: currentPage

View File

@@ -58,16 +58,18 @@ export type LoginFormIDP = {
type LoginFormProps = {
redirect?: string;
onLogin?: () => void | Promise<void>;
onLogin?: (redirectUrl?: string) => void | Promise<void>;
idps?: LoginFormIDP[];
orgId?: string;
forceLogin?: boolean;
};
export default function LoginForm({
redirect,
onLogin,
idps,
orgId
orgId,
forceLogin
}: LoginFormProps) {
const router = useRouter();
@@ -141,7 +143,7 @@ export default function LoginForm({
try {
// Start WebAuthn authentication without email
const startResponse = await securityKeyStartProxy({});
const startResponse = await securityKeyStartProxy({}, forceLogin);
if (startResponse.error) {
setError(startResponse.message);
@@ -165,7 +167,8 @@ export default function LoginForm({
// Verify authentication
const verifyResponse = await securityKeyVerifyProxy(
{ credential },
tempSessionId
tempSessionId,
forceLogin
);
if (verifyResponse.error) {
@@ -175,7 +178,7 @@ export default function LoginForm({
if (verifyResponse.success) {
if (onLogin) {
await onLogin();
await onLogin(redirect);
}
}
} catch (error: any) {
@@ -234,12 +237,15 @@ export default function LoginForm({
setShowSecurityKeyPrompt(false);
try {
const response = await loginProxy({
const response = await loginProxy(
{
email,
password,
code,
resourceGuid: resourceGuid as string
});
},
forceLogin
);
try {
const identity = {
@@ -263,7 +269,7 @@ export default function LoginForm({
// Handle case where data is null (e.g., already logged in)
if (!data) {
if (onLogin) {
await onLogin();
await onLogin(redirect);
}
return;
}
@@ -312,7 +318,7 @@ export default function LoginForm({
}
if (onLogin) {
await onLogin();
await onLogin(redirect);
}
} catch (e: any) {
console.error(e);
@@ -333,7 +339,8 @@ export default function LoginForm({
const data = await generateOidcUrlProxy(
idpId,
redirect || "/",
orgId
orgId,
forceLogin
);
const url = data.data?.redirectUrl;
if (data.error) {
@@ -354,6 +361,15 @@ export default function LoginForm({
return (
<div className="space-y-4">
{forceLogin && (
<Alert variant="neutral">
<AlertDescription className="flex items-center gap-2">
<LockIcon className="w-4 h-4" />
{t("loginRequiredForDevice")}
</AlertDescription>
</Alert>
)}
{showSecurityKeyPrompt && (
<Alert>
<FingerprintIcon className="w-5 h-5 mr-2" />

View File

@@ -0,0 +1,430 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
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 { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
userId: string | null;
username: string | null;
userEmail: string | null;
};
type ClientTableProps = {
machineClients: ClientRow[];
orgId: string;
};
export default function MachineClientsTable({
machineClients,
orgId
}: ClientTableProps) {
const router = useRouter();
const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const defaultMachineColumnVisibility = {
client: false,
subnet: false,
userId: false
};
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
// Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => {
return machineClients.some((client) => !client.userId) ?? false;
}, [machineClients]);
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userId",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
User
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
friendlyName: "Connectivity",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Olm</span>
{originalRow.olmVersion && (
<span className="text-xs text-gray-500">
v{originalRow.olmVersion}
</span>
)}
</div>
</Badge>
{originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}
];
// Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) {
baseColumns.push({
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const clientRow = row.original;
return !clientRow.userId ? (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
Delete
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/machine/${clientRow.id}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
) : null;
}
});
}
return baseColumns;
}, [hasRowsWithoutUserId, t]);
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<DataTable
columns={columns}
data={machineClients || []}
persistPageSize="machine-clients"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() =>
router.push(`/${orgId}/settings/clients/machine/create`)
}
addButtonText={t("createClient")}
onRefresh={refreshData}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
persistColumnVisibility="machine-clients"
columnVisibility={defaultMachineColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}

View File

@@ -34,6 +34,9 @@ export function OrgApiKeysDataTable<TData, TValue>({
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t('apiKeysAdd')}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { OrgApiKeysDataTable } from "@app/components/OrgApiKeysDataTable";
import {
DropdownMenu,
@@ -88,9 +89,11 @@ export default function OrgApiKeysTable({
});
};
const columns: ColumnDef<OrgApiKeyRow>[] = [
const columns: ExtendedColumnDef<OrgApiKeyRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -107,7 +110,8 @@ export default function OrgApiKeysTable({
},
{
accessorKey: "key",
header: t("key"),
friendlyName: t("key"),
header: () => (<span className="p-3">{t("key")}</span>),
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
@@ -115,7 +119,8 @@ export default function OrgApiKeysTable({
},
{
accessorKey: "createdAt",
header: t("createdAt"),
friendlyName: t("createdAt"),
header: () => (<span className="p-3">{t("createdAt")}</span>),
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")}</span>;
@@ -123,10 +128,12 @@ export default function OrgApiKeysTable({
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -158,12 +165,9 @@ export default function OrgApiKeysTable({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
variant={"outline"}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />

View File

@@ -54,7 +54,6 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
role="combobox"
aria-expanded={open}
className={cn(
"shadow-2xs",
isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4"
)}
>
@@ -63,7 +62,7 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
) : (
<div className="flex items-center justify-between w-full min-w-0">
<div className="flex items-center min-w-0 flex-1">
<Building2 className="h-4 w-4 mr-2 shrink-0" />
<Building2 className="h-4 w-4 mr-3 shrink-0" />
<div className="flex flex-col items-start min-w-0 flex-1">
<span className="font-bold text-sm">
{t('org')}

View File

@@ -103,19 +103,14 @@ function getActionsCategories(root: boolean) {
[t('actionUpdateClient')]: "updateClient",
[t('actionListClients')]: "listClients",
[t('actionGetClient')]: "getClient"
},
"Logs": {
[t('actionExportLogs')]: "exportLogs",
[t('actionViewLogs')]: "viewLogs",
}
};
if (env.flags.enableClients) {
actionsByCategory["Clients"] = {
"Create Client": "createClient",
"Delete Client": "deleteClient",
"Update Client": "updateClient",
"List Clients": "listClients",
"Get Client": "getClient"
};
}
if (root) {
actionsByCategory["Organization"] = {
[t("actionListOrgs")]: "listOrgs",

View File

@@ -28,6 +28,9 @@ export function PolicyDataTable<TData, TValue>({
searchColumn="orgId"
addButtonText={t('orgPoliciesAdd')}
onAdd={onAdd}
enableColumnVisibility={true}
stickyLeftColumn="orgId"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
ArrowUpDown,
@@ -36,35 +37,11 @@ interface Props {
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
const t = useTranslations();
const columns: ColumnDef<PolicyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const r = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onDelete(r.orgId);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
const columns: ExtendedColumnDef<PolicyRow>[] = [
{
accessorKey: "orgId",
enableHiding: false,
friendlyName: t('orgId'),
header: ({ column }) => {
return (
<Button
@@ -81,6 +58,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
},
{
accessorKey: "roleMapping",
friendlyName: t('roleMapping'),
header: ({ column }) => {
return (
<Button
@@ -102,12 +80,13 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
info={mapping}
/>
) : (
"--"
"-"
);
}
},
{
accessorKey: "orgMapping",
friendlyName: t('orgMapping'),
header: ({ column }) => {
return (
<Button
@@ -129,19 +108,37 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
info={mapping}
/>
) : (
"--"
"-"
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const policy = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onDelete(policy.orgId);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
className="ml-2"
variant={"outline"}
onClick={() => onEdit(policy)}
>
{t('edit')}

View File

@@ -14,7 +14,7 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { Laptop, LogOut, Moon, Sun } from "lucide-react";
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react";
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -23,6 +23,7 @@ import Disable2FaForm from "./Disable2FaForm";
import SecurityKeyForm from "./SecurityKeyForm";
import Enable2FaDialog from "./Enable2FaDialog";
import ChangePasswordDialog from "./ChangePasswordDialog";
import ViewDevicesDialog from "./ViewDevicesDialog";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from "@app/components/LocaleSwitcher";
@@ -43,6 +44,7 @@ export default function ProfileIcon() {
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const [openSecurityKey, setOpenSecurityKey] = useState(false);
const [openChangePassword, setOpenChangePassword] = useState(false);
const [openViewDevices, setOpenViewDevices] = useState(false);
const t = useTranslations();
@@ -84,6 +86,10 @@ export default function ProfileIcon() {
open={openChangePassword}
setOpen={setOpenChangePassword}
/>
<ViewDevicesDialog
open={openViewDevices}
setOpen={setOpenViewDevices}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -146,6 +152,13 @@ export default function ProfileIcon() {
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => setOpenViewDevices(true)}
>
<Smartphone className="mr-2 h-4 w-4" />
<span>{t("viewDevices") || "View Devices"}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
{(["light", "dark", "system"] as const).map(
(themeOption) => (

View File

@@ -0,0 +1,571 @@
"use client";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios";
import {
ArrowRight,
ArrowUpDown,
CheckCircle2,
ChevronDown,
Clock,
MoreHorizontal,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown";
};
export type ResourceRow = {
id: number;
nice: string | null;
name: string;
orgId: string;
domain: string;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
};
function getOverallHealthStatus(
targets?: TargetHealth[]
): "online" | "degraded" | "offline" | "unknown" {
if (!targets || targets.length === 0) {
return "unknown";
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
if (monitoredTargets.length === 0) {
return "unknown";
}
const healthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "healthy"
).length;
const unhealthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "unhealthy"
).length;
if (healthyCount === monitoredTargets.length) {
return "online";
} else if (unhealthyCount === monitoredTargets.length) {
return "offline";
} else {
return "degraded";
}
}
function StatusIcon({
status,
className = ""
}: {
status: "online" | "degraded" | "offline" | "unknown";
className?: string;
}) {
const iconClass = `h-4 w-4 ${className}`;
switch (status) {
case "online":
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case "offline":
return <XCircle className={`${iconClass} text-destructive`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
default:
return null;
}
}
type ProxyResourcesTableProps = {
resources: ResourceRow[];
orgId: string;
defaultSort?: {
id: string;
desc: boolean;
};
};
export default function ProxyResourcesTable({
resources,
orgId,
defaultSort
}: ProxyResourcesTableProps) {
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const [isRefreshing, startTransition] = useTransition();
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
async function toggleResourceEnabled(val: boolean, resourceId: number) {
await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resourceId}`,
{
enabled: val
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
});
}
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
const overallStatus = getOverallHealthStatus(targets);
if (!targets || targets.length === 0) {
return (
<div className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm">
{t("resourcesTableNoTargets")}
</span>
</div>
);
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
const unknownTargets = targets.filter(
(t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown"
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8 px-0 font-normal"
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === "online" &&
t("resourcesTableHealthy")}
{overallStatus === "degraded" &&
t("resourcesTableDegraded")}
{overallStatus === "offline" &&
t("resourcesTableOffline")}
{overallStatus === "unknown" &&
t("resourcesTableUnknown")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[280px]">
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status={
target.healthStatus ===
"healthy"
? "online"
: "offline"
}
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span
className={`capitalize ${
target.healthStatus === "healthy"
? "text-green-500"
: "text-destructive"
}`}
>
{target.healthStatus}
</span>
</DropdownMenuItem>
))}
</>
)}
{unknownTargets.length > 0 && (
<>
{unknownTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status="unknown"
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled
? t("disabled")
: t("resourcesTableNotMonitored")}
</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
const proxyColumns: ExtendedColumnDef<ResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("niceId"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("niceId")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
},
{
accessorKey: "protocol",
friendlyName: t("protocol"),
header: () => <span className="p-3">{t("protocol")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<span>
{resourceRow.http
? resourceRow.ssl
? "HTTPS"
: "HTTP"
: resourceRow.protocol.toUpperCase()}
</span>
);
}
},
{
id: "status",
accessorKey: "status",
friendlyName: t("status"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} />;
},
sortingFn: (rowA, rowB) => {
const statusA = getOverallHealthStatus(rowA.original.targets);
const statusB = getOverallHealthStatus(rowB.original.targets);
const statusOrder = {
online: 3,
degraded: 2,
offline: 1,
unknown: 0
};
return statusOrder[statusA] - statusOrder[statusB];
}
},
{
accessorKey: "domain",
friendlyName: t("access"),
header: () => <span className="p-3">{t("access")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center space-x-2">
{!resourceRow.http ? (
<CopyToClipboard
text={resourceRow.proxyPort?.toString() || ""}
isLink={false}
/>
) : !resourceRow.domainId ? (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
)}
</div>
);
}
},
{
accessorKey: "authState",
friendlyName: t("authentication"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("authentication")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
{resourceRow.authState === "protected" ? (
<span className="flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 text-green-500" />
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 text-yellow-500" />
<span>{t("notProtected")}</span>
</span>
) : (
<span>-</span>
)}
</div>
);
}
},
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<Switch
defaultChecked={
row.original.http
? !!row.original.domainId && row.original.enabled
: row.original.enabled
}
disabled={
row.original.http ? !row.original.domainId : false
}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/proxy/${resourceRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResource(resourceRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${resourceRow.orgId}/settings/resources/proxy/${resourceRow.nice}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResource(null);
}}
dialog={
<div>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title={t("resourceDelete")}
/>
)}
<DataTable
columns={proxyColumns}
data={resources}
persistPageSize="proxy-resources"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() =>
router.push(`/${orgId}/settings/resources/proxy/create`)
}
addButtonText={t("resourceAdd")}
onRefresh={refreshData}
isRefreshing={isRefreshing}
defaultSort={defaultSort}
enableColumnVisibility={true}
persistColumnVisibility="proxy-resources"
columnVisibility={{ niceId: false }}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}

View File

@@ -34,8 +34,8 @@ import {
ResetPasswordBody,
ResetPasswordResponse
} from "@server/routers/auth";
import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "./ui/alert";
import { Loader2, InfoIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";
@@ -84,22 +84,23 @@ export default function ResetPasswordForm({
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const formSchema = z
.object({
email: z.email({ message: t('emailInvalid') }),
token: z.string().min(8, { message: t('tokenInvalid') }),
email: z.email({ message: t("emailInvalid") }),
token: z.string().min(8, { message: t("tokenInvalid") }),
password: passwordSchema,
confirmPassword: passwordSchema
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: t('passwordNotMatch')
message: t("passwordNotMatch")
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
code: z.string().length(6, { message: t("pincodeInvalid") })
});
const form = useForm({
@@ -139,8 +140,8 @@ export default function ResetPasswordForm({
} as RequestPasswordResetBody
)
.catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('passwordErrorRequestReset'), e);
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("passwordErrorRequestReset"), e);
setIsSubmitting(false);
});
@@ -169,8 +170,8 @@ export default function ResetPasswordForm({
} as ResetPasswordBody
)
.catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('passwordErrorReset'), e);
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("passwordErrorReset"), e);
setIsSubmitting(false);
});
@@ -186,7 +187,11 @@ export default function ResetPasswordForm({
return;
}
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
setSuccessMessage(
quickstart
? t("accountSetupSuccess")
: t("passwordResetSuccess")
);
// Auto-login after successful password reset
try {
@@ -208,7 +213,10 @@ export default function ResetPasswordForm({
try {
await api.post("/auth/verify-email/request");
} catch (verificationError) {
console.error("Failed to send verification code:", verificationError);
console.error(
"Failed to send verification code:",
verificationError
);
}
if (redirect) {
@@ -229,7 +237,6 @@ export default function ResetPasswordForm({
}
setIsSubmitting(false);
}, 1500);
} catch (loginError) {
// Auto-login failed, but password reset was successful
console.error("Auto-login failed:", loginError);
@@ -251,47 +258,70 @@ export default function ResetPasswordForm({
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
{quickstart
? t("completeAccountSetup")
: t("passwordReset")}
</CardTitle>
<CardDescription>
{quickstart
? t('completeAccountSetupDescription')
: t('passwordResetDescription')
}
? t("completeAccountSetupDescription")
: t("passwordResetDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{state === "request" && (
<Form {...requestForm}>
<form
onSubmit={requestForm.handleSubmit(
onRequest
)}
className="space-y-4"
id="form"
>
<FormField
control={requestForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t('accountSetupSent')
: t('passwordResetSent')
}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
<>
{!env.email.emailEnabled && (
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("passwordResetSmtpRequired")}
</AlertTitle>
<AlertDescription>
{t(
"passwordResetSmtpRequiredDescription"
)}
</AlertDescription>
</Alert>
)}
{env.email.emailEnabled && (
<Form {...requestForm}>
<form
onSubmit={requestForm.handleSubmit(
onRequest
)}
className="space-y-4"
id="form"
>
<FormField
control={requestForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t(
"accountSetupSent"
)
: t(
"passwordResetSent"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</>
)}
{state === "reset" && (
@@ -306,11 +336,13 @@ export default function ResetPasswordForm({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled
disabled={env.email.emailEnabled}
/>
</FormControl>
<FormMessage />
@@ -326,9 +358,12 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>
{quickstart
? t('accountSetupCode')
: t('passwordResetCode')
}
? t(
"accountSetupCode"
)
: t(
"passwordResetCode"
)}
</FormLabel>
<FormControl>
<Input
@@ -337,12 +372,17 @@ export default function ResetPasswordForm({
/>
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t('accountSetupCodeDescription')
: t('passwordResetCodeDescription')
}
</FormDescription>
{env.email.emailEnabled && (
<FormDescription>
{quickstart
? t(
"accountSetupCodeDescription"
)
: t(
"passwordResetCodeDescription"
)}
</FormDescription>
)}
</FormItem>
)}
/>
@@ -355,9 +395,8 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>
{quickstart
? t('passwordCreate')
: t('passwordNew')
}
? t("passwordCreate")
: t("passwordNew")}
</FormLabel>
<FormControl>
<Input
@@ -376,9 +415,12 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>
{quickstart
? t('passwordCreateConfirm')
: t('passwordNewConfirm')
}
? t(
"passwordCreateConfirm"
)
: t(
"passwordNewConfirm"
)}
</FormLabel>
<FormControl>
<Input
@@ -407,7 +449,7 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pincodeAuth')}
{t("pincodeAuth")}
</FormLabel>
<FormControl>
<div className="flex justify-center">
@@ -475,26 +517,45 @@ export default function ResetPasswordForm({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{state === "reset"
? (quickstart ? t('completeSetup') : t('passwordReset'))
: t('pincodeSubmit2')}
? quickstart
? t("completeSetup")
: t("passwordReset")
: t("pincodeSubmit2")}
</Button>
)}
{state === "request" && (
<Button
type="submit"
form="form"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<div className="flex flex-col gap-2">
{env.email.emailEnabled && (
<Button
type="submit"
form="form"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{quickstart
? t("accountSetupSubmit")
: t("passwordResetSubmit")}
</Button>
)}
{quickstart
? t('accountSetupSubmit')
: t('passwordResetSubmit')
}
</Button>
<Button
type="button"
className="w-full"
onClick={() => {
const email =
requestForm.getValues("email");
if (email) {
form.setValue("email", email);
}
setState("reset");
}}
>
{t("passwordResetAlreadyHaveCode")}
</Button>
</div>
)}
{state === "mfa" && (
@@ -507,7 +568,7 @@ export default function ResetPasswordForm({
mfaForm.reset();
}}
>
{t('passwordBack')}
{t("passwordBack")}
</Button>
)}
@@ -521,7 +582,7 @@ export default function ResetPasswordForm({
form.reset();
}}
>
{t('backToEmail')}
{t("backToEmail")}
</Button>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,9 @@ export function RolesDataTable<TData, TValue>({
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t('accessRolesAdd')}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -61,9 +62,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
}
};
const columns: ColumnDef<RoleRow>[] = [
const columns: ExtendedColumnDef<RoleRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -80,18 +83,20 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
},
{
accessorKey: "description",
header: t("description")
friendlyName: t("description"),
header: () => (<span className="p-3">{t("description")}</span>)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const roleRow = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<Button
variant={"secondary"}
size="sm"
variant={"outline"}
disabled={roleRow.isAdmin || false}
onClick={() => {
setIsDeleteModalOpen(true);

View File

@@ -36,6 +36,9 @@ export function ShareLinksDataTable<TData, TValue>({
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t('shareCreate')}
enableColumnVisibility={true}
stickyLeftColumn="resourceName"
stickyRightColumn="delete"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { ShareLinksDataTable } from "@app/components/ShareLinksDataTable";
import {
DropdownMenu,
@@ -102,9 +103,11 @@ export default function ShareLinksTable({
});
}
const columns: ColumnDef<ShareLinkRow>[] = [
const columns: ExtendedColumnDef<ShareLinkRow>[] = [
{
accessorKey: "resourceName",
enableHiding: false,
friendlyName: t("resource"),
header: ({ column }) => {
return (
<Button
@@ -121,8 +124,8 @@ export default function ShareLinksTable({
cell: ({ row }) => {
const r = row.original;
return (
<Link href={`/${orgId}/settings/resources/${r.resourceNiceId}`}>
<Button variant="outline" size="sm">
<Link href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}>
<Button variant="outline">
{r.resourceName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
@@ -132,6 +135,7 @@ export default function ShareLinksTable({
},
{
accessorKey: "title",
friendlyName: t("title"),
header: ({ column }) => {
return (
<Button
@@ -211,6 +215,7 @@ export default function ShareLinksTable({
// },
{
accessorKey: "createdAt",
friendlyName: t("created"),
header: ({ column }) => {
return (
<Button
@@ -231,6 +236,7 @@ export default function ShareLinksTable({
},
{
accessorKey: "expiresAt",
friendlyName: t("expires"),
header: ({ column }) => {
return (
<Button
@@ -254,10 +260,12 @@ export default function ShareLinksTable({
},
{
id: "delete",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center justify-end space-x-2">
<div className="flex items-center space-x-2 justify-end">
{/* <DropdownMenu> */}
{/* <DropdownMenuTrigger asChild> */}
{/* <Button variant="ghost" className="h-8 w-8 p-0"> */}
@@ -281,9 +289,7 @@ export default function ShareLinksTable({
{/* </DropdownMenuItem> */}
{/* </DropdownMenuContent> */}
{/* </DropdownMenu> */}
<Button
variant="secondary"
size="sm"
<Button variant={"outline"}
onClick={() =>
deleteSharelink(row.original.accessTokenId)
}

View File

@@ -14,14 +14,26 @@ import {
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { ChevronDown } from "lucide-react";
import { build } from "@server/build";
export type SidebarNavItem = {
href: string;
href?: string;
title: string;
icon?: React.ReactNode;
showEE?: boolean;
isBeta?: boolean;
items?: SidebarNavItem[];
};
export type SidebarNavSection = {
@@ -36,6 +48,104 @@ export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
isCollapsed?: boolean;
}
type CollapsibleNavItemProps = {
item: SidebarNavItem;
level: number;
isChildActive: boolean;
isDisabled: boolean;
isCollapsed: boolean;
renderNavItem: (item: SidebarNavItem, level: number) => React.ReactNode;
t: (key: string) => string;
build: string;
isUnlocked: () => boolean;
};
function CollapsibleNavItem({
item,
level,
isChildActive,
isDisabled,
isCollapsed,
renderNavItem,
t,
build,
isUnlocked
}: CollapsibleNavItemProps) {
const [isOpen, setIsOpen] = React.useState(isChildActive);
// Update open state when child active state changes
React.useEffect(() => {
if (isChildActive) {
setIsOpen(true);
}
}, [isChildActive]);
return (
<Collapsible
key={item.title}
open={isOpen}
onOpenChange={setIsOpen}
className="group/collapsible"
>
<CollapsibleTrigger asChild>
<button
className={cn(
"flex items-center w-full rounded transition-colors hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
level === 0 ? "p-3 py-1.5" : "py-1.5",
isChildActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 mr-2">{item.icon}</span>
)}
<div className="flex items-center gap-1.5 flex-1">
<span className="text-left">{t(item.title)}</span>
{item.isBeta && (
<Badge
variant="outline"
className="text-muted-foreground"
>
{t("beta")}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
<Badge variant="outlinePrimary">
{t("licenseBadge")}
</Badge>
)}
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-300 ease-in-out",
"group-data-[state=open]/collapsible:rotate-180"
)}
/>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div
className={cn(
"border-l ml-3 pl-2 mt-1 space-y-1",
"border-border"
)}
>
{item.items!.map((childItem) =>
renderNavItem(childItem, level + 1)
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
export function SidebarNav({
className,
sections,
@@ -56,7 +166,8 @@ export function SidebarNav({
const { user } = useUserContext();
const t = useTranslations();
function hydrateHref(val: string): string {
function hydrateHref(val?: string): string | undefined {
if (!val) return undefined;
return val
.replace("{orgId}", orgId)
.replace("{niceId}", niceId)
@@ -66,18 +177,56 @@ export function SidebarNav({
.replace("{clientId}", clientId);
}
function isItemOrChildActive(item: SidebarNavItem): boolean {
const hydratedHref = hydrateHref(item.href);
if (hydratedHref && pathname.startsWith(hydratedHref)) {
return true;
}
if (item.items) {
return item.items.some((child) => isItemOrChildActive(child));
}
return false;
}
const renderNavItem = (
item: SidebarNavItem,
hydratedHref: string,
isActive: boolean,
isDisabled: boolean
) => {
level: number = 0
): React.ReactNode => {
const hydratedHref = hydrateHref(item.href);
const hasNestedItems = item.items && item.items.length > 0;
const isActive = hydratedHref
? pathname.startsWith(hydratedHref)
: false;
const isChildActive = hasNestedItems
? isItemOrChildActive(item)
: false;
const isEE = build === "enterprise" && item.showEE && !isUnlocked();
const isDisabled = disabled || isEE;
const tooltipText =
item.showEE && !isUnlocked()
? `${t(item.title)} (${t("licenseBadge")})`
: t(item.title);
const itemContent = (
// If item has nested items, render as collapsible
if (hasNestedItems && !isCollapsed) {
return (
<CollapsibleNavItem
key={item.title}
item={item}
level={level}
isChildActive={isChildActive}
isDisabled={isDisabled || false}
isCollapsed={isCollapsed}
renderNavItem={renderNavItem}
t={t}
build={build}
isUnlocked={isUnlocked}
/>
);
}
// Regular item without nested items
const itemContent = hydratedHref ? (
<Link
href={isDisabled ? "#" : hydratedHref}
className={cn(
@@ -107,33 +256,179 @@ export function SidebarNav({
)}
{!isCollapsed && (
<>
<span>{t(item.title)}</span>
{item.isBeta && (
<Badge
variant="outline"
className="ml-2 text-muted-foreground"
>
{t("beta")}
</Badge>
)}
<div className="flex items-center gap-1.5 flex-1">
<span>{t(item.title)}</span>
{item.isBeta && (
<Badge
variant="outline"
className="text-muted-foreground"
>
{t("beta")}
</Badge>
)}
</div>
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
<Badge variant="outlinePrimary">
{t("licenseBadge")}
</Badge>
)}
</>
)}
</Link>
) : (
<div
className={cn(
"flex items-center rounded transition-colors px-3 py-1.5",
"text-muted-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
>
{item.icon && (
<span className="flex-shrink-0 mr-2">{item.icon}</span>
)}
<div className="flex items-center gap-1.5 flex-1">
<span>{t(item.title)}</span>
{item.isBeta && (
<Badge
variant="outline"
className="text-muted-foreground"
>
{t("beta")}
</Badge>
)}
</div>
{build === "enterprise" && item.showEE && !isUnlocked() && (
<Badge variant="outlinePrimary">{t("licenseBadge")}</Badge>
)}
</div>
);
if (isCollapsed) {
// If item has nested items, show both tooltip and popover
if (hasNestedItems) {
return (
<TooltipProvider key={item.title}>
<Tooltip>
<Popover>
<PopoverTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
"flex items-center rounded transition-colors hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-2 py-2 justify-center w-full",
isChildActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0">
{item.icon}
</span>
)}
</button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{tooltipText}</p>
</TooltipContent>
<PopoverContent
side="right"
align="start"
className="w-56 p-1"
>
<div className="space-y-1">
{item.items!.map((childItem) => {
const childHydratedHref =
hydrateHref(childItem.href);
const childIsActive =
childHydratedHref
? pathname.startsWith(
childHydratedHref
)
: false;
const childIsEE =
build === "enterprise" &&
childItem.showEE &&
!isUnlocked();
const childIsDisabled =
disabled || childIsEE;
if (!childHydratedHref) {
return null;
}
return (
<Link
key={childItem.title}
href={
childIsDisabled
? "#"
: childHydratedHref
}
className={cn(
"flex items-center rounded transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary text-primary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
onClick={(e) => {
if (childIsDisabled) {
e.preventDefault();
} else if (
onItemClick
) {
onItemClick();
}
}}
>
{childItem.icon && (
<span className="flex-shrink-0 mr-2">
{childItem.icon}
</span>
)}
<div className="flex items-center gap-1.5 flex-1">
<span>
{t(childItem.title)}
</span>
{childItem.isBeta && (
<Badge
variant="outline"
className="text-muted-foreground"
>
{t("beta")}
</Badge>
)}
</div>
{build === "enterprise" &&
childItem.showEE &&
!isUnlocked() && (
<Badge variant="outlinePrimary">
{t(
"licenseBadge"
)}
</Badge>
)}
</Link>
);
})}
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
);
}
// Regular item without nested items - show tooltip
return (
<TooltipProvider key={hydratedHref}>
<TooltipProvider key={item.title}>
<Tooltip>
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
@@ -144,9 +439,7 @@ export function SidebarNav({
);
}
return (
<React.Fragment key={hydratedHref}>{itemContent}</React.Fragment>
);
return <React.Fragment key={item.title}>{itemContent}</React.Fragment>;
};
return (
@@ -161,26 +454,12 @@ export function SidebarNav({
{sections.map((section) => (
<div key={section.heading} className="mb-2">
{!isCollapsed && (
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{t(section.heading)}
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wide">
{t(`${section.heading}`)}
</div>
)}
<div className="flex flex-col gap-1">
{section.items.map((item) => {
const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref);
const isEE =
build === "enterprise" &&
item.showEE &&
!isUnlocked();
const isDisabled = disabled || isEE;
return renderNavItem(
item,
hydratedHref,
isActive,
isDisabled || false
);
})}
{section.items.map((item) => renderNavItem(item, 0))}
</div>
</div>
))}

View File

@@ -202,7 +202,7 @@ export default function SignupForm({
: 58;
return (
<Card className="w-full max-w-md shadow-md">
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />

View File

@@ -35,7 +35,7 @@ export default function SiteInfoCard({ }: SiteInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={env.flags.enableClients ? 4 : 3}>
<InfoSections cols={4}>
<InfoSection>
<InfoSectionTitle>
{t("identifier")}
@@ -75,7 +75,7 @@ export default function SiteInfoCard({ }: SiteInfoCardProps) {
</InfoSectionContent>
</InfoSection>
{env.flags.enableClients && site.type == "newt" && (
{site.type == "newt" && (
<InfoSection>
<InfoSectionTitle>Address</InfoSectionTitle>
<InfoSectionContent>

View File

@@ -10,6 +10,8 @@ interface DataTableProps<TData, TValue> {
createSite?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
}
export function SitesDataTable<TData, TValue>({
@@ -17,7 +19,9 @@ export function SitesDataTable<TData, TValue>({
data,
createSite,
onRefresh,
isRefreshing
isRefreshing,
columnVisibility,
enableColumnVisibility
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -38,6 +42,10 @@ export function SitesDataTable<TData, TValue>({
id: "name",
desc: false
}}
columnVisibility={columnVisibility}
enableColumnVisibility={enableColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { Column, ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { SitesDataTable } from "@app/components/SitesDataTable";
import {
DropdownMenu,
@@ -106,9 +107,10 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
});
};
const columns: ColumnDef<SiteRow>[] = [
const columns: ExtendedColumnDef<SiteRow>[] = [
{
accessorKey: "name",
enableHiding: false,
header: ({ column }) => {
return (
<Button
@@ -123,8 +125,31 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
);
}
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("niceId"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("niceId")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
},
{
accessorKey: "online",
friendlyName: t("online"),
header: ({ column }) => {
return (
<Button
@@ -166,6 +191,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
accessorKey: "mbIn",
friendlyName: t("dataIn"),
header: ({ column }) => {
return (
<Button
@@ -185,6 +211,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
accessorKey: "mbOut",
friendlyName: t("dataOut"),
header: ({ column }) => {
return (
<Button
@@ -204,6 +231,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
accessorKey: "type",
friendlyName: t("connectionType"),
header: ({ column }) => {
return (
<Button
@@ -261,6 +289,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
header: ({ column }) => {
return (
<Button
@@ -299,48 +328,40 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
);
}
},
...(env.flags.enableClients
? [
{
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>
);
},
cell: ({ row }: { row: any }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
<span>{originalRow.address}</span>
</div>
) : (
"-"
);
}
}
]
: []),
{
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>
);
},
cell: ({ row }: { row: any }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
<span>{originalRow.address}</span>
</div>
) : (
"-"
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -369,11 +390,10 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"secondary"} size="sm">
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
@@ -395,9 +415,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}}
dialog={
<div className="">
<p>
{t("siteQuestionRemove")}
</p>
<p>{t("siteQuestionRemove")}</p>
<p>{t("siteMessageRemove")}</p>
</div>
}
@@ -416,6 +434,13 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}
onRefresh={refreshData}
isRefreshing={isRefreshing}
columnVisibility={{
niceId: false,
nice: false,
exitNode: false,
address: false
}}
enableColumnVisibility={true}
/>
</>
);

View File

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

View File

@@ -0,0 +1,421 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
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 { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
userId: string | null;
username: string | null;
userEmail: string | null;
};
type ClientTableProps = {
userClients: ClientRow[];
orgId: string;
};
export default function UserDevicesTable({ userClients }: ClientTableProps) {
const router = useRouter();
const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const defaultUserColumnVisibility = {
client: false,
subnet: false
};
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
// Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => {
return userClients.some((client) => !client.userId);
}, [userClients]);
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userEmail",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
User
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
}
},
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{
accessorKey: "online",
friendlyName: "Connectivity",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-2">
<span>Olm</span>
{originalRow.olmVersion && (
<span className="text-xs text-gray-500">
v{originalRow.olmVersion}
</span>
)}
</div>
</Badge>
{originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}
];
// Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) {
baseColumns.push({
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const clientRow = row.original;
return !clientRow.userId ? (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <Link */}
{/* className="block w-full" */}
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
{/* > */}
{/* <DropdownMenuItem> */}
{/* View settings */}
{/* </DropdownMenuItem> */}
{/* </Link> */}
<DropdownMenuItem
onClick={() => {
setSelectedClient(clientRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
Delete
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
>
<Button variant={"outline"}>
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
) : null;
}
});
}
return baseColumns;
}, [hasRowsWithoutUserId, t]);
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<DataTable
columns={columns}
data={userClients || []}
persistPageSize="user-clients"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onRefresh={refreshData}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
persistColumnVisibility="user-clients"
columnVisibility={defaultUserColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}

View File

@@ -36,6 +36,9 @@ export function UsersDataTable<TData, TValue>({
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t('accessUserCreate')}
enableColumnVisibility={true}
stickyLeftColumn="displayUsername"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -70,9 +71,11 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}
};
const columns: ColumnDef<UserRow>[] = [
const columns: ExtendedColumnDef<UserRow>[] = [
{
accessorKey: "displayUsername",
enableHiding: false,
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
@@ -89,6 +92,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
@@ -115,6 +119,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
},
{
accessorKey: "role",
friendlyName: t("role"),
header: ({ column }) => {
return (
<Button
@@ -133,9 +138,6 @@ export default function UsersTable({ users: u }: UsersTableProps) {
return (
<div className="flex flex-row items-center gap-2">
{userRow.isOwner && (
<Crown className="w-4 h-4 text-yellow-600" />
)}
<span>{userRow.role}</span>
</div>
);
@@ -143,82 +145,65 @@ export default function UsersTable({ users: u }: UsersTableProps) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex items-center justify-end">
<>
<div>
{userRow.isOwner && (
<MoreHorizontal className="h-4 w-4 opacity-0" />
)}
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
<div>
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
>
<DropdownMenuItem>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${user?.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
<span className="sr-only">
{t("openMenu")}
<span className="text-red-500">
{t("accessUserRemove")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
>
<DropdownMenuItem>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${user?.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
<span className="text-red-500">
{t(
"accessUserRemove"
)}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</>
{userRow.isOwner && (
<Button
variant={"secondary"}
className="ml-2"
size="sm"
disabled={true}
>
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
{!userRow.isOwner && (
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button
variant={"secondary"}
variant={"outline"}
className="ml-2"
size="sm"
disabled={userRow.isOwner}
>
{t("manage")}
@@ -274,9 +259,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}}
dialog={
<div>
<p>
{t("userQuestionOrgRemove")}
</p>
<p>{t("userQuestionOrgRemove")}</p>
<p>{t("userMessageOrgRemove")}</p>
</div>
}

View File

@@ -241,7 +241,7 @@ export default function VerifyEmailForm({
<Button
type="button"
variant={"secondary"}
variant={"outline"}
className="w-full"
onClick={logout}
>

View File

@@ -0,0 +1,246 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useTranslations } from "next-intl";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { ListUserOlmsResponse } from "@server/routers/olm";
import { ResponseT } from "@server/types/Response";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Loader2, RefreshCw } from "lucide-react";
import moment from "moment";
import { useUserContext } from "@app/hooks/useUserContext";
type ViewDevicesDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
type Device = {
olmId: string;
dateCreated: string;
version: string | null;
name: string | null;
clientId: number | null;
userId: string | null;
};
export default function ViewDevicesDialog({
open,
setOpen
}: ViewDevicesDialogProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const { user } = useUserContext();
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const fetchDevices = async () => {
setLoading(true);
try {
const res = await api.get<ResponseT<ListUserOlmsResponse>>(
`/user/${user?.userId}/olms`
);
if (res.data.success && res.data.data) {
setDevices(res.data.data.olms);
}
} catch (error: any) {
console.error("Error fetching devices:", error);
toast({
variant: "destructive",
title: t("errorLoadingDevices") || "Error loading devices",
description: formatAxiosError(
error,
t("failedToLoadDevices") || "Failed to load devices"
)
});
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) {
fetchDevices();
}
}, [open]);
const deleteDevice = async (olmId: string) => {
try {
await api.delete(`/user/${user?.userId}/olm/${olmId}`);
toast({
title: t("deviceDeleted") || "Device deleted",
description:
t("deviceDeletedDescription") ||
"The device has been successfully deleted."
});
setDevices(devices.filter((d) => d.olmId !== olmId));
setIsDeleteModalOpen(false);
setSelectedDevice(null);
} catch (error: any) {
console.error("Error deleting device:", error);
toast({
variant: "destructive",
title: t("errorDeletingDevice") || "Error deleting device",
description: formatAxiosError(
error,
t("failedToDeleteDevice") || "Failed to delete device"
)
});
}
};
function reset() {
setDevices([]);
setSelectedDevice(null);
setIsDeleteModalOpen(false);
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
reset();
}
}}
>
<CredenzaContent className="max-w-4xl">
<CredenzaHeader>
<CredenzaTitle>
{t("viewDevices") || "View Devices"}
</CredenzaTitle>
<CredenzaDescription>
{t("viewDevicesDescription") ||
"Manage your connected devices"}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : devices.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("noDevices") || "No devices found"}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-3">
{t("name") || "Name"}
</TableHead>
<TableHead>
{t("dateCreated") ||
"Date Created"}
</TableHead>
<TableHead>
{t("actions") || "Actions"}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device) => (
<TableRow key={device.olmId}>
<TableCell className="font-medium">
{device.name ||
t("unnamedDevice") ||
"Unnamed Device"}
</TableCell>
<TableCell>
{moment(
device.dateCreated
).format("lll")}
</TableCell>
<TableCell>
<Button
variant="outline"
onClick={() => {
setSelectedDevice(
device
);
setIsDeleteModalOpen(
true
);
}}
>
{t("delete") ||
"Delete"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">
{t("close") || "Close"}
</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedDevice && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) {
setSelectedDevice(null);
}
}}
dialog={
<div>
<p>
{t("deviceQuestionRemove") ||
"Are you sure you want to delete this device?"}
</p>
<p>
{t("deviceMessageRemove") ||
"This action cannot be undone."}
</p>
</div>
}
buttonText={t("deviceDeleteConfirm") || "Delete Device"}
onConfirm={async () => deleteDevice(selectedDevice.olmId)}
string={selectedDevice.name || selectedDevice.olmId}
title={t("deleteDevice") || "Delete Device"}
/>
)}
</>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { IdpDataTable } from "@app/components/private/OrgIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
@@ -59,9 +60,10 @@ export default function IdpTable({ idps, orgId }: Props) {
};
const columns: ColumnDef<IdpRow>[] = [
const columns: ExtendedColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
@@ -78,6 +80,7 @@ export default function IdpTable({ idps, orgId }: Props) {
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -94,6 +97,7 @@ export default function IdpTable({ idps, orgId }: Props) {
},
{
accessorKey: "type",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
@@ -117,10 +121,11 @@ export default function IdpTable({ idps, orgId }: Props) {
},
{
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -153,9 +158,7 @@ export default function IdpTable({ idps, orgId }: Props) {
</DropdownMenu>
<Link href={`/${orgId}/settings/idp/${siteRow.idpId}/general`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
variant={"outline"}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />

View File

@@ -46,7 +46,7 @@ export const tagVariants = cva(
capitalize: "capitalize"
},
interaction: {
clickable: "cursor-pointer hover:shadow-md",
clickable: "cursor-pointer hover:shadow-sm",
nonClickable: "cursor-default"
},
animation: {

View File

@@ -14,9 +14,9 @@ const alertVariants = cva(
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500",
info: "border-blue-500/50 border bg-blue-500/10 text-blue-800 dark:border-blue-400 [&>svg]:text-blue-500",
warning:
"border-yellow-500/50 border bg-yellow-500/10 text-yellow-500 dark:border-yellow-400 [&>svg]:text-yellow-500"
"border-yellow-500 border text-yellow-800 bg-yellow-500/20 dark:bg-yellow-800/20 dark:text-yellow-100 dark:border-yellow-700 [&>svg]:text-yellow-500"
}
},
defaultVariants: {

View File

@@ -11,22 +11,22 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xs",
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 shadow-2xs",
"bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 ",
outline:
"border border-input bg-card hover:bg-accent hover:text-accent-foreground shadow-2xs",
"border border-input bg-card hover:bg-accent hover:text-accent-foreground ",
outlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary shadow-2xs",
"border border-primary bg-card hover:bg-primary/10 text-primary ",
secondary:
"bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80 shadow-2xs",
"bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80 ",
ghost: "hover:bg-accent hover:text-accent-foreground",
squareOutlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary rounded-md shadow-2xs",
"border border-primary bg-card hover:bg-primary/10 text-primary rounded-md ",
squareOutline:
"border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md shadow-2xs",
"border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md ",
squareDefault:
"bg-primary text-primary-foreground hover:bg-primary/90 rounded-md shadow-2xs",
"bg-primary text-primary-foreground hover:bg-primary/90 rounded-md ",
text: "",
link: "text-primary underline-offset-4 hover:underline"
},

View File

@@ -70,7 +70,7 @@ function Calendar({
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
"has-focus:border-ring border-input has-focus:ring-ring/50 relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
@@ -201,7 +201,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}

View File

@@ -9,8 +9,14 @@ import {
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
getFilteredRowModel,
VisibilityState
} from "@tanstack/react-table";
// Extended ColumnDef type that includes optional friendlyName for column visibility dropdown
export type ExtendedColumnDef<TData, TValue = unknown> = ColumnDef<TData, TValue> & {
friendlyName?: string;
};
import {
Table,
TableBody,
@@ -23,7 +29,7 @@ import { Button } from "@app/components/ui/button";
import { useEffect, useMemo, useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search, RefreshCw } from "lucide-react";
import { Plus, Search, RefreshCw, Columns } from "lucide-react";
import {
Card,
CardContent,
@@ -32,16 +38,29 @@ import {
} from "@app/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
const STORAGE_KEYS = {
PAGE_SIZE: 'datatable-page-size',
getTablePageSize: (tableId?: string) =>
tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE
PAGE_SIZE: "datatable-page-size",
COLUMN_VISIBILITY: "datatable-column-visibility",
getTablePageSize: (tableId?: string) =>
tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE,
getTableColumnVisibility: (tableId?: string) =>
tableId
? `${tableId}-column-visibility`
: STORAGE_KEYS.COLUMN_VISIBILITY
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === 'undefined') return defaultSize;
if (typeof window === "undefined") return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
@@ -53,19 +72,61 @@ const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
}
}
} catch (error) {
console.warn('Failed to read page size from localStorage:', error);
console.warn("Failed to read page size from localStorage:", error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn('Failed to save page size to localStorage:', error);
console.warn("Failed to save page size to localStorage:", error);
}
};
const getStoredColumnVisibility = (
tableId?: string,
defaultVisibility?: Record<string, boolean>
): Record<string, boolean> => {
if (typeof window === "undefined") return defaultVisibility || {};
try {
const key = STORAGE_KEYS.getTableColumnVisibility(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
// Validate that it's an object
if (typeof parsed === "object" && parsed !== null) {
return parsed;
}
}
} catch (error) {
console.warn(
"Failed to read column visibility from localStorage:",
error
);
}
return defaultVisibility || {};
};
const setStoredColumnVisibility = (
visibility: Record<string, boolean>,
tableId?: string
): void => {
if (typeof window === "undefined") return;
try {
const key = STORAGE_KEYS.getTableColumnVisibility(tableId);
localStorage.setItem(key, JSON.stringify(visibility));
} catch (error) {
console.warn(
"Failed to save column visibility to localStorage:",
error
);
}
};
@@ -76,7 +137,7 @@ type TabFilter = {
};
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
columns: ExtendedColumnDef<TData, TValue>[];
data: TData[];
title?: string;
addButtonText?: string;
@@ -93,6 +154,11 @@ type DataTableProps<TData, TValue> = {
defaultTab?: string;
persistPageSize?: boolean | string;
defaultPageSize?: number;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
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")
};
export function DataTable<TData, TValue>({
@@ -109,13 +175,42 @@ export function DataTable<TData, TValue>({
tabs,
defaultTab,
persistPageSize = false,
defaultPageSize = 20
defaultPageSize = 20,
columnVisibility: defaultColumnVisibility,
enableColumnVisibility = false,
persistColumnVisibility = false,
stickyLeftColumn,
stickyRightColumn
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
// Determine table identifier for storage
const tableId = typeof persistPageSize === 'string' ? persistPageSize : undefined;
// Use persistPageSize string if provided, otherwise use persistColumnVisibility string, otherwise undefined
const tableId =
typeof persistPageSize === "string"
? persistPageSize
: typeof persistColumnVisibility === "string"
? persistColumnVisibility
: undefined;
// Auto-enable persistence if column visibility is enabled
// Use explicit persistColumnVisibility if provided, otherwise auto-enable when enableColumnVisibility is true and we have a tableId
const shouldPersistColumnVisibility =
persistColumnVisibility === true ||
typeof persistColumnVisibility === "string" ||
(enableColumnVisibility && tableId !== undefined);
// Compute initial column visibility (from localStorage if enabled, otherwise from prop/default)
const initialColumnVisibility = (() => {
if (shouldPersistColumnVisibility) {
return getStoredColumnVisibility(
tableId,
defaultColumnVisibility
);
}
return defaultColumnVisibility || {};
})();
// Initialize page size from storage or default
const [pageSize, setPageSize] = useState<number>(() => {
if (persistPageSize) {
@@ -123,12 +218,14 @@ export function DataTable<TData, TValue>({
}
return defaultPageSize;
});
const [sorting, setSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState<any>([]);
const [columnVisibility, setColumnVisibility] =
useState<VisibilityState>(initialColumnVisibility);
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
@@ -157,16 +254,19 @@ export function DataTable<TData, TValue>({
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
onColumnVisibilityChange: setColumnVisibility,
initialState: {
pagination: {
pageSize: pageSize,
pageIndex: 0
}
},
columnVisibility: initialColumnVisibility
},
state: {
sorting,
columnFilters,
globalFilter
globalFilter,
columnVisibility
}
});
@@ -174,7 +274,7 @@ export function DataTable<TData, TValue>({
const currentPageSize = table.getState().pagination.pageSize;
if (currentPageSize !== pageSize) {
table.setPageSize(pageSize);
// Persist to localStorage if enabled
if (persistPageSize) {
setStoredPageSize(pageSize, tableId);
@@ -182,6 +282,13 @@ export function DataTable<TData, TValue>({
}
}, [pageSize, table, persistPageSize, tableId]);
useEffect(() => {
// Persist column visibility to localStorage when it changes
if (shouldPersistColumnVisibility) {
setStoredColumnVisibility(columnVisibility, tableId);
}
}, [columnVisibility, shouldPersistColumnVisibility, tableId]);
const handleTabChange = (value: string) => {
setActiveTab(value);
// Reset to first page when changing tabs
@@ -192,13 +299,35 @@ export function DataTable<TData, TValue>({
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
table.setPageSize(newPageSize);
// Persist immediately when changed
if (persistPageSize) {
setStoredPageSize(newPageSize, tableId);
}
};
// 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>
@@ -239,78 +368,157 @@ export function DataTable<TData, TValue>({
</div>
<div className="flex items-center gap-2 sm:justify-end">
{onRefresh && (
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("refresh")}
</Button>
<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 && (
<Button onClick={onAdd}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
<div>
<Button onClick={onAdd}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: 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) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
<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>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
))}
</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">
<DataTablePagination
table={table}
<DataTablePagination
table={table}
onPageSizeChange={handlePageSizeChange}
/>
</div>

View File

@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:scale-95 data-[state=open]:scale-100 sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:scale-95 data-[state=open]:scale-100 sm:rounded-lg",
className
)}
{...props}

View File

@@ -44,7 +44,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-sm",
className
)}
{...props}
@@ -237,7 +237,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-sm",
className
)}
{...props}

View File

@@ -55,7 +55,7 @@ function InputOTPSlot({
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-2xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10",
className
)}
{...props}

View File

@@ -16,8 +16,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={showPassword ? "text" : "password"}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm shadow-2xs",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
@@ -43,8 +43,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm shadow-2xs",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}

View File

@@ -29,7 +29,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-sm outline-hidden",
className
)}
{...props}

View File

@@ -18,7 +18,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>

View File

@@ -36,7 +36,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-2xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
className
)}
{...props}
@@ -60,7 +60,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-sm",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className

View File

@@ -12,7 +12,7 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
"inline-flex h-9 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground cursor-pointer",
"inline-flex h-full items-center justify-center whitespace-nowrap rounded-sm px-3 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground cursor-pointer",
className
)}
{...props}

View File

@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-card px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-[color,box-shadow]",
"flex min-h-[80px] w-full rounded-md border border-input bg-card px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring/50 transition-[color,box-shadow]",
className
)}
ref={ref}