mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-23 21:36:37 +00:00
Merge branch 'dev' into feat-blueprint-ui-on-dashboard
This commit is contained in:
87
src/components/ChangePasswordDialog.tsx
Normal file
87
src/components/ChangePasswordDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
647
src/components/ChangePasswordForm.tsx
Normal file
647
src/components/ChangePasswordForm.tsx
Normal 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;
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
104
src/components/ColumnFilter.tsx
Normal file
104
src/components/ColumnFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
106
src/components/DNSRecordTable.tsx
Normal file
106
src/components/DNSRecordTable.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
179
src/components/DNSRecordsDataTable.tsx
Normal file
179
src/components/DNSRecordsDataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
215
src/components/DateTimePicker.tsx
Normal file
215
src/components/DateTimePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
492
src/components/DomainInfoCard.tsx
Normal file
492
src/components/DomainInfoCard.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() ? (
|
||||
|
||||
529
src/components/LogDataTable.tsx
Normal file
529
src/components/LogDataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
59
src/components/OrgPolicyRequired.tsx
Normal file
59
src/components/OrgPolicyRequired.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
src/components/OrgPolicyResult.tsx
Normal file
229
src/components/OrgPolicyResult.tsx
Normal 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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
33
src/components/SecurityFeaturesAlert.tsx
Normal file
33
src/components/SecurityFeaturesAlert.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
275
src/components/SidebarSupportButton.tsx
Normal file
275
src/components/SidebarSupportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
src/components/ui/calendar.tsx
Normal file
213
src/components/ui/calendar.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user