mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-12 16:06:38 +00:00
Merge branch 'dev' into feat/login-page-customization
This commit is contained in:
@@ -35,6 +35,9 @@ export function IdpDataTable<TData, TValue>({
|
||||
}}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -32,6 +32,9 @@ export function UsersDataTable<TData, TValue>({
|
||||
searchColumn="email"
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="username"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ export function ApiKeysDataTable<TData, TValue>({
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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`);
|
||||
}}
|
||||
|
||||
348
src/components/ClientResourcesTable.tsx
Normal file
348
src/components/ClientResourcesTable.tsx
Normal 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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
144
src/components/DeviceAuthConfirmation.tsx
Normal file
144
src/components/DeviceAuthConfirmation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
src/components/DeviceLoginForm.tsx
Normal file
303
src/components/DeviceLoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export function DomainsDataTable<TData, TValue>({
|
||||
onAdd={onAdd}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="baseDomain"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,9 @@ export function InvitationsDataTable<TData, TValue>({
|
||||
searchColumn="email"
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="email"
|
||||
stickyRightColumn="dots"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ export default function LocaleSwitcher() {
|
||||
{
|
||||
value: "nb-NO",
|
||||
label: "Norsk (Bokmål)"
|
||||
},
|
||||
{
|
||||
value: "zh-TW",
|
||||
label: "繁體中文"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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" />{" "}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
430
src/components/MachineClientsTable.tsx
Normal file
430
src/components/MachineClientsTable.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,9 @@ export function OrgApiKeysDataTable<TData, TValue>({
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -28,6 +28,9 @@ export function PolicyDataTable<TData, TValue>({
|
||||
searchColumn="orgId"
|
||||
addButtonText={t('orgPoliciesAdd')}
|
||||
onAdd={onAdd}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="orgId"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
571
src/components/ProxyResourcesTable.tsx
Normal file
571
src/components/ProxyResourcesTable.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -36,6 +36,9 @@ export function RolesDataTable<TData, TValue>({
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t('accessRolesAdd')}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -36,6 +36,9 @@ export function ShareLinksDataTable<TData, TValue>({
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t('shareCreate')}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="resourceName"
|
||||
stickyRightColumn="delete"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
421
src/components/UserDevicesTable.tsx
Normal file
421
src/components/UserDevicesTable.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,9 @@ export function UsersDataTable<TData, TValue>({
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t('accessUserCreate')}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="displayUsername"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ export default function VerifyEmailForm({
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
variant={"outline"}
|
||||
className="w-full"
|
||||
onClick={logout}
|
||||
>
|
||||
|
||||
246
src/components/ViewDevicesDialog.tsx
Normal file
246
src/components/ViewDevicesDialog.tsx
Normal 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"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user