Merge branch 'dev' into user-compliance

This commit is contained in:
Owen
2025-10-27 10:37:53 -07:00
105 changed files with 8762 additions and 776 deletions

View File

@@ -26,6 +26,8 @@ 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;
@@ -36,6 +38,8 @@ export type ClientRow = {
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
};
type ClientTableProps = {
@@ -204,6 +208,45 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
);
}
},
{
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 }) => {
@@ -282,7 +325,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) {
{t("deleteClientQuestion")}
</p>
<p>
{t("clientMessageRemove")}
{t("clientMessageRemove")}
</p>
</div>
}

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
import { cn } from "@app/lib/cn";
interface FilterOption {
value: string;
label: string;
}
interface ColumnFilterProps {
options: FilterOption[];
selectedValue?: string;
onValueChange: (value: string | undefined) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
}
export function ColumnFilter({
options,
selectedValue,
onValueChange,
placeholder,
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className
}: ColumnFilterProps) {
const [open, setOpen] = useState(false);
const selectedOption = options.find(option => option.value === selectedValue);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm h-8 px-2",
!selectedValue && "text-muted-foreground",
className
)}
>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<span className="truncate">
{selectedOption ? selectedOption.label : placeholder}
</span>
</div>
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{/* Clear filter option */}
{selectedValue && (
<CommandItem
onSelect={() => {
onValueChange(undefined);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear filter
</CommandItem>
)}
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
onValueChange(
selectedValue === option.value ? undefined : option.value
);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedValue === option.value
? "opacity-100"
: "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -11,6 +11,7 @@ import {
FormDescription
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useMemo } from "react";
@@ -45,6 +46,8 @@ import {
import { useOrgContext } from "@app/hooks/useOrgContext";
import { build } from "@server/build";
import { toASCII, toUnicode } from 'punycode';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { useRouter } from "next/navigation";
// Helper functions for Unicode domain handling
@@ -96,7 +99,9 @@ const formSchema = z.object({
.min(1, "Domain is required")
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
.transform((val) => toPunycode(val)),
type: z.enum(["ns", "cname", "wildcard"])
type: z.enum(["ns", "cname", "wildcard"]),
certResolver: z.string().nullable().optional(),
preferWildcardCert: z.boolean().optional()
});
type FormValues = z.infer<typeof formSchema>;
@@ -107,6 +112,12 @@ type CreateDomainFormProps = {
onCreated?: (domain: CreateDomainResponse) => void;
};
// Example cert resolver options (replace with real API/fetch if needed)
const certResolverOptions = [
{ id: "default", title: "Default" },
{ id: "custom", title: "Custom Resolver" }
];
export default function CreateDomainForm({
open,
setOpen,
@@ -120,20 +131,32 @@ export default function CreateDomainForm({
const { toast } = useToast();
const { org } = useOrgContext();
const { env } = useEnvContext();
const router = useRouter();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
baseDomain: "",
type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns"
type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns",
certResolver: null,
preferWildcardCert: false
}
});
function reset() {
const baseDomain = form.watch("baseDomain");
const domainType = form.watch("type");
const punycodePreview = useMemo(() => {
if (!baseDomain) return "";
const punycode = toPunycode(baseDomain);
return punycode !== baseDomain.toLowerCase() ? punycode : "";
}, [baseDomain]);
const reset = () => {
form.reset();
setLoading(false);
setCreatedDomain(null);
}
};
async function onSubmit(values: FormValues) {
setLoading(true);
@@ -149,6 +172,7 @@ export default function CreateDomainForm({
description: t("domainCreatedDescription")
});
onCreated?.(domainData);
router.push(`/${org.org.orgId}/settings/domains/${domainData.domainId}`);
} catch (e) {
toast({
title: t("error"),
@@ -158,17 +182,9 @@ export default function CreateDomainForm({
} finally {
setLoading(false);
}
}
const baseDomain = form.watch("baseDomain");
const domainInputValue = form.watch("baseDomain") || "";
const punycodePreview = useMemo(() => {
if (!domainInputValue) return "";
const punycode = toPunycode(domainInputValue);
return punycode !== domainInputValue.toLowerCase() ? punycode : "";
}, [domainInputValue]);
};
// Domain type options
let domainOptions: any = [];
if (build != "oss" && env.flags.usePangolinDns) {
domainOptions = [
@@ -209,7 +225,6 @@ export default function CreateDomainForm({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!createdDomain ? (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -260,331 +275,95 @@ export default function CreateDomainForm({
</FormItem>
)}
/>
{domainType === "wildcard" && (
<>
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormLabel>{t("certResolver")}</FormLabel>
<FormControl>
<Select
value={
field.value === null ? "default" :
(field.value === "" || (field.value && field.value !== "default")) ? "custom" :
"default"
}
onValueChange={(val) => {
if (val === "default") {
field.onChange(null);
} else if (val === "custom") {
field.onChange("");
} else {
field.onChange(val);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t("selectCertResolver")} />
</SelectTrigger>
<SelectContent>
{certResolverOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t("enterCustomResolver")}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="preferWildcardCert"
render={({ field: checkboxField }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
<FormControl>
<CheckboxWithLabel
label={t("preferWildcardCert")}
checked={checkboxField.value}
onCheckedChange={checkboxField.onChange}
/>
</FormControl>
{/* <div className="space-y-1 leading-none">
<FormLabel>
{t("preferWildcardCert")}
</FormLabel>
</div> */}
</FormItem>
)}
/>
)}
</>
)}
</form>
</Form>
) : (
<div className="space-y-6">
<Alert variant="default">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainAddDnsRecords")}
</AlertTitle>
<AlertDescription>
{t("createDomainAddDnsRecordsDescription")}
</AlertDescription>
</Alert>
<div className="space-y-4">
{createdDomain.nsRecords &&
createdDomain.nsRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainNsRecords")}
</h3>
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
{t("createDomainRecord")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
NS
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(baseDomain)}
</span>
{fromPunycode(baseDomain) !== baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({baseDomain})
</span>
)}
</div>
</div>
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={index}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
{createdDomain.cnameRecords &&
createdDomain.cnameRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainCnameRecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.cnameRecords.map(
(cnameRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
CNAME
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(cnameRecord.baseDomain)}
</span>
{fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({cnameRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<CopyToClipboard
text={
cnameRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.aRecords &&
createdDomain.aRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainARecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.aRecords.map(
(aRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
A
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(aRecord.baseDomain)}
</span>
{fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({aRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<span className="text-sm font-mono">
{
aRecord.value
}
</span>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.txtRecords &&
createdDomain.txtRecords.length > 0 && (
<div>
<h3 className="font-medium mb-3">
{t("createDomainTxtRecords")}
</h3>
<InfoSections cols={1}>
{createdDomain.txtRecords.map(
(txtRecord, index) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
{t(
"createDomainRecordNumber",
{
number:
index +
1
}
)}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainType"
)}
</span>
<span className="text-sm font-mono">
TXT
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainName"
)}
</span>
<div className="text-right">
<span className="text-sm font-mono block">
{fromPunycode(txtRecord.baseDomain)}
</span>
{fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
<span className="text-xs text-muted-foreground font-mono">
({txtRecord.baseDomain})
</span>
)}
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
{t(
"createDomainValue"
)}
</span>
<CopyToClipboard
text={
txtRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
</div>
{build != "oss" && env.flags.usePangolinDns && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainSaveTheseRecords")}
</AlertTitle>
<AlertDescription>
{t(
"createDomainSaveTheseRecordsDescription"
)}
</AlertDescription>
</Alert>
)}
<Alert variant="info">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("createDomainDnsPropagation")}
</AlertTitle>
<AlertDescription>
{t("createDomainDnsPropagationDescription")}
</AlertDescription>
</Alert>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>

View File

@@ -0,0 +1,130 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { useTranslations } from "next-intl";
import { Badge } from "@app/components/ui/badge";
import { DNSRecordsDataTable } from "./DNSRecordsDataTable";
export type DNSRecordRow = {
id: string;
domainId: string;
recordType: string; // "NS" | "CNAME" | "A" | "TXT"
baseDomain: string | null;
value: string;
verified?: boolean;
};
type Props = {
records: DNSRecordRow[];
domainId: string;
isRefreshing?: boolean;
};
export default function DNSRecordsTable({ records, domainId, isRefreshing }: Props) {
const t = useTranslations();
const columns: ColumnDef<DNSRecordRow>[] = [
{
accessorKey: "baseDomain",
header: ({ column }) => {
return (
<div
>
{t("recordName", { fallback: "Record name" })}
</div>
);
},
cell: ({ row }) => {
const baseDomain = row.original.baseDomain;
return (
<div>
{baseDomain || "-"}
</div>
);
}
},
{
accessorKey: "recordType",
header: ({ column }) => {
return (
<div
>
{t("type")}
</div>
);
},
cell: ({ row }) => {
const type = row.original.recordType;
return (
<div className="">
{type}
</div>
);
}
},
{
accessorKey: "ttl",
header: ({ column }) => {
return (
<div
>
{t("TTL")}
</div>
);
},
cell: ({ row }) => {
return (
<div>
{t("auto")}
</div>
);
}
},
{
accessorKey: "value",
header: () => {
return <div>{t("value")}</div>;
},
cell: ({ row }) => {
const value = row.original.value;
return (
<div>
{value}
</div>
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return (
<div
>
{t("status")}
</div>
);
},
cell: ({ row }) => {
const verified = row.original.verified;
return (
verified ? (
<Badge variant="green">{t("verified")}</Badge>
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)
);
}
}
];
return (
<DNSRecordsDataTable
columns={columns}
data={records}
isRefreshing={isRefreshing}
/>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useMemo, useState } from "react";
import { ExternalLink, Plus, RefreshCw } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
type TabFilter = {
id: string;
label: string;
filterFn: (row: any) => boolean;
};
type DNSRecordsDataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
title?: string;
addButtonText?: string;
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
searchPlaceholder?: string;
searchColumn?: string;
defaultSort?: {
id: string;
desc: boolean;
};
tabs?: TabFilter[];
defaultTab?: string;
persistPageSize?: boolean | string;
defaultPageSize?: number;
};
export function DNSRecordsDataTable<TData, TValue>({
columns,
data,
title,
addButtonText,
onAdd,
onRefresh,
isRefreshing,
defaultSort,
tabs,
defaultTab,
}: DNSRecordsDataTableProps<TData, TValue>) {
const t = useTranslations();
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
// Apply tab filter to data
const filteredData = useMemo(() => {
if (!tabs || activeTab === "") {
return data;
}
const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
if (!activeTabFilter) {
return data;
}
return data.filter(activeTabFilter.filterFn);
}, [data, tabs, activeTab]);
const table = useReactTable({
data: filteredData,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
return (
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2 justify-between">
<div className="relative w-full sm:max-w-sm flex flex-row gap-4 items-center">
<h1 className="font-bold">{t("dnsRecord")}</h1>
<Badge variant="secondary">{t("required")}</Badge>
</div>
<Button
variant="outline"
>
<ExternalLink className="h-4 w-4 mr-1"/>
{t("howToAddRecords")}
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-secondary dark:bg-transparent">
{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>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{t("noResults")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -19,11 +19,21 @@ import { useTranslations } from "next-intl";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
onPageSizeChange?: (pageSize: number) => void;
onPageChange?: (pageIndex: number) => void;
totalCount?: number;
isServerPagination?: boolean;
isLoading?: boolean;
disabled?: boolean;
}
export function DataTablePagination<TData>({
table,
onPageSizeChange
onPageSizeChange,
onPageChange,
totalCount,
isServerPagination = false,
isLoading = false,
disabled = false
}: DataTablePaginationProps<TData>) {
const t = useTranslations();
@@ -37,14 +47,60 @@ export function DataTablePagination<TData>({
}
};
const handlePageNavigation = (action: 'first' | 'previous' | 'next' | 'last') => {
if (isServerPagination && onPageChange) {
const currentPage = table.getState().pagination.pageIndex;
const pageCount = table.getPageCount();
let newPage: number;
switch (action) {
case 'first':
newPage = 0;
break;
case 'previous':
newPage = Math.max(0, currentPage - 1);
break;
case 'next':
newPage = Math.min(pageCount - 1, currentPage + 1);
break;
case 'last':
newPage = pageCount - 1;
break;
default:
return;
}
if (newPage !== currentPage) {
onPageChange(newPage);
}
} else {
// Use table's built-in navigation for client-side pagination
switch (action) {
case 'first':
table.setPageIndex(0);
break;
case 'previous':
table.previousPage();
break;
case 'next':
table.nextPage();
break;
case 'last':
table.setPageIndex(table.getPageCount() - 1);
break;
}
}
};
return (
<div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2">
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={handlePageSizeChange}
disabled={disabled}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectTrigger className="h-8 w-[73px]" disabled={disabled}>
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
@@ -61,14 +117,21 @@ export function DataTablePagination<TData>({
<div className="flex items-center space-x-3 lg:space-x-8">
<div className="flex items-center justify-center text-sm font-medium">
{t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})}
{isServerPagination && totalCount !== undefined ? (
t('paginator', {
current: table.getState().pagination.pageIndex + 1,
last: Math.ceil(totalCount / table.getState().pagination.pageSize)
})
) : (
t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
onClick={() => handlePageNavigation('first')}
disabled={!table.getCanPreviousPage() || isLoading || disabled}
>
<span className="sr-only">{t('paginatorToFirst')}</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
@@ -76,8 +139,8 @@ export function DataTablePagination<TData>({
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
onClick={() => handlePageNavigation('previous')}
disabled={!table.getCanPreviousPage() || isLoading || disabled}
>
<span className="sr-only">{t('paginatorToPrevious')}</span>
<ChevronLeftIcon className="h-4 w-4" />
@@ -85,8 +148,8 @@ export function DataTablePagination<TData>({
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
onClick={() => handlePageNavigation('next')}
disabled={!table.getCanNextPage() || isLoading || disabled}
>
<span className="sr-only">{t('paginatorToNext')}</span>
<ChevronRightIcon className="h-4 w-4" />
@@ -94,10 +157,8 @@ export function DataTablePagination<TData>({
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() =>
table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()}
onClick={() => handlePageNavigation('last')}
disabled={!table.getCanNextPage() || isLoading || disabled}
>
<span className="sr-only">{t('paginatorToLast')}</span>
<DoubleArrowRightIcon className="h-4 w-4" />

View File

@@ -0,0 +1,215 @@
"use client";
import { ChevronDownIcon, CalendarIcon } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { Calendar } from "@app/components/ui/calendar";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { ChangeEvent, useEffect, useState } from "react";
export interface DateTimeValue {
date?: Date;
time?: string;
}
export interface DateTimePickerProps {
label?: string;
value?: DateTimeValue;
onChange?: (value: DateTimeValue) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
showTime?: boolean;
}
export function DateTimePicker({
label,
value,
onChange,
placeholder = "Select date & time",
className,
disabled = false,
showTime = true,
}: DateTimePickerProps) {
const [open, setOpen] = useState(false);
const [internalDate, setInternalDate] = useState<Date | undefined>(value?.date);
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
// Sync internal state with external value prop
useEffect(() => {
setInternalDate(value?.date);
setInternalTime(value?.time || "");
}, [value?.date, value?.time]);
const handleDateChange = (date: Date | undefined) => {
setInternalDate(date);
const newValue = { date, time: internalTime };
onChange?.(newValue);
};
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
const time = event.target.value;
setInternalTime(time);
const newValue = { date: internalDate, time };
onChange?.(newValue);
};
const getDisplayText = () => {
if (!internalDate) return placeholder;
const dateStr = internalDate.toLocaleDateString();
if (!showTime || !internalTime) return dateStr;
// Parse time and format in local timezone
const [hours, minutes, seconds] = internalTime.split(':');
const timeDate = new Date();
timeDate.setHours(parseInt(hours, 10), parseInt(minutes, 10), parseInt(seconds || '0', 10));
const timeStr = timeDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return `${dateStr} ${timeStr}`;
};
const hasValue = internalDate || (showTime && internalTime);
return (
<div className={cn("flex gap-4", className)}>
<div className="flex flex-col gap-2">
{label && (
<Label htmlFor="date-picker">
{label}
</Label>
)}
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date-picker"
disabled={disabled}
className={cn(
"justify-between font-normal",
showTime ? "w-48" : "w-32",
!hasValue && "text-muted-foreground"
)}
>
{getDisplayText()}
<ChevronDownIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
{showTime ? (
<div className="flex">
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
if (!showTime) {
setOpen(false);
}
}}
className="flex-grow w-[250px]"
/>
<div className="p-3 border-l">
<div className="flex flex-col gap-3">
<Label htmlFor="time-input" className="text-sm font-medium">
Time
</Label>
<Input
id="time-input"
type="time"
step="1"
value={internalTime}
onChange={handleTimeChange}
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
</div>
</div>
) : (
<Calendar
mode="single"
selected={internalDate}
captionLayout="dropdown"
onSelect={(date) => {
handleDateChange(date);
setOpen(false);
}}
/>
)}
</PopoverContent>
</Popover>
</div>
</div>
</div>
);
}
export interface DateRangePickerProps {
startLabel?: string;
endLabel?: string;
startValue?: DateTimeValue;
endValue?: DateTimeValue;
onStartChange?: (value: DateTimeValue) => void;
onEndChange?: (value: DateTimeValue) => void;
onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void;
className?: string;
disabled?: boolean;
showTime?: boolean;
}
export function DateRangePicker({
// startLabel = "From",
// endLabel = "To",
startValue,
endValue,
onStartChange,
onEndChange,
onRangeChange,
className,
disabled = false,
showTime = true,
}: DateRangePickerProps) {
const handleStartChange = (value: DateTimeValue) => {
onStartChange?.(value);
if (onRangeChange && endValue) {
onRangeChange(value, endValue);
}
};
const handleEndChange = (value: DateTimeValue) => {
onEndChange?.(value);
if (onRangeChange && startValue) {
onRangeChange(startValue, value);
}
};
return (
<div className={cn("flex gap-4 items-center", className)}>
<DateTimePicker
label="Start"
value={startValue}
onChange={handleStartChange}
placeholder="Start date & time"
disabled={disabled}
showTime={showTime}
/>
<DateTimePicker
label="End"
value={endValue}
onChange={handleEndChange}
placeholder="End date & time"
disabled={disabled}
showTime={showTime}
/>
</div>
);
}

View File

@@ -0,0 +1,397 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useDomainContext } from "@app/hooks/useDomainContext";
import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "./Settings";
import { Button } from "./ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from "@app/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Input } from "./ui/input";
import { useForm } from "react-hook-form";
import z from "zod";
import { toASCII } from "punycode";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { Switch } from "./ui/switch";
import { useEffect, useState } from "react";
import DNSRecordsTable, { DNSRecordRow } from "./DNSRecordTable";
import { createApiClient } from "@app/lib/api";
import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { Badge } from "./ui/badge";
type DomainInfoCardProps = {
orgId?: string;
domainId?: string;
};
// Helper functions for Unicode domain handling
function toPunycode(domain: string): string {
try {
const parts = toASCII(domain);
return parts;
} catch (error) {
return domain.toLowerCase();
}
}
function isValidDomainFormat(domain: string): boolean {
const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/;
if (!unicodeRegex.test(domain)) {
return false;
}
const parts = domain.split('.');
for (const part of parts) {
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
return false;
}
if (part.length > 63) {
return false;
}
}
if (domain.length > 253) {
return false;
}
return true;
}
const formSchema = z.object({
baseDomain: z
.string()
.min(1, "Domain is required")
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
.transform((val) => toPunycode(val)),
type: z.enum(["ns", "cname", "wildcard"]),
certResolver: z.string().nullable().optional(),
preferWildcardCert: z.boolean().optional()
});
type FormValues = z.infer<typeof formSchema>;
const certResolverOptions = [
{ id: "default", title: "Default" },
{ id: "custom", title: "Custom Resolver" }
];
export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) {
const { domain, updateDomain } = useDomainContext();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient(useEnvContext());
const { toast } = useToast();
const [dnsRecords, setDnsRecords] = useState<DNSRecordRow[]>([]);
const [loadingRecords, setLoadingRecords] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
baseDomain: "",
type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns",
certResolver: domain.certResolver ?? "",
preferWildcardCert: false
}
});
useEffect(() => {
if (domain.domainId) {
const certResolverValue = domain.certResolver && domain.certResolver.trim() !== ""
? domain.certResolver
: null;
form.reset({
baseDomain: domain.baseDomain || "",
type: (domain.type as "ns" | "cname" | "wildcard") || "wildcard",
certResolver: certResolverValue,
preferWildcardCert: domain.preferWildcardCert || false
});
}
}, [domain]);
const fetchDNSRecords = async (showRefreshing = false) => {
if (showRefreshing) {
setIsRefreshing(true);
} else {
setLoadingRecords(true);
}
try {
const response = await api.get<{ data: DNSRecordRow[] }>(
`/org/${orgId}/domain/${domainId}/dns-records`
);
setDnsRecords(response.data.data);
} catch (error) {
// Only show error if records exist (not a 404)
const err = error as any;
if (err?.response?.status !== 404) {
toast({
title: t("error"),
description: formatAxiosError(error),
variant: "destructive"
});
}
} finally {
setLoadingRecords(false);
setIsRefreshing(false);
}
};
useEffect(() => {
if (domain.domainId) {
fetchDNSRecords();
}
}, [domain.domainId]);
const onSubmit = async (values: FormValues) => {
if (!orgId || !domainId) {
toast({
title: t("error"),
description: t("orgOrDomainIdMissing", { fallback: "Organization or Domain ID is missing" }),
variant: "destructive"
});
return;
}
setSaveLoading(true);
try {
const response = await api.patch(
`/org/${orgId}/domain/${domainId}`,
{
certResolver: values.certResolver,
preferWildcardCert: values.preferWildcardCert
}
);
updateDomain({
...domain,
certResolver: values.certResolver || null,
preferWildcardCert: values.preferWildcardCert || false
});
toast({
title: t("success"),
description: t("domainSettingsUpdated", { fallback: "Domain settings updated successfully" }),
variant: "default"
});
} catch (error) {
toast({
title: t("error"),
description: formatAxiosError(error),
variant: "destructive"
});
} finally {
setSaveLoading(false);
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "ns":
return t("selectDomainTypeNsName");
case "cname":
return t("selectDomainTypeCnameName");
case "wildcard":
return t("selectDomainTypeWildcardName");
default:
return type;
}
};
return (
<>
<Alert>
<AlertDescription>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t("type")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{getTypeDisplay(domain.type ? domain.type : "")}
</span>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("status")}
</InfoSectionTitle>
<InfoSectionContent>
{domain.verified ? (
<Badge variant="green">{t("verified")}</Badge>
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
{loadingRecords ? (
<div className="space-y-4">
{t("loadingDNSRecords", { fallback: "Loading DNS Records..." })}
</div>
) : (
<DNSRecordsTable
domainId={domain.domainId}
records={dnsRecords}
isRefreshing={isRefreshing}
/>
)
}
{/* Domain Settings - Only show for wildcard domains */}
{domain.type === "wildcard" && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("domainSetting")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="domain-settings-form"
>
<>
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormLabel>{t("certResolver")}</FormLabel>
<FormControl>
<Select
value={
field.value === null ? "default" :
(field.value === "" || (field.value && field.value !== "default")) ? "custom" :
"default"
}
onValueChange={(val) => {
if (val === "default") {
field.onChange(null);
} else if (val === "custom") {
field.onChange("");
} else {
field.onChange(val);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t("selectCertResolver")} />
</SelectTrigger>
<SelectContent>
{certResolverOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="certResolver"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t("enterCustomResolver")}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("certResolver") !== null &&
form.watch("certResolver") !== "default" && (
<FormField
control={form.control}
name="preferWildcardCert"
render={({ field: switchField }) => (
<FormItem className="items-center space-y-2 mt-4">
<FormControl>
<div className="flex items-center space-x-2">
<Switch
checked={switchField.value}
onCheckedChange={switchField.onChange}
/>
<FormLabel>{t("preferWildcardCert")}</FormLabel>
</div>
</FormControl>
<FormDescription>
{t("preferWildcardCertDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="domain-settings-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
)}
</>
);
}

View File

@@ -3,7 +3,7 @@
import { ColumnDef } from "@tanstack/react-table";
import { DomainsDataTable } from "@app/components/DomainsDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown } from "lucide-react";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
@@ -15,6 +15,8 @@ import { useTranslations } from "next-intl";
import CreateDomainForm from "@app/components/CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
import Link from "next/link";
export type DomainRow = {
domainId: string;
@@ -24,13 +26,16 @@ export type DomainRow = {
failed: boolean;
tries: number;
configManaged: boolean;
certResolver: string;
preferWildcardCert: boolean;
};
type Props = {
domains: DomainRow[];
orgId: string;
};
export default function DomainsTable({ domains }: Props) {
export default function DomainsTable({ domains, orgId }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
@@ -205,12 +210,51 @@ export default function DomainsTable({ domains }: Props) {
>
{isRestarting
? t("restarting", {
fallback: "Restarting..."
})
fallback: "Restarting..."
})
: t("restart", { fallback: "Restart" })}
</Button>
)}
<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>
{/* <Button
variant="secondary"
size="sm"
disabled={domain.configManaged}
@@ -220,7 +264,7 @@ export default function DomainsTable({ domains }: Props) {
}}
>
{t("delete")}
</Button>
</Button> */}
</div>
);
}

View File

@@ -4,10 +4,10 @@ import React from "react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn";
import { buttonVariants } from "@/components/ui/button";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
export type HorizontalTabs = Array<{
title: string;
@@ -30,6 +30,7 @@ export function HorizontalTabs({
const pathname = usePathname();
const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
const t = useTranslations();
function hydrateHref(href: string) {

View File

@@ -0,0 +1,529 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
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,
Filter,
X,
Download,
ChevronRight,
ChevronDown
} from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl";
import { DateRangePicker, DateTimeValue } from "@app/components/DateTimePicker";
const STORAGE_KEYS = {
PAGE_SIZE: "datatable-page-size",
getTablePageSize: (tableId?: string) =>
tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE
};
export const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === "undefined") return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = parseInt(stored, 10);
// Validate that it's a reasonable page size
if (parsed > 0 && parsed <= 1000) {
return parsed;
}
}
} catch (error) {
console.warn("Failed to read page size from localStorage:", error);
}
return defaultSize;
};
export const setStoredPageSize = (pageSize: number, tableId?: string): void => {
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);
}
};
type TabFilter = {
id: string;
label: string;
filterFn: (row: any) => boolean;
};
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
title?: string;
addButtonText?: string;
onRefresh?: () => void;
onExport?: () => void;
isExporting?: boolean;
isRefreshing?: boolean;
searchPlaceholder?: string;
searchColumn?: string;
defaultSort?: {
id: string;
desc: boolean;
};
tabs?: TabFilter[];
defaultTab?: string;
disabled?: boolean;
onDateRangeChange?: (
startDate: DateTimeValue,
endDate: DateTimeValue
) => void;
dateRange?: {
start: DateTimeValue;
end: DateTimeValue;
};
// Server-side pagination props
totalCount?: number;
pageSize: number;
currentPage?: number;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
isLoading?: boolean;
// Row expansion props
expandable?: boolean;
renderExpandedRow?: (row: TData) => React.ReactNode;
};
export function LogDataTable<TData, TValue>({
columns,
data,
title,
onRefresh,
isRefreshing,
onExport,
isExporting,
// searchPlaceholder = "Search...",
// searchColumn = "name",
defaultSort,
tabs,
defaultTab,
onDateRangeChange,
pageSize,
dateRange,
totalCount,
currentPage = 0,
onPageChange,
onPageSizeChange: onPageSizeChangeProp,
isLoading = false,
expandable = false,
disabled=false,
renderExpandedRow
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
const [sorting, setSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState<any>([]);
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
const [startDate, setStartDate] = useState<DateTimeValue>(
dateRange?.start || {}
);
const [endDate, setEndDate] = useState<DateTimeValue>(dateRange?.end || {});
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
// Sync internal date state with external dateRange prop
useEffect(() => {
if (dateRange?.start) {
setStartDate(dateRange.start);
}
if (dateRange?.end) {
setEndDate(dateRange.end);
}
}, [dateRange?.start, dateRange?.end]);
// Apply tab filter to data
const filteredData = useMemo(() => {
// If disabled, return empty array to prevent data loading
if (disabled) {
return [];
}
if (!tabs || activeTab === "") {
return data;
}
const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
if (!activeTabFilter) {
return data;
}
return data.filter(activeTabFilter.filterFn);
}, [data, tabs, activeTab, disabled]);
// Toggle row expansion
const toggleRowExpansion = (rowId: string) => {
setExpandedRows((prev) => {
const newSet = new Set(prev);
if (newSet.has(rowId)) {
newSet.delete(rowId);
} else {
newSet.add(rowId);
}
return newSet;
});
};
// Determine if using server-side pagination
const isServerPagination = totalCount !== undefined;
// Create columns with expansion column if expandable
const enhancedColumns = useMemo(() => {
if (!expandable) {
return columns;
}
const expansionColumn: ColumnDef<TData, TValue> = {
id: "expand",
header: () => null,
cell: ({ row }) => {
const isExpanded = expandedRows.has(row.id);
return (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
disabled={disabled}
onClick={(e) => {
if (!disabled) {
toggleRowExpansion(row.id);
e.stopPropagation();
}
}}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
);
},
size: 40
};
return [expansionColumn, ...columns];
}, [columns, expandable, expandedRows, toggleRowExpansion, disabled]);
const table = useReactTable({
data: filteredData,
columns: enhancedColumns,
getCoreRowModel: getCoreRowModel(),
// Only use client-side pagination if totalCount is not provided
...(isServerPagination
? {}
: { getPaginationRowModel: getPaginationRowModel() }),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
// Configure pagination state
...(isServerPagination
? {
manualPagination: true,
pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0
}
: {}),
initialState: {
pagination: {
pageSize: pageSize,
pageIndex: currentPage
}
},
state: {
sorting,
columnFilters,
globalFilter,
pagination: {
pageSize: pageSize,
pageIndex: currentPage
}
}
});
// useEffect(() => {
// const currentPageSize = table.getState().pagination.pageSize;
// if (currentPageSize !== pageSize) {
// table.setPageSize(pageSize);
// // Persist to localStorage if enabled
// if (persistPageSize) {
// setStoredPageSize(pageSize, tableId);
// }
// }
// }, [pageSize, table, persistPageSize, tableId]);
// Update table page index when currentPage prop changes (server pagination)
useEffect(() => {
if (isServerPagination) {
const currentPageIndex = table.getState().pagination.pageIndex;
if (currentPageIndex !== currentPage) {
table.setPageIndex(currentPage);
}
}
}, [currentPage, table, isServerPagination]);
const handleTabChange = (value: string) => {
if (disabled) return;
setActiveTab(value);
// Reset to first page when changing tabs
table.setPageIndex(0);
};
// Enhanced pagination component that updates our local state
const handlePageSizeChange = (newPageSize: number) => {
if (disabled) return;
// setPageSize(newPageSize);
table.setPageSize(newPageSize);
// Persist immediately when changed
// if (persistPageSize) {
// setStoredPageSize(newPageSize, tableId);
// }
// For server pagination, notify parent component
if (isServerPagination && onPageSizeChangeProp) {
onPageSizeChangeProp(newPageSize);
}
};
// Handle page changes for server pagination
const handlePageChange = (newPageIndex: number) => {
if (disabled) return;
if (isServerPagination && onPageChange) {
onPageChange(newPageIndex);
}
};
const handleDateRangeChange = (
start: DateTimeValue,
end: DateTimeValue
) => {
if (disabled) return;
setStartDate(start);
setEndDate(end);
onDateRangeChange?.(start, end);
};
return (
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-row items-start w-full sm:mr-2 gap-2">
{/* <div className="relative w-full sm:max-w-sm">
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(e) =>
table.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8 m-0"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div> */}
<DateRangePicker
startValue={startDate}
endValue={endDate}
onRangeChange={handleDateRangeChange}
className="flex-wrap gap-2"
disabled={disabled}
/>
</div>
<div className="flex items-start gap-2 sm:justify-end">
{onRefresh && (
<Button
variant="outline"
onClick={() => !disabled && onRefresh()}
disabled={isRefreshing || disabled}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("refresh")}
</Button>
)}
{onExport && (
<Button onClick={() => !disabled && onExport()} disabled={isExporting || disabled}>
<Download
className={`mr-2 h-4 w-4 ${isExporting ? "animate-spin" : ""}`}
/>
{t("exportCsv")}
</Button>
)}
</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) => {
const isExpanded =
expandable && expandedRows.has(row.id);
return [
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
onClick={() =>
expandable && !disabled
? toggleRowExpansion(
row.id
)
: undefined
}
className="text-xs" // made smaller
>
{row
.getVisibleCells()
.map((cell) => {
const originalRow =
row.original as any;
const actionValue =
originalRow?.action;
let className = "";
if (
typeof actionValue ===
"boolean"
) {
className =
actionValue
? "bg-green-100 dark:bg-green-900/50"
: "bg-red-100 dark:bg-red-900/50";
}
return (
<TableCell
key={cell.id}
className={`${className} py-2`} // made smaller
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>,
isExpanded &&
renderExpandedRow && (
<TableRow
key={`${row.id}-expanded`}
>
<TableCell
colSpan={
enhancedColumns.length
}
className="p-4 bg-muted/50"
>
{renderExpandedRow(
row.original
)}
</TableCell>
</TableRow>
)
].filter(Boolean);
}).flat()
) : (
<TableRow>
<TableCell
colSpan={enhancedColumns.length}
className="h-24 text-center"
>
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination
table={table}
onPageSizeChange={handlePageSizeChange}
onPageChange={
isServerPagination
? handlePageChange
: undefined
}
totalCount={totalCount}
isServerPagination={isServerPagination}
isLoading={isLoading}
disabled={disabled}
/>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -22,7 +22,7 @@ import {
CardTitle
} from "@app/components/ui/card";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useRouter } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { LockIcon, FingerprintIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import {
@@ -74,6 +74,8 @@ export default function LoginForm({
const { env } = useEnvContext();
const api = createApiClient({ env });
const { resourceGuid } = useParams();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const hasIdp = idps && idps.length > 0;
@@ -235,7 +237,8 @@ export default function LoginForm({
const response = await loginProxy({
email,
password,
code
code,
resourceGuid: resourceGuid as string
});
try {

View File

@@ -0,0 +1,213 @@
"use client";
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@app/lib/cn";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
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",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
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",
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };