Merge branch 'dev' into feat-blueprint-ui-on-dashboard

This commit is contained in:
Fred KISSIE
2025-10-29 03:31:51 +01:00
committed by GitHub
169 changed files with 14164 additions and 1207 deletions

View File

@@ -0,0 +1,87 @@
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import ChangePasswordForm from "@app/components/ChangePasswordForm";
import { useTranslations } from "next-intl";
type ChangePasswordDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
export default function ChangePasswordDialog({ open, setOpen }: ChangePasswordDialogProps) {
const t = useTranslations();
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const formRef = useRef<{ handleSubmit: () => void }>(null);
function reset() {
setCurrentStep(1);
setLoading(false);
}
const handleSubmit = () => {
if (formRef.current) {
formRef.current.handleSubmit();
}
};
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t('changePassword')}
</CredenzaTitle>
<CredenzaDescription>
{t('changePasswordDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<ChangePasswordForm
ref={formRef}
isDialog={true}
submitButtonText={t('submit')}
cancelButtonText="Close"
showCancelButton={false}
onComplete={() => setOpen(false)}
onStepChange={setCurrentStep}
onLoadingChange={setLoading}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(currentStep === 1 || currentStep === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t('submit')}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,647 @@
"use client";
import { useState, forwardRef, useImperativeHandle, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { CheckCircle2, Check, X } from "lucide-react";
import { Progress } from "@/components/ui/progress";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { useTranslations } from "next-intl";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "./ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { ChangePasswordResponse } from "@server/routers/auth";
import { cn } from "@app/lib/cn";
// Password strength calculation
const calculatePasswordStrength = (password: string) => {
const requirements = {
length: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /[0-9]/.test(password),
special: /[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]/.test(password)
};
const score = Object.values(requirements).filter(Boolean).length;
let strength: "weak" | "medium" | "strong" = "weak";
let color = "bg-red-500";
let percentage = 0;
if (score >= 5) {
strength = "strong";
color = "bg-green-500";
percentage = 100;
} else if (score >= 3) {
strength = "medium";
color = "bg-yellow-500";
percentage = 60;
} else if (score >= 1) {
strength = "weak";
color = "bg-red-500";
percentage = 30;
}
return { requirements, strength, color, percentage, score };
};
type ChangePasswordFormProps = {
onComplete?: () => void;
onCancel?: () => void;
isDialog?: boolean;
submitButtonText?: string;
cancelButtonText?: string;
showCancelButton?: boolean;
onStepChange?: (step: number) => void;
onLoadingChange?: (loading: boolean) => void;
};
const ChangePasswordForm = forwardRef<
{ handleSubmit: () => void },
ChangePasswordFormProps
>(
(
{
onComplete,
onCancel,
isDialog = false,
submitButtonText,
cancelButtonText,
showCancelButton = false,
onStepChange,
onLoadingChange
},
ref
) => {
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [newPasswordValue, setNewPasswordValue] = useState("");
const [confirmPasswordValue, setConfirmPasswordValue] = useState("");
const api = createApiClient(useEnvContext());
const t = useTranslations();
const passwordStrength = calculatePasswordStrength(newPasswordValue);
const doPasswordsMatch =
newPasswordValue.length > 0 &&
confirmPasswordValue.length > 0 &&
newPasswordValue === confirmPasswordValue;
// Notify parent of step and loading changes
useEffect(() => {
onStepChange?.(step);
}, [step, onStepChange]);
useEffect(() => {
onLoadingChange?.(loading);
}, [loading, onLoadingChange]);
const passwordSchema = z.object({
oldPassword: z.string().min(1, { message: t("passwordRequired") }),
newPassword: z.string().min(8, { message: t("passwordRequirementsChars") }),
confirmPassword: z.string().min(1, { message: t("passwordRequired") })
}).refine((data) => data.newPassword === data.confirmPassword, {
message: t("passwordsDoNotMatch"),
path: ["confirmPassword"],
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t("pincodeInvalid") })
});
const passwordForm = useForm({
resolver: zodResolver(passwordSchema),
defaultValues: {
oldPassword: "",
newPassword: "",
confirmPassword: ""
}
});
const mfaForm = useForm({
resolver: zodResolver(mfaSchema),
defaultValues: {
code: ""
}
});
const changePassword = async (values: z.infer<typeof passwordSchema>) => {
setLoading(true);
const endpoint = `/auth/change-password`;
const payload = {
oldPassword: values.oldPassword,
newPassword: values.newPassword
};
const res = await api
.post<AxiosResponse<ChangePasswordResponse>>(endpoint, payload)
.catch((e) => {
toast({
title: t("changePasswordError"),
description: formatAxiosError(
e,
t("changePasswordErrorDescription")
),
variant: "destructive"
});
});
if (res && res.data) {
if (res.data.data?.codeRequested) {
setStep(2);
} else {
setStep(3);
}
}
setLoading(false);
};
const confirmMfa = async (values: z.infer<typeof mfaSchema>) => {
setLoading(true);
const endpoint = `/auth/change-password`;
const passwordValues = passwordForm.getValues();
const payload = {
oldPassword: passwordValues.oldPassword,
newPassword: passwordValues.newPassword,
code: values.code
};
const res = await api
.post<AxiosResponse<ChangePasswordResponse>>(endpoint, payload)
.catch((e) => {
toast({
title: t("changePasswordError"),
description: formatAxiosError(
e,
t("changePasswordErrorDescription")
),
variant: "destructive"
});
});
if (res && res.data) {
setStep(3);
}
setLoading(false);
};
const handleSubmit = () => {
if (step === 1) {
passwordForm.handleSubmit(changePassword)();
} else if (step === 2) {
mfaForm.handleSubmit(confirmMfa)();
}
};
const handleComplete = () => {
if (onComplete) {
onComplete();
}
};
useImperativeHandle(ref, () => ({
handleSubmit
}));
return (
<div className="space-y-4">
{step === 1 && (
<Form {...passwordForm}>
<form
onSubmit={passwordForm.handleSubmit(changePassword)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={passwordForm.control}
name="oldPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("oldPassword")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("newPassword")}
</FormLabel>
{passwordStrength.strength ===
"strong" && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setNewPasswordValue(
e.target.value
);
}}
className={cn(
passwordStrength.strength ===
"strong" &&
"border-green-500 focus-visible:ring-green-500",
passwordStrength.strength ===
"medium" &&
"border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength ===
"weak" &&
newPasswordValue.length >
0 &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{newPasswordValue.length > 0 && (
<div className="space-y-3 mt-2">
{/* Password Strength Meter */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">
{t("passwordStrength")}
</span>
<span
className={cn(
"text-sm font-semibold",
passwordStrength.strength ===
"strong" &&
"text-green-600 dark:text-green-400",
passwordStrength.strength ===
"medium" &&
"text-yellow-600 dark:text-yellow-400",
passwordStrength.strength ===
"weak" &&
"text-red-600 dark:text-red-400"
)}
>
{t(
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
)}
</span>
</div>
<Progress
value={
passwordStrength.percentage
}
className="h-2"
/>
</div>
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">
{t("passwordRequirements")}
</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.length ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.length
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLengthText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.uppercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.uppercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementUppercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.lowercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.lowercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLowercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.number ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.number
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementNumberText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength
.requirements
.special ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.special
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementSpecialText"
)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Only show FormMessage when not showing our custom requirements */}
{newPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>
{t("confirmNewPassword")}
</FormLabel>
{doPasswordsMatch && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<FormControl>
<div className="relative">
<Input
type="password"
{...field}
onChange={(e) => {
field.onChange(e);
setConfirmPasswordValue(
e.target.value
);
}}
className={cn(
doPasswordsMatch &&
"border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length >
0 &&
!doPasswordsMatch &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{confirmPasswordValue.length > 0 &&
!doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<div className="text-center">
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
<p className="text-sm text-muted-foreground">
{t("otpAuthDescription")}
</p>
</div>
<Form {...mfaForm}>
<form
onSubmit={mfaForm.handleSubmit(confirmMfa)}
className="space-y-4"
id="form"
>
<FormField
control={mfaForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={6}
{...field}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(
value: string
) => {
field.onChange(value);
if (
value.length === 6
) {
mfaForm.handleSubmit(
confirmMfa
)();
}
}}
>
<InputOTPGroup>
<InputOTPSlot
index={0}
/>
<InputOTPSlot
index={1}
/>
<InputOTPSlot
index={2}
/>
<InputOTPSlot
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
)}
{step === 3 && (
<div className="space-y-4 text-center">
<CheckCircle2
className="mx-auto text-green-500"
size={48}
/>
<p className="font-semibold text-lg">
{t("changePasswordSuccess")}
</p>
<p>{t("changePasswordSuccessDescription")}</p>
</div>
)}
{/* Action buttons - only show when not in dialog */}
{!isDialog && (
<div className="flex gap-2 justify-end">
{showCancelButton && onCancel && (
<Button
variant="outline"
onClick={onCancel}
disabled={loading}
>
{cancelButtonText || "Cancel"}
</Button>
)}
{(step === 1 || step === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
className="w-full"
>
{submitButtonText || t("submit")}
</Button>
)}
{step === 3 && (
<Button
onClick={handleComplete}
className="w-full"
>
{t("continueToApplication")}
</Button>
)}
</div>
)}
</div>
);
}
);
export default ChangePasswordForm;

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

@@ -54,6 +54,7 @@ type InviteUserFormProps = {
dialog: React.ReactNode;
buttonText: string;
onConfirm: () => Promise<void>;
warningText?: string;
};
export default function InviteUserForm({
@@ -63,7 +64,8 @@ export default function InviteUserForm({
title,
onConfirm,
buttonText,
dialog
dialog,
warningText
}: InviteUserFormProps) {
const [loading, setLoading] = useState(false);
@@ -86,13 +88,20 @@ export default function InviteUserForm({
function reset() {
form.reset();
setLoading(false);
}
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
await onConfirm();
reset();
try {
await onConfirm();
setOpen(false);
reset();
} catch (error) {
// Handle error if needed
console.error("Confirmation failed:", error);
} finally {
setLoading(false);
}
}
return (
@@ -111,8 +120,8 @@ export default function InviteUserForm({
<CredenzaBody>
<div className="mb-4 break-all overflow-hidden">
{dialog}
<div className="mt-2 mb-6 font-bold text-red-700">
{t("cannotbeUndone")}
<div className="mt-2 mb-6 font-bold text-destructive">
{warningText || t("cannotbeUndone")}
</div>
<div>

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

@@ -98,6 +98,7 @@ export default function CreateShareLinkForm({
{
resourceId: number;
name: string;
niceId: string;
resourceUrl: string;
}[]
>([]);
@@ -160,6 +161,7 @@ export default function CreateShareLinkForm({
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
}))
);
@@ -233,6 +235,7 @@ export default function CreateShareLinkForm({
accessTokenId: token.accessTokenId,
resourceId: token.resourceId,
resourceName: values.resourceName,
resourceNiceId: resource ? resource.niceId : "",
title: token.title,
createdAt: token.createdAt,
expiresAt: token.expiresAt

View File

@@ -0,0 +1,106 @@
"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;
type: string | null;
};
export default function DNSRecordsTable({
records,
domainId,
isRefreshing,
type
}: 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 ? (
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", { fallback: "Manual" })}
</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
);
}
}
];
return (
<DNSRecordsDataTable
columns={columns}
data={records}
isRefreshing={isRefreshing}
type={type}
/>
);
}

View File

@@ -0,0 +1,179 @@
"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";
import Link from "next/link";
import { build } from "@server/build";
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;
type?: string | null;
};
export function DNSRecordsDataTable<TData, TValue>({
columns,
data,
title,
addButtonText,
onAdd,
onRefresh,
isRefreshing,
defaultSort,
tabs,
defaultTab,
type
}: 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>
<Link href="https://docs.pangolin.net/manage/domains">
<Button variant="outline">
<ExternalLink className="h-4 w-4 mr-1" />
{t("howToAddRecords")}
</Button>
</Link>
</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,492 @@
"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 {
if (!values.certResolver) {
values.certResolver = null;
}
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 ? (
domain.type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", {
fallback: "Manual"
})}
</Badge>
) : (
<Badge variant="green">
{t("verified")}
</Badge>
)
) : (
<Badge variant="yellow">
{t("pending", { fallback: "Pending" })}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
<DNSRecordsTable
domainId={domain.domainId}
records={dnsRecords}
isRefreshing={isRefreshing}
type={domain.type}
/>
{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,13 @@ 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 +31,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>(
@@ -174,9 +184,15 @@ export default function DomainsTable({ domains }: Props) {
);
},
cell: ({ row }) => {
const { verified, failed } = row.original;
const { verified, failed, type } = row.original;
if (verified) {
return <Badge variant="green">{t("verified")}</Badge>;
type === "wildcard" ? (
<Badge variant="outlinePrimary">
{t("manual", { fallback: "Manual" })}
</Badge>
) : (
<Badge variant="green">{t("verified")}</Badge>
);
} else if (failed) {
return (
<Badge variant="destructive">
@@ -210,7 +226,51 @@ export default function DomainsTable({ domains }: Props) {
: 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 +280,7 @@ export default function DomainsTable({ domains }: Props) {
}}
>
{t("delete")}
</Button>
</Button> */}
</div>
);
}
@@ -238,12 +298,8 @@ export default function DomainsTable({ domains }: Props) {
}}
dialog={
<div>
<p>
{t("domainQuestionRemove")}
</p>
<p>
{t("domainMessageRemove")}
</p>
<p>{t("domainQuestionRemove")}</p>
<p>{t("domainMessageRemove")}</p>
</div>
}
buttonText={t("domainConfirmDelete")}

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

@@ -31,6 +31,7 @@ import {
} from "@app/components/ui/tooltip";
import { build } from "@server/build";
import SidebarLicenseButton from "./SidebarLicenseButton";
import { SidebarSupportButton } from "./SidebarSupportButton";
interface LayoutSidebarProps {
orgId?: string;
@@ -145,6 +146,11 @@ export function LayoutSidebar({
<SupporterStatus isCollapsed={isSidebarCollapsed} />
</div>
)}
{build === "saas" && (
<div className="mb-3">
<SidebarSupportButton isCollapsed={isSidebarCollapsed} />
</div>
)}
{!isSidebarCollapsed && (
<div className="space-y-2">
{loadFooterLinks() ? (

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,59 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Shield, ArrowRight } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
type OrgPolicyRequiredProps = {
orgId: string;
policies: {
requiredTwoFactor?: boolean;
};
};
export default function OrgPolicyRequired({
orgId,
policies
}: OrgPolicyRequiredProps) {
const t = useTranslations();
const policySteps = [];
if (policies?.requiredTwoFactor === false) {
policySteps.push(t("enableTwoFactorAuthentication"));
}
return (
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-orange-100">
<Shield className="h-6 w-6 text-orange-600" />
</div>
<CardTitle className="text-xl font-semibold">
{t("additionalSecurityRequired")}
</CardTitle>
<CardDescription>
{t("organizationRequiresAdditionalSteps")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="pt-4">
<Link href={`/${orgId}`}>
<Button className="w-full">
{t("completeSecuritySteps")}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,229 @@
"use client";
import { useState } from "react";
import { CheckOrgUserAccessResponse } from "@server/routers/org";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { CheckCircle2, XCircle, Shield } from "lucide-react";
import Enable2FaDialog from "./Enable2FaDialog";
import ChangePasswordDialog from "./ChangePasswordDialog";
import { useTranslations } from "next-intl";
import { useUserContext } from "@app/hooks/useUserContext";
import { useRouter } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
type OrgPolicyResultProps = {
orgId: string;
userId: string;
accessRes: CheckOrgUserAccessResponse;
};
type PolicyItem = {
id: string;
name: string;
description: string;
compliant: boolean;
action?: () => void;
actionText?: string;
};
export default function OrgPolicyResult({
orgId,
userId,
accessRes
}: OrgPolicyResultProps) {
const [show2FaDialog, setShow2FaDialog] = useState(false);
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false);
const t = useTranslations();
const { user } = useUserContext();
const router = useRouter();
let requireedSteps = 0;
let completedSteps = 0;
const { env } = useEnvContext();
const api = createApiClient({ env });
const policies: PolicyItem[] = [];
if (
accessRes.policies?.requiredTwoFactor === false ||
accessRes.policies?.requiredTwoFactor === true
) {
const isTwoFactorCompliant =
accessRes.policies?.requiredTwoFactor === true;
policies.push({
id: "two-factor",
name: t("twoFactorAuthentication"),
description: t("twoFactorDescription"),
compliant: isTwoFactorCompliant,
action: !isTwoFactorCompliant
? () => setShow2FaDialog(true)
: undefined,
actionText: !isTwoFactorCompliant ? t("enableTwoFactor") : undefined
});
requireedSteps += 1;
if (isTwoFactorCompliant) {
completedSteps += 1;
}
}
// Add max session length policy if the organization has it enforced
if (accessRes.policies?.maxSessionLength) {
const maxSessionPolicy = accessRes.policies?.maxSessionLength;
const maxHours = maxSessionPolicy.maxSessionLengthHours;
// Use hours if less than 24, otherwise convert to days
const useHours = maxHours < 24;
const maxTime = useHours ? maxHours : Math.round(maxHours / 24);
const descriptionKey = useHours
? "reauthenticationDescriptionHours"
: "reauthenticationDescription";
const description = useHours
? t(descriptionKey, { maxHours })
: t(descriptionKey, { maxDays: maxTime });
policies.push({
id: "max-session-length",
name: t("reauthenticationRequired"),
description,
compliant: maxSessionPolicy.compliant,
action: !maxSessionPolicy.compliant
? async () => {
try {
await api.post("/auth/logout", undefined);
router.push(`/auth/login?orgId=${orgId}`);
} catch (error) {
console.error("Error during logout:", error);
router.push(`/auth/login?orgId=${orgId}`);
}
}
: undefined,
actionText: !maxSessionPolicy.compliant
? t("reauthenticateNow")
: undefined
});
requireedSteps += 1;
if (maxSessionPolicy.compliant) {
completedSteps += 1;
}
}
// Add password age policy if the organization has it enforced
if (accessRes.policies?.passwordAge) {
const passwordAgePolicy = accessRes.policies.passwordAge;
const maxDays = passwordAgePolicy.maxPasswordAgeDays;
const daysAgo = Math.round(passwordAgePolicy.passwordAgeDays);
policies.push({
id: "password-age",
name: t("passwordExpiryRequired"),
description: t("passwordExpiryDescription", {
maxDays,
daysAgo
}),
compliant: passwordAgePolicy.compliant,
action: !passwordAgePolicy.compliant
? () => setShowChangePasswordDialog(true)
: undefined,
actionText: !passwordAgePolicy.compliant
? t("changePasswordNow")
: undefined
});
requireedSteps += 1;
if (passwordAgePolicy.compliant) {
completedSteps += 1;
}
}
const progressPercentage =
requireedSteps === 0 ? 100 : (completedSteps / requireedSteps) * 100;
const allCompliant = completedSteps === requireedSteps;
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{t("securityRequirements")}
</CardTitle>
<CardDescription>
{allCompliant
? t("allRequirementsMet")
: t("completeRequirementsToContinue")}
</CardDescription>
</CardHeader>
<div className="px-6 pb-4">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>
{completedSteps} of {requireedSteps} steps completed
</span>
<span>{Math.round(progressPercentage)}%</span>
</div>
<Progress value={progressPercentage} className="h-2" />
</div>
<CardContent className="space-y-4">
{policies.map((policy) => (
<div
key={policy.id}
className="flex items-start gap-3 p-4 border rounded-lg"
>
<div className="flex-shrink-0 mt-0.5">
{policy.compliant ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium">
{policy.name}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{policy.description}
</p>
{policy.action && policy.actionText && (
<div className="mt-3">
<Button
size="sm"
onClick={policy.action}
className="w-full sm:w-auto"
>
{policy.actionText}
</Button>
</div>
)}
</div>
</div>
))}
</CardContent>
</Card>
<Enable2FaDialog
open={show2FaDialog}
setOpen={(val) => {
setShow2FaDialog(val);
router.refresh();
}}
/>
<ChangePasswordDialog
open={showChangePasswordDialog}
setOpen={(val) => {
setShowChangePasswordDialog(val);
router.refresh();
}}
/>
</>
);
}

View File

@@ -22,6 +22,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm";
import SecurityKeyForm from "./SecurityKeyForm";
import Enable2FaDialog from "./Enable2FaDialog";
import ChangePasswordDialog from "./ChangePasswordDialog";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from "@app/components/LocaleSwitcher";
@@ -41,6 +42,7 @@ export default function ProfileIcon() {
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const [openSecurityKey, setOpenSecurityKey] = useState(false);
const [openChangePassword, setOpenChangePassword] = useState(false);
const t = useTranslations();
@@ -78,6 +80,10 @@ export default function ProfileIcon() {
open={openSecurityKey}
setOpen={setOpenSecurityKey}
/>
<ChangePasswordDialog
open={openChangePassword}
setOpen={setOpenChangePassword}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -132,6 +138,11 @@ export default function ProfileIcon() {
>
<span>{t("securityKeyManage")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setOpenChangePassword(true)}
>
<span>{t("changePassword")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}

View File

@@ -0,0 +1,33 @@
"use client";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
export function SecurityFeaturesAlert() {
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const subscriptionStatus = useSubscriptionStatusContext();
return (
<>
{build === "saas" && !subscriptionStatus?.isSubscribed() ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
{build === "enterprise" && !isUnlocked() ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("licenseRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
</>
);
}

View File

@@ -39,6 +39,7 @@ export type ShareLinkRow = {
accessTokenId: string;
resourceId: number;
resourceName: string;
resourceNiceId: string;
title: string | null;
createdAt: number;
expiresAt: number | null;
@@ -120,7 +121,7 @@ export default function ShareLinksTable({
cell: ({ row }) => {
const r = row.original;
return (
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
<Link href={`/${orgId}/settings/resources/${r.resourceNiceId}`}>
<Button variant="outline" size="sm">
{r.resourceName}
<ArrowUpRight className="ml-2 h-4 w-4" />

View File

@@ -0,0 +1,275 @@
"use client";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { MessageCircle, CheckCircle2 } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { useToast } from "@app/hooks/useToast";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { Textarea } from "@app/components/ui/textarea";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { useTranslations } from "next-intl";
type SupportFormValues = {
subject: string;
body: string;
};
type SidebarSupportButtonProps = {
isCollapsed: boolean;
};
export function SidebarSupportButton({
isCollapsed
}: SidebarSupportButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const { user } = useUserContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const { toast } = useToast();
const t = useTranslations();
const form = useForm<SupportFormValues>({
resolver: zodResolver(z.object({
subject: z
.string()
.min(1, t("supportSubjectRequired"))
.max(255, t("supportSubjectMaxLength")),
body: z.string().min(1, t("supportMessageRequired"))
})),
defaultValues: {
subject: "",
body: ""
}
});
const onSubmit = async (data: SupportFormValues) => {
if (!user?.email) {
toast({
variant: "destructive",
title: t("supportNotAvailableTitle"),
description: t("supportNotAvailableDescription")
});
return;
}
setIsSubmitting(true);
try {
await api.post("/send-support-request", {
subject: data.subject,
body: data.body
});
setIsSuccess(true);
toast({
title: t("supportRequestSentTitle"),
description: t("supportRequestSentDescription")
});
form.reset();
} catch (error) {
toast({
variant: "destructive",
title: t("supportRequestFailedTitle"),
description: formatAxiosError(
error,
t("supportRequestFailedDescription")
)
});
} finally {
setIsSubmitting(false);
}
};
if (!user?.email) {
// Show message that support is not available
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
size="icon"
className="w-8 h-8"
variant="ghost"
>
<MessageCircle className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{t("support", { defaultValue: "Support" })}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<PopoverTrigger asChild>
<Button
variant="ghost"
className="w-full justify-center gap-2"
>
<MessageCircle className="h-4 w-4" />
{t("support", { defaultValue: "Support" })}
</Button>
</PopoverTrigger>
)}
<PopoverContent className="w-80" align="start">
<p className="text-sm text-muted-foreground">
{t("supportNotAvailableDescription")}
</p>
</PopoverContent>
</Popover>
);
}
return (
<Popover
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setIsSuccess(false);
}
}}
>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
size="icon"
className="w-8 h-8"
variant="outline"
>
<MessageCircle className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{t("messageSupport")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-center gap-2"
>
<MessageCircle className="h-4 w-4" />
{t("messageSupport")}
</Button>
</PopoverTrigger>
)}
<PopoverContent className="w-96" align="start">
{isSuccess ? (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<CheckCircle2 className="h-16 w-16 text-green-500" />
<h3 className="text-lg font-semibold">{t("supportMessageSent")}</h3>
<p className="text-sm text-muted-foreground text-center">
{t("supportWillContact")}
</p>
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormItem>
<FormLabel>{t("supportReplyTo")}</FormLabel>
<FormControl>
<Input
value={user?.email || ""}
disabled
/>
</FormControl>
</FormItem>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>{t("supportSubject")}</FormLabel>
<FormControl>
<Input
placeholder={t("supportSubjectPlaceholder")}
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="body"
render={({ field }) => (
<FormItem>
<FormLabel>{t("supportMessage")}</FormLabel>
<FormControl>
<Textarea
placeholder={t("supportMessagePlaceholder")}
rows={5}
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting} loading={isSubmitting}>
{t("supportSend")}
</Button>
</div>
</form>
</Form>
)}
</PopoverContent>
</Popover>
);
}

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