Merge branch 'dev' into translation-nb-NO

This commit is contained in:
Elias Torstensen
2025-08-08 21:41:43 +02:00
committed by GitHub
359 changed files with 30625 additions and 8096 deletions

View File

@@ -1,3 +0,0 @@
"use client";
export function AuthFooter() {}

View File

@@ -0,0 +1,50 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTheme } from "next-themes";
import Image from "next/image";
import { useEffect, useState } from "react";
type BrandingLogoProps = {
width: number;
height: number;
};
export default function BrandingLogo(props: BrandingLogoProps) {
const { env } = useEnvContext();
const { theme } = useTheme();
const [path, setPath] = useState<string>(""); // Default logo path
useEffect(() => {
function getPath() {
let lightOrDark = theme;
if (theme === "system" || !theme) {
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
if (lightOrDark === "light") {
return "/logo/word_mark_black.png";
}
return "/logo/word_mark_white.png";
}
const path = getPath();
setPath(path);
}, [theme, env]);
return (
path && (
<Image
src={path}
alt="Logo"
width={props.width}
height={props.height}
/>
)
);
}

View File

@@ -1,42 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { cn } from "@app/lib/cn";
interface BreadcrumbItem {
label: string;
href: string;
}
export function Breadcrumbs() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join("/")}`;
let label = decodeURIComponent(segment);
return { label, href };
});
return (
<nav className="flex items-center space-x-1 text-muted-foreground">
{breadcrumbs.map((crumb, index) => (
<div key={crumb.href} className="flex items-center flex-nowrap">
{index !== 0 && <ChevronRight className="h-4 w-4 flex-shrink-0" />}
<Link
href={crumb.href}
className={cn(
"ml-1 hover:text-foreground whitespace-nowrap",
index === breadcrumbs.length - 1 &&
"text-foreground font-medium"
)}
>
{crumb.label}
</Link>
</div>
))}
</nav>
);
}

View File

@@ -72,7 +72,7 @@ export default function InviteUserForm({
const formSchema = z.object({
string: z.string().refine((val) => val === string, {
message: t('inviteErrorInvalidConfirmation')
message: t("inviteErrorInvalidConfirmation")
})
});
@@ -108,7 +108,9 @@ export default function InviteUserForm({
<CredenzaTitle>{title}</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<div className="mb-4 break-all overflow-hidden">{dialog}</div>
<div className="mb-4 break-all overflow-hidden">
{dialog}
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -132,9 +134,10 @@ export default function InviteUserForm({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
variant={"destructive"}
type="submit"
form="confirm-delete-form"
loading={loading}

View File

@@ -40,7 +40,7 @@ export default function CopyTextBox({
>
<pre
ref={textRef}
className={`p-2 pr-16 text-sm w-full ${
className={`p-4 pr-16 text-sm w-full ${
wrapText
? "whitespace-pre-wrap break-words"
: "overflow-x-auto"

View File

@@ -32,7 +32,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
href={text}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:underline"
className="truncate hover:underline text-sm"
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
title={text} // Shows full text on hover
>
@@ -40,7 +40,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
</Link>
) : (
<span
className="truncate"
className="truncate text-sm"
style={{
maxWidth: "100%",
display: "block",

View File

@@ -0,0 +1,552 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertCircle,
CheckCircle2,
Building2,
Zap,
ArrowUpDown
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { createApiClient, formatAxiosError } from "@/lib/api";
import { useEnvContext } from "@/hooks/useEnvContext";
import { toast } from "@/hooks/useToast";
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
import { AxiosResponse } from "axios";
import { cn } from "@/lib/cn";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
type OrganizationDomain = {
domainId: string;
baseDomain: string;
verified: boolean;
type: "ns" | "cname" | "wildcard";
};
type AvailableOption = {
domainNamespaceId: string;
fullDomain: string;
domainId: string;
};
type DomainOption = {
id: string;
domain: string;
type: "organization" | "provided";
verified?: boolean;
domainType?: "ns" | "cname" | "wildcard";
domainId?: string;
domainNamespaceId?: string;
subdomain?: string;
};
interface DomainPickerProps {
orgId: string;
cols?: number;
onDomainChange?: (domainInfo: {
domainId: string;
domainNamespaceId?: string;
type: "organization" | "provided";
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) => void;
}
export default function DomainPicker({
orgId,
cols,
onDomainChange
}: DomainPickerProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const [userInput, setUserInput] = useState<string>("");
const [selectedOption, setSelectedOption] = useState<DomainOption | null>(
null
);
const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>(
[]
);
const [isChecking, setIsChecking] = useState(false);
const [organizationDomains, setOrganizationDomains] = useState<
OrganizationDomain[]
>([]);
const [loadingDomains, setLoadingDomains] = useState(false);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [activeTab, setActiveTab] = useState<
"all" | "organization" | "provided"
>("all");
const [providedDomainsShown, setProvidedDomainsShown] = useState(3);
useEffect(() => {
const loadOrganizationDomains = async () => {
setLoadingDomains(true);
try {
const response = await api.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains`);
if (response.status === 200) {
const domains = response.data.data.domains
.filter(
(domain) =>
domain.type === "ns" ||
domain.type === "cname" ||
domain.type === "wildcard"
)
.map((domain) => ({
...domain,
type: domain.type as "ns" | "cname" | "wildcard"
}));
setOrganizationDomains(domains);
}
} catch (error) {
console.error("Failed to load organization domains:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load organization domains"
});
} finally {
setLoadingDomains(false);
}
};
loadOrganizationDomains();
}, [orgId, api]);
// Generate domain options based on user input
const generateDomainOptions = (): DomainOption[] => {
const options: DomainOption[] = [];
if (!userInput.trim()) return options;
// Add organization domain options
organizationDomains.forEach((orgDomain) => {
if (orgDomain.type === "cname") {
// For CNAME domains, check if the user input matches exactly
if (
orgDomain.baseDomain.toLowerCase() ===
userInput.toLowerCase()
) {
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "cname",
domainId: orgDomain.domainId
});
}
} else if (orgDomain.type === "ns") {
// For NS domains, check if the user input could be a subdomain
const userInputLower = userInput.toLowerCase();
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
// Check if user input ends with the base domain
if (userInputLower.endsWith(`.${baseDomainLower}`)) {
const subdomain = userInputLower.slice(
0,
-(baseDomainLower.length + 1)
);
options.push({
id: `org-${orgDomain.domainId}`,
domain: userInput,
type: "organization",
verified: orgDomain.verified,
domainType: "ns",
domainId: orgDomain.domainId,
subdomain: subdomain
});
} else if (userInputLower === baseDomainLower) {
// Exact match for base domain
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "ns",
domainId: orgDomain.domainId
});
}
} else if (orgDomain.type === "wildcard") {
// For wildcard domains, allow the base domain or multiple levels up
const userInputLower = userInput.toLowerCase();
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
// Check if user input is exactly the base domain
if (userInputLower === baseDomainLower) {
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "wildcard",
domainId: orgDomain.domainId
});
}
// Check if user input ends with the base domain (allows multiple level subdomains)
else if (userInputLower.endsWith(`.${baseDomainLower}`)) {
const subdomain = userInputLower.slice(
0,
-(baseDomainLower.length + 1)
);
// Allow multiple levels (subdomain can contain dots)
options.push({
id: `org-${orgDomain.domainId}`,
domain: userInput,
type: "organization",
verified: orgDomain.verified,
domainType: "wildcard",
domainId: orgDomain.domainId,
subdomain: subdomain
});
}
}
});
// Add provided domain options (always try to match provided domains)
availableOptions.forEach((option) => {
options.push({
id: `provided-${option.domainNamespaceId}`,
domain: option.fullDomain,
type: "provided",
domainNamespaceId: option.domainNamespaceId,
domainId: option.domainId
});
});
// Sort options
return options.sort((a, b) => {
const comparison = a.domain.localeCompare(b.domain);
return sortOrder === "asc" ? comparison : -comparison;
});
};
const domainOptions = generateDomainOptions();
// Filter options based on active tab
const filteredOptions = domainOptions.filter((option) => {
if (activeTab === "all") return true;
return option.type === activeTab;
});
// Separate organization and provided options for pagination
const organizationOptions = filteredOptions.filter(
(opt) => opt.type === "organization"
);
const allProvidedOptions = filteredOptions.filter(
(opt) => opt.type === "provided"
);
const providedOptions = allProvidedOptions.slice(0, providedDomainsShown);
const hasMoreProvided = allProvidedOptions.length > providedDomainsShown;
// Handle option selection
const handleOptionSelect = (option: DomainOption) => {
setSelectedOption(option);
if (option.type === "organization") {
if (option.domainType === "cname") {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
} else if (option.domainType === "ns") {
const subdomain = option.subdomain || "";
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: subdomain || undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
} else if (option.domainType === "wildcard") {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: option.subdomain || undefined,
fullDomain: option.domain,
baseDomain: option.subdomain
? option.domain.split(".").slice(1).join(".")
: option.domain
});
}
} else if (option.type === "provided") {
// Extract subdomain from full domain
const parts = option.domain.split(".");
const subdomain = parts[0];
const baseDomain = parts.slice(1).join(".");
onDomainChange?.({
domainId: option.domainId!,
domainNamespaceId: option.domainNamespaceId,
type: "provided",
subdomain: subdomain,
fullDomain: option.domain,
baseDomain: baseDomain
});
}
};
return (
<div className="space-y-6">
{/* Domain Input */}
<div className="space-y-2">
<Label htmlFor="domain-input">
{t("domainPickerEnterDomain")}
</Label>
<Input
id="domain-input"
value={userInput}
className="max-w-xl"
onChange={(e) => {
// Only allow letters, numbers, hyphens, and periods
const validInput = e.target.value.replace(
/[^a-zA-Z0-9.-]/g,
""
);
setUserInput(validInput);
// Clear selection when input changes
setSelectedOption(null);
}}
/>
<p className="text-sm text-muted-foreground">
{build === "saas"
? t("domainPickerDescriptionSaas")
: t("domainPickerDescription")}
</p>
</div>
{/* Tabs and Sort Toggle */}
{build === "saas" && (
<div className="flex justify-between items-center">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(
value as "all" | "organization" | "provided"
)
}
>
<TabsList>
<TabsTrigger value="all">
{t("domainPickerTabAll")}
</TabsTrigger>
<TabsTrigger value="organization">
{t("domainPickerTabOrganization")}
</TabsTrigger>
{build == "saas" && (
<TabsTrigger value="provided">
{t("domainPickerTabProvided")}
</TabsTrigger>
)}
</TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
}
>
<ArrowUpDown className="h-4 w-4 mr-2" />
{sortOrder === "asc"
? t("domainPickerSortAsc")
: t("domainPickerSortDesc")}
</Button>
</div>
)}
{/* Loading State */}
{isChecking && (
<div className="flex items-center justify-center p-8">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span>{t("domainPickerCheckingAvailability")}</span>
</div>
</div>
)}
{/* No Options */}
{!isChecking &&
filteredOptions.length === 0 &&
userInput.trim() && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("domainPickerNoMatchingDomains")}
</AlertDescription>
</Alert>
)}
{/* Domain Options */}
{!isChecking && filteredOptions.length > 0 && (
<div className="space-y-4">
{/* Organization Domains */}
{organizationOptions.length > 0 && (
<div className="space-y-3">
{build !== "oss" && (
<div className="flex items-center space-x-2">
<Building2 className="h-4 w-4" />
<h4 className="text-sm font-medium">
{t("domainPickerOrganizationDomains")}
</h4>
</div>
)}
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
{organizationOptions.map((option) => (
<div
key={option.id}
className={cn(
"transition-all p-3 rounded-lg border",
selectedOption?.id === option.id
? "border-primary bg-primary/10"
: "border-input hover:bg-accent",
option.verified
? "cursor-pointer"
: "cursor-not-allowed opacity-60"
)}
onClick={() =>
option.verified &&
handleOptionSelect(option)
}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-mono text-sm">
{option.domain}
</p>
{/* <Badge */}
{/* variant={ */}
{/* option.domainType === */}
{/* "ns" */}
{/* ? "default" */}
{/* : "secondary" */}
{/* } */}
{/* > */}
{/* {option.domainType} */}
{/* </Badge> */}
{option.verified ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<AlertCircle className="h-3 w-3 text-yellow-500" />
)}
</div>
{option.subdomain && (
<p className="text-xs text-muted-foreground mt-1">
{t(
"domainPickerSubdomain",
{
subdomain:
option.subdomain
}
)}
</p>
)}
{!option.verified && (
<p className="text-xs text-yellow-600 mt-1">
Domain is unverified
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Provided Domains */}
{providedOptions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Zap className="h-4 w-4" />
<div className="text-sm font-medium">
{t("domainPickerProvidedDomains")}
</div>
</div>
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
{providedOptions.map((option) => (
<div
key={option.id}
className={cn(
"transition-all p-3 rounded-lg border",
selectedOption?.id === option.id
? "border-primary bg-primary/10"
: "border-input",
"cursor-pointer hover:bg-accent"
)}
onClick={() =>
handleOptionSelect(option)
}
>
<div className="flex items-center justify-between">
<div>
<p className="font-mono text-sm">
{option.domain}
</p>
<p className="text-xs text-muted-foreground">
{t(
"domainPickerNamespace",
{
namespace:
option.domainNamespaceId as string
}
)}
</p>
</div>
{selectedOption?.id ===
option.id && (
<CheckCircle2 className="h-4 w-4 text-primary" />
)}
</div>
</div>
))}
</div>
{hasMoreProvided && (
<Button
variant="outline"
size="sm"
onClick={() =>
setProvidedDomainsShown(
(prev) => prev + 3
)
}
className="w-full"
>
{t("domainPickerShowMore")}
</Button>
)}
</div>
)}
</div>
)}
</div>
);
}
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
func(...args);
}, wait);
};
}

View File

@@ -0,0 +1,89 @@
"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 TwoFactorSetupForm from "./TwoFactorSetupForm";
import { useTranslations } from "next-intl";
import { useUserContext } from "@app/hooks/useUserContext";
type Enable2FaDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
export default function Enable2FaDialog({ open, setOpen }: Enable2FaDialogProps) {
const t = useTranslations();
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const formRef = useRef<{ handleSubmit: () => void }>(null);
const { user, updateUser } = useUserContext();
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('otpSetup')}
</CredenzaTitle>
<CredenzaDescription>
{t('otpSetupDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<TwoFactorSetupForm
ref={formRef}
isDialog={true}
submitButtonText={t('submit')}
cancelButtonText="Close"
showCancelButton={false}
onComplete={() => {setOpen(false); updateUser({ twoFactorEnabled: true });}}
onStepChange={setCurrentStep}
onLoadingChange={setLoading}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(currentStep === 1 || currentStep === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={handleSubmit}
>
{t('submit')}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -1,46 +1,7 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import {
RequestTotpSecretBody,
RequestTotpSecretResponse,
VerifyTotpBody,
VerifyTotpResponse
} from "@server/routers/auth";
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 {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import Enable2FaDialog from "./Enable2FaDialog";
type Enable2FaProps = {
open: boolean;
@@ -48,261 +9,5 @@ type Enable2FaProps = {
};
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
const [step, setStep] = useState(1);
const [secretKey, setSecretKey] = useState("");
const [secretUri, setSecretUri] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const { user, updateUser } = useUserContext();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const enableSchema = z.object({
password: z.string().min(1, { message: t('passwordRequired') })
});
const confirmSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
});
const enableForm = useForm<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema),
defaultValues: {
password: ""
}
});
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
resolver: zodResolver(confirmSchema),
defaultValues: {
code: ""
}
});
const request2fa = async (values: z.infer<typeof enableSchema>) => {
setLoading(true);
const res = await api
.post<AxiosResponse<RequestTotpSecretResponse>>(
`/auth/2fa/request`,
{
password: values.password
} as RequestTotpSecretBody
)
.catch((e) => {
toast({
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
t('otpErrorEnableDescription')
),
variant: "destructive"
});
});
if (res && res.data.data.secret) {
setSecretKey(res.data.data.secret);
setSecretUri(res.data.data.uri);
setStep(2);
}
setLoading(false);
};
const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
setLoading(true);
const res = await api
.post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/enable`, {
code: values.code
} as VerifyTotpBody)
.catch((e) => {
toast({
title: t('otpErrorEnable'),
description: formatAxiosError(
e,
t('otpErrorEnableDescription')
),
variant: "destructive"
});
});
if (res && res.data.data.valid) {
setBackupCodes(res.data.data.backupCodes || []);
updateUser({ twoFactorEnabled: true });
setStep(3);
}
setLoading(false);
};
const handleVerify = () => {
if (verificationCode.length !== 6) {
setError(t('otpSetupCheckCode'));
return;
}
if (verificationCode === "123456") {
setSuccess(true);
setStep(3);
} else {
setError(t('otpSetupCheckCodeRetry'));
}
};
function reset() {
setLoading(false);
setStep(1);
setSecretKey("");
setSecretUri("");
setVerificationCode("");
setError("");
setSuccess(false);
setBackupCodes([]);
enableForm.reset();
confirmForm.reset();
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t('otpSetup')}
</CredenzaTitle>
<CredenzaDescription>
{t('otpSetupDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{step === 1 && (
<Form {...enableForm}>
<form
onSubmit={enableForm.handleSubmit(request2fa)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={enableForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<p>
{t('otpSetupScanQr')}
</p>
<div className="h-[250px] mx-auto flex items-center justify-center">
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox
text={secretUri}
wrapText={false}
/>
</div>
<Form {...confirmForm}>
<form
onSubmit={confirmForm.handleSubmit(
confirm2fa
)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={confirmForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('otpSetupSecretCode')}
</FormLabel>
<FormControl>
<Input
type="code"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</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('otpSetupSuccess')}
</p>
<p>
{t('otpSetupSuccessStoreBackupCodes')}
</p>
<div className="max-w-md mx-auto">
<CopyTextBox text={backupCodes.join("\n")} />
</div>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(step === 1 || step === 2) && (
<Button
type="button"
loading={loading}
disabled={loading}
onClick={() => {
if (step === 1) {
enableForm.handleSubmit(request2fa)();
} else {
confirmForm.handleSubmit(confirm2fa)();
}
}}
>
{t('submit')}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
return <Enable2FaDialog open={open} setOpen={setOpen} />;
}

View File

@@ -38,6 +38,7 @@ export function HorizontalTabs({
.replace("{resourceId}", params.resourceId as string)
.replace("{niceId}", params.niceId as string)
.replace("{userId}", params.userId as string)
.replace("{clientId}", params.clientId as string)
.replace("{apiKeyId}", params.apiKeyId as string);
}

View File

@@ -1,276 +1,82 @@
"use client";
import React, { useEffect, useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import React from "react";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { Button } from "@app/components/ui/button";
import { ExternalLink, Menu, X, Server } from "lucide-react";
import Image from "next/image";
import ProfileIcon from "@app/components/ProfileIcon";
import {
Sheet,
SheetContent,
SheetTrigger,
SheetTitle,
SheetDescription
} from "@app/components/ui/sheet";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Breadcrumbs } from "@app/components/Breadcrumbs";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
import type { SidebarNavSection } from "@app/app/navigation";
import { LayoutSidebar } from "@app/components/LayoutSidebar";
import { LayoutHeader } from "@app/components/LayoutHeader";
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
import { cookies } from "next/headers";
interface LayoutProps {
children: React.ReactNode;
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems?: Array<{
title: string;
href: string;
icon?: React.ReactNode;
children?: Array<{
title: string;
href: string;
icon?: React.ReactNode;
}>;
}>;
navItems?: SidebarNavSection[];
showSidebar?: boolean;
showBreadcrumbs?: boolean;
showHeader?: boolean;
showTopBar?: boolean;
defaultSidebarCollapsed?: boolean;
}
export function Layout({
export async function Layout({
children,
orgId,
orgs,
navItems = [],
showSidebar = true,
showBreadcrumbs = true,
showHeader = true,
showTopBar = true
showTopBar = true,
defaultSidebarCollapsed = false
}: LayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { env } = useEnvContext();
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext();
const allCookies = await cookies();
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
const { theme } = useTheme();
const [path, setPath] = useState<string>(""); // Default logo path
useEffect(() => {
function getPath() {
let lightOrDark = theme;
if (theme === "system" || !theme) {
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
if (lightOrDark === "light") {
// return "/logo/word_mark_black.png";
return "/logo/pangolin_orange.svg";
}
// return "/logo/word_mark_white.png";
return "/logo/pangolin_orange.svg";
}
setPath(getPath());
}, [theme, env]);
const t = useTranslations();
const initialSidebarCollapsed =
sidebarStateCookie === "collapsed" ||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* Full width header */}
{showHeader && (
<div className="border-b shrink-0 bg-card">
<div className="h-16 flex items-center px-4">
<div className="flex items-center gap-4">
{showSidebar && (
<div className="md:hidden">
<Sheet
open={isMobileMenuOpen}
onOpenChange={setIsMobileMenuOpen}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
{t('navbar')}
</SheetTitle>
<SheetDescription className="sr-only">
{t('navbarDescription')}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<SidebarNav
items={navItems}
onItemClick={() =>
setIsMobileMenuOpen(
false
)
}
/>
</div>
{!isAdminPage &&
user.serverAdmin && (
<div className="p-4 border-t">
<Link
href="/admin"
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<Server className="h-4 w-4" />
{t('serverAdmin')}
</Link>
</div>
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
)}
<Link
href="/"
className="flex items-center hidden md:block"
>
{path && (
<Image
src={path}
alt="Pangolin Logo"
width={35}
height={35}
priority={true}
quality={25}
/>
)}
</Link>
{showBreadcrumbs && (
<div className="hidden md:block overflow-x-auto scrollbar-hide">
<Breadcrumbs />
</div>
)}
</div>
{showTopBar && (
<div className="ml-auto flex items-center justify-end md:justify-between">
<div className="hidden md:flex items-center space-x-3 mr-6">
<Link
href="https://docs.fossorial.io"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
{t('navbarDocsLink')}
</Link>
</div>
<div>
<ProfileIcon />
</div>
</div>
)}
</div>
{showBreadcrumbs && (
<div className="md:hidden px-4 pb-2 overflow-x-auto scrollbar-hide">
<Breadcrumbs />
</div>
)}
</div>
<div className="flex h-screen overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<LayoutSidebar
orgId={orgId}
orgs={orgs}
navItems={navItems}
defaultSidebarCollapsed={initialSidebarCollapsed}
/>
)}
<div className="flex flex-1 overflow-hidden">
{/* Desktop Sidebar */}
{showSidebar && (
<div className="hidden md:flex w-64 border-r bg-card flex-col h-full shrink-0">
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<SidebarNav items={navItems} />
</div>
{!isAdminPage && user.serverAdmin && (
<div className="p-4 border-t">
<Link
href="/admin"
className="flex items-center gap-3 text-muted-foreground hover:text-foreground transition-colors px-3 py-2 rounded-md w-full"
>
<Server className="h-4 w-4" />
{t('serverAdmin')}
</Link>
</div>
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t('communityEdition')
: t('commercialEdition')}
<ExternalLink size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</div>
</div>
{/* Main content area */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0 relative",
!showSidebar && "w-full"
)}
>
{/* Mobile header */}
{showHeader && (
<LayoutMobileMenu
orgId={orgId}
orgs={orgs}
navItems={navItems}
showSidebar={showSidebar}
showTopBar={showTopBar}
/>
)}
{/* Desktop header */}
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
{/* Main content */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0",
!showSidebar && "w-full"
)}
>
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className="container mx-auto max-w-12xl mb-12">
{children}
</div>
</main>
</div>
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-20" // Add top padding only on desktop to account for fixed header
)}>
{children}
</div>
</main>
</div>
</div>
);

View File

@@ -0,0 +1,72 @@
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
interface LayoutHeaderProps {
showTopBar: boolean;
}
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
useEffect(() => {
function getPath() {
let lightOrDark = theme;
if (theme === "system" || !theme) {
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
if (lightOrDark === "light") {
return "/logo/word_mark_black.png";
}
return "/logo/word_mark_white.png";
}
setPath(getPath());
}, [theme]);
return (
<div className="absolute top-0 left-0 right-0 z-50 hidden md:block">
<div className="absolute inset-0 bg-background/86 backdrop-blur-sm" />
<div className="relative z-10 px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center">
{path && (
<Image
src={path}
alt="Pangolin"
width={98}
height={32}
className="h-8 w-auto"
/>
)}
</Link>
</div>
{showTopBar && (
<div className="flex items-center space-x-2">
<ThemeSwitcher />
<ProfileIcon />
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default LayoutHeader;

View File

@@ -0,0 +1,142 @@
"use client";
import React, { useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { Button } from "@app/components/ui/button";
import { Menu, Server } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import type { SidebarNavSection } from "@app/app/navigation";
import {
Sheet,
SheetContent,
SheetTrigger,
SheetTitle,
SheetDescription
} from "@app/components/ui/sheet";
import { Abel } from "next/font/google";
interface LayoutMobileMenuProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
showSidebar: boolean;
showTopBar: boolean;
}
export function LayoutMobileMenu({
orgId,
orgs,
navItems,
showSidebar,
showTopBar
}: LayoutMobileMenuProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { env } = useEnvContext();
const t = useTranslations();
return (
<div className="shrink-0 md:hidden">
<div className="h-16 flex items-center px-4">
<div className="flex items-center gap-4">
{showSidebar && (
<div>
<Sheet
open={isMobileMenuOpen}
onOpenChange={setIsMobileMenuOpen}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
className="w-64 p-0 flex flex-col h-full"
>
<SheetTitle className="sr-only">
{t("navbar")}
</SheetTitle>
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
<div className="px-4">
{!isAdminPage &&
user.serverAdmin && (
<div className="pb-3">
<Link
href="/admin"
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
)}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<span className="flex-shrink-0 mr-2">
<Server className="h-4 w-4" />
</span>
<span>
{t(
"serverAdmin"
)}
</span>
</Link>
</div>
)}
<SidebarNav
sections={navItems}
onItemClick={() =>
setIsMobileMenuOpen(false)
}
/>
</div>
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
</SheetContent>
</Sheet>
</div>
)}
</div>
{showTopBar && (
<div className="ml-auto flex items-center justify-end">
<div className="flex items-center space-x-2">
<ThemeSwitcher />
<ProfileIcon />
</div>
</div>
)}
</div>
</div>
);
}
export default LayoutMobileMenu;

View File

@@ -0,0 +1,178 @@
"use client";
import React, { useEffect, useState } from "react";
import { SidebarNav } from "@app/components/SidebarNav";
import { OrgSelector } from "@app/components/OrgSelector";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import SupporterStatus from "@app/components/SupporterStatus";
import { ExternalLink, Server, BookOpenText } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import type { SidebarNavSection } from "@app/app/navigation";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
interface LayoutSidebarProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
defaultSidebarCollapsed: boolean;
}
export function LayoutSidebar({
orgId,
orgs,
navItems,
defaultSidebarCollapsed
}: LayoutSidebarProps) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
defaultSidebarCollapsed
);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext();
const { env } = useEnvContext();
const t = useTranslations();
const setSidebarStateCookie = (collapsed: boolean) => {
if (typeof window !== "undefined") {
const isSecure = window.location.protocol === "https:";
document.cookie = `pangolin-sidebar-state=${collapsed ? "collapsed" : "expanded"}; path=/; max-age=${60 * 60 * 24 * 30}; samesite=lax${isSecure ? "; secure" : ""}`;
}
};
useEffect(() => {
setSidebarStateCookie(isSidebarCollapsed);
}, [isSidebarCollapsed]);
return (
<div
className={cn(
"hidden md:flex border-r bg-card flex-col h-full shrink-0 relative",
isSidebarCollapsed ? "w-16" : "w-64"
)}
>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<OrgSelector
orgId={orgId}
orgs={orgs}
isCollapsed={isSidebarCollapsed}
/>
</div>
<div className="px-4 pt-1">
{!isAdminPage && user.serverAdmin && (
<div className="pb-4">
<Link
href="/admin"
className={cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("serverAdmin")
: undefined
}
>
<span
className={cn(
"flex-shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("serverAdmin")}</span>
)}
</Link>
</div>
)}
<SidebarNav
sections={navItems}
isCollapsed={isSidebarCollapsed}
/>
</div>
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
{!isSidebarCollapsed && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t("communityEdition")
: t("commercialEdition")}
<ExternalLink size={12} />
</Link>
</div>
<div className="text-xs text-muted-foreground ">
<Link
href="https://docs.digpangolin.com/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{t("documentation")}
<BookOpenText size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
v{env.app.version}
</div>
)}
</div>
)}
</div>
{/* Collapse button */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() =>
setIsSidebarCollapsed(!isSidebarCollapsed)
}
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group z-[60]"
aria-label={
isSidebarCollapsed
? "Expand sidebar"
: "Collapse sidebar"
}
>
<div className="w-0.5 h-4 bg-current opacity-30 group-hover:opacity-100 transition-opacity duration-200" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>
{isSidebarCollapsed
? t("sidebarExpand")
: t("sidebarCollapse")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
export default LayoutSidebar;

View File

@@ -0,0 +1,60 @@
"use client";
import { Button } from "@app/components/ui/button";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useState } from "react";
import { useTranslations } from "next-intl";
export default function LicenseViolation() {
const { licenseStatus } = useLicenseStatusContext();
const [isDismissed, setIsDismissed] = useState(false);
const t = useTranslations();
if (!licenseStatus || isDismissed) return null;
// Show invalid license banner
if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) {
return (
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
<div className="flex justify-between items-center">
<p>
{t('componentsInvalidKey')}
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
{t('dismiss')}
</Button>
</div>
</div>
);
}
// Show usage violation banner
if (
licenseStatus.maxSites &&
licenseStatus.usedSites &&
licenseStatus.usedSites > licenseStatus.maxSites
) {
return (
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
<div className="flex justify-between items-center">
<p>
{t('componentsLicenseViolation', {usedSites: licenseStatus.usedSites, maxSites: licenseStatus.maxSites})}
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
{t('dismiss')}
</Button>
</div>
</div>
);
}
return null;
}

View File

@@ -1,59 +1,62 @@
import { useLocale } from "next-intl";
import LocaleSwitcherSelect from './LocaleSwitcherSelect';
import LocaleSwitcherSelect from "./LocaleSwitcherSelect";
export default function LocaleSwitcher() {
const locale = useLocale();
const locale = useLocale();
return (
<LocaleSwitcherSelect
label="Select language"
defaultValue={locale}
items={[
{
value: 'en-US',
label: 'English'
},
{
value: 'fr-FR',
label: "Français"
},
{
value: 'de-DE',
label: 'Deutsch'
},
{
value: 'it-IT',
label: 'Italiano'
},
{
value: 'nl-NL',
label: 'Nederlands'
},
{
value: 'pl-PL',
label: 'Polski'
},
{
value: 'pt-PT',
label: 'Português'
},
{
value: 'es-ES',
label: 'Español'
},
{
value: 'tr-TR',
label: 'Türkçe'
},
{
value: 'zh-CN',
label: '简体中文'
},
{
value: 'nb-NO',
label: 'Norsk (Bokmål)'
}
]}
/>
);
}
return (
<LocaleSwitcherSelect
label="Select language"
defaultValue={locale}
items={[
{
value: "en-US",
label: "English"
},
{
value: "fr-FR",
label: "Français"
},
{
value: "de-DE",
label: "Deutsch"
},
{
value: "it-IT",
label: "Italiano"
},
{
value: "nl-NL",
label: "Nederlands"
},
{
value: "pl-PL",
label: "Polski"
},
{
value: "pt-PT",
label: "Português"
},
{
value: "es-ES",
label: "Español"
},
{
value: "tr-TR",
label: "Türkçe"
},
{
value: "zh-CN",
label: "简体中文"
},
{
value: "ko-KR",
label: "한국어"
},
{
value: "nb-NO",
label: "Norsk (Bokmål)"
]}
/>
);
}

View File

@@ -1,71 +1,71 @@
'use client';
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@app/components/ui/dropdown-menu';
import { Button } from '@app/components/ui/button';
import { Check, Globe, Languages } from 'lucide-react';
import clsx from 'clsx';
import { useTransition } from 'react';
import { Locale } from '@/i18n/config';
import { setUserLocale } from '@/services/locale';
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { Check, Globe, Languages } from "lucide-react";
import clsx from "clsx";
import { useTransition } from "react";
import { Locale } from "@/i18n/config";
import { setUserLocale } from "@/services/locale";
type Props = {
defaultValue: string;
items: Array<{ value: string; label: string }>;
label: string;
defaultValue: string;
items: Array<{ value: string; label: string }>;
label: string;
};
export default function LocaleSwitcherSelect({
defaultValue,
items,
label
defaultValue,
items,
label
}: Props) {
const [isPending, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
function onChange(value: string) {
const locale = value as Locale;
startTransition(() => {
setUserLocale(locale);
});
}
function onChange(value: string) {
const locale = value as Locale;
startTransition(() => {
setUserLocale(locale);
});
}
const selected = items.find((item) => item.value === defaultValue);
const selected = items.find((item) => item.value === defaultValue);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={clsx(
'w-full rounded-sm h-8 gap-2 justify-start font-normal',
isPending && 'pointer-events-none'
)}
aria-label={label}
>
<Languages className="h-4 w-4" />
<span className="text-left flex-1">
{selected?.label ?? label}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]">
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value)}
className="flex items-center gap-2"
>
{item.value === defaultValue && (
<Check className="h-4 w-4" />
)}
<span>{item.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={clsx(
"w-full rounded-sm h-8 gap-2 justify-start font-normal",
isPending && "pointer-events-none"
)}
aria-label={label}
>
<Languages className="h-4 w-4" />
<span className="text-left flex-1">
{selected?.label ?? label}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]">
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value)}
className="flex items-center gap-2"
>
{item.value === defaultValue && (
<Check className="h-4 w-4" />
)}
<span>{item.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -4,8 +4,8 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Form,
FormControl,
@@ -13,20 +13,20 @@ import {
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
} from "@app/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
} from "@app/components/ui/card";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { LockIcon } from "lucide-react";
import { LockIcon, FingerprintIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
@@ -41,6 +41,7 @@ import Image from "next/image";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
import { startAuthentication } from "@simplewebauthn/browser";
export type LoginFormIDP = {
idpId: number;
@@ -65,18 +66,17 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const hasIdp = idps && idps.length > 0;
const [mfaRequested, setMfaRequested] = useState(false);
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
const t = useTranslations();
const formSchema = z.object({
email: z.string().email({ message: t('emailInvalid') }),
password: z
.string()
.min(8, { message: t('passwordRequirementsChars') })
email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") })
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
code: z.string().length(6, { message: t("pincodeInvalid") })
});
const form = useForm<z.infer<typeof formSchema>>({
@@ -94,30 +94,135 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
});
async function initiateSecurityKeyAuth() {
setShowSecurityKeyPrompt(true);
setLoading(true);
setError(null);
try {
// Start WebAuthn authentication without email
const startRes = await api.post(
"/auth/security-key/authenticate/start",
{}
);
if (!startRes) {
setError(
t("securityKeyAuthError", {
defaultValue:
"Failed to start security key authentication"
})
);
return;
}
const { tempSessionId, ...options } = startRes.data.data;
// Perform WebAuthn authentication
try {
const credential = await startAuthentication(options);
// Verify authentication
const verifyRes = await api.post(
"/auth/security-key/authenticate/verify",
{ credential },
{
headers: {
"X-Temp-Session-Id": tempSessionId
}
}
);
if (verifyRes) {
if (onLogin) {
await onLogin();
}
}
} catch (error: any) {
if (error.name === "NotAllowedError") {
if (error.message.includes("denied permission")) {
setError(
t("securityKeyPermissionDenied", {
defaultValue:
"Please allow access to your security key to continue signing in."
})
);
} else {
setError(
t("securityKeyRemovedTooQuickly", {
defaultValue:
"Please keep your security key connected until the sign-in process completes."
})
);
}
} else if (error.name === "NotSupportedError") {
setError(
t("securityKeyNotSupported", {
defaultValue:
"Your security key may not be compatible. Please try a different security key."
})
);
} else {
setError(
t("securityKeyUnknownError", {
defaultValue:
"There was a problem using your security key. Please try again."
})
);
}
}
} catch (e: any) {
if (e.isAxiosError) {
setError(
formatAxiosError(
e,
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
)
);
} else {
console.error(e);
setError(
e.message ||
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
);
}
} finally {
setLoading(false);
setShowSecurityKeyPrompt(false);
}
}
async function onSubmit(values: any) {
const { email, password } = form.getValues();
const { code } = mfaForm.getValues();
setLoading(true);
setError(null);
setShowSecurityKeyPrompt(false);
const res = await api
.post<AxiosResponse<LoginResponse>>("/auth/login", {
email,
password,
code
})
.catch((e) => {
console.error(e);
setError(
formatAxiosError(e, t('loginError'))
);
});
if (res) {
setError(null);
try {
const res = await api.post<AxiosResponse<LoginResponse>>(
"/auth/login",
{
email,
password,
code
}
);
const data = res.data.data;
if (data?.useSecurityKey) {
await initiateSecurityKeyAuth();
return;
}
if (data?.codeRequested) {
setMfaRequested(true);
setLoading(false);
@@ -134,12 +239,38 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
return;
}
if (data?.twoFactorSetupRequired) {
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
router.push(setupUrl);
return;
}
if (onLogin) {
await onLogin();
}
} catch (e: any) {
if (e.isAxiosError) {
const errorMessage = formatAxiosError(
e,
t("loginError", {
defaultValue: "Failed to log in"
})
);
setError(errorMessage);
return;
} else {
console.error(e);
setError(
e.message ||
t("loginError", {
defaultValue: "Failed to log in"
})
);
return;
}
} finally {
setLoading(false);
}
setLoading(false);
}
async function loginWithIdp(idpId: number) {
@@ -154,7 +285,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
console.log(res);
if (!res) {
setError(t('loginError'));
setError(t("loginError"));
return;
}
@@ -167,6 +298,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
return (
<div className="space-y-4">
{showSecurityKeyPrompt && (
<Alert>
<FingerprintIcon className="w-5 h-5 mr-2" />
<AlertDescription>
{t("securityKeyPrompt", {
defaultValue:
"Please verify your identity using your security key. Make sure your security key is connected and ready."
})}
</AlertDescription>
</Alert>
)}
{!mfaRequested && (
<>
<Form {...form}>
@@ -180,7 +323,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -195,7 +338,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
@@ -212,10 +357,20 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground"
>
{t('passwordForgot')}
{t("passwordForgot")}
</Link>
</div>
</div>
<div className="flex flex-col space-y-2">
<Button
type="submit"
disabled={loading}
loading={loading}
>
{t("login")}
</Button>
</div>
</form>
</Form>
</>
@@ -224,11 +379,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
{mfaRequested && (
<>
<div className="text-center">
<h3 className="text-lg font-medium">
{t('otpAuth')}
</h3>
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
<p className="text-sm text-muted-foreground">
{t('otpAuthDescription')}
{t("otpAuthDescription")}
</p>
</div>
<Form {...mfaForm}>
@@ -250,10 +403,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(e) => {
field.onChange(e);
if (e.length === 6) {
mfaForm.handleSubmit(onSubmit)();
onChange={(
value: string
) => {
field.onChange(value);
if (
value.length === 6
) {
mfaForm.handleSubmit(
onSubmit
)();
}
}}
>
@@ -304,21 +463,24 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
loading={loading}
disabled={loading}
>
{t('otpAuthSubmit')}
{t("otpAuthSubmit")}
</Button>
)}
{!mfaRequested && (
<>
<Button
type="submit"
form="form"
type="button"
variant="outline"
className="w-full"
onClick={initiateSecurityKeyAuth}
loading={loading}
disabled={loading}
disabled={loading || showSecurityKeyPrompt}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('login')}
<FingerprintIcon className="w-4 h-4 mr-2" />
{t("securityKeyLogin", {
defaultValue: "Sign in with security key"
})}
</Button>
{hasIdp && (
@@ -329,7 +491,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="px-2 bg-card text-muted-foreground">
{t('idpContinue')}
{t("idpContinue")}
</span>
</div>
</div>
@@ -362,7 +524,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
mfaForm.reset();
}}
>
{t('otpAuthBack')}
{t("otpAuthBack")}
</Button>
)}
</div>

View File

@@ -15,10 +15,16 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@app/components/ui/tooltip";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -27,94 +33,110 @@ import { useTranslations } from "next-intl";
interface OrgSelectorProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
isCollapsed?: boolean;
}
export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorProps) {
const { user } = useUserContext();
const [open, setOpen] = useState(false);
const router = useRouter();
const { env } = useEnvContext();
const t = useTranslations();
return (
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
const orgSelectorContent = (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="lg"
variant="secondary"
size={isCollapsed ? "icon" : "lg"}
role="combobox"
aria-expanded={open}
className="w-full h-12 px-3 py-4 bg-neutral hover:bg-neutral"
className={cn(
"shadow-xs",
isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4"
)}
>
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start">
<span className="font-bold text-sm">
{t('org')}
</span>
<span className="text-sm text-muted-foreground">
{orgId
? orgs?.find(
(org) =>
org.orgId ===
orgId
)?.name
: t('noneSelected')}
</span>
{isCollapsed ? (
<Building2 className="h-4 w-4" />
) : (
<div className="flex items-center justify-between w-full min-w-0">
<div className="flex items-center min-w-0 flex-1">
<Building2 className="h-4 w-4 mr-2 shrink-0" />
<div className="flex flex-col items-start min-w-0 flex-1">
<span className="font-bold text-sm">
{t('org')}
</span>
<span className="text-sm text-muted-foreground truncate w-full text-left">
{selectedOrg?.name || t('noneSelected')}
</span>
</div>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 ml-2" />
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0">
<Command>
<CommandInput placeholder={t('searchProgress')} />
<CommandEmpty>
{t('orgNotFound2')}
<PopoverContent className="w-[320px] p-0" align="start">
<Command className="rounded-lg">
<CommandInput
placeholder={t('searchProgress')}
className="border-0 focus:ring-0"
/>
<CommandEmpty className="py-6 text-center">
<div className="text-muted-foreground text-sm">
{t('orgNotFound2')}
</div>
</CommandEmpty>
{(!env.flags.disableUserCreateOrg ||
user.serverAdmin) && (
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
<>
<CommandGroup heading={t('create')}>
<CommandGroup heading={t('create')} className="py-2">
<CommandList>
<CommandItem
onSelect={(
currentValue
) => {
router.push(
"/setup"
);
onSelect={() => {
setOpen(false);
router.push("/setup");
}}
className="mx-2 rounded-md"
>
<Plus className="mr-2 h-4 w-4" />
{t('setupNewOrg')}
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Plus className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col">
<span className="font-medium">{t('setupNewOrg')}</span>
<span className="text-xs text-muted-foreground">{t('createNewOrgDescription')}</span>
</div>
</CommandItem>
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandSeparator className="my-2" />
</>
)}
<CommandGroup heading={t('orgs')}>
<CommandGroup heading={t('orgs')} className="py-2">
<CommandList>
{orgs?.map((org) => (
<CommandItem
key={org.orgId}
onSelect={(
currentValue
) => {
router.push(
`/${org.orgId}/settings`
);
onSelect={() => {
setOpen(false);
router.push(`/${org.orgId}/settings`);
}}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Users className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col flex-1">
<span className="font-medium">{org.name}</span>
<span className="text-xs text-muted-foreground">{t('organization')}</span>
</div>
<Check
className={cn(
"mr-2 h-4 w-4",
orgId === org.orgId
? "opacity-100"
: "opacity-0"
"h-4 w-4 text-primary",
orgId === org.orgId ? "opacity-100" : "opacity-0"
)}
/>
{org.name}
</CommandItem>
))}
</CommandList>
@@ -123,4 +145,24 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
</PopoverContent>
</Popover>
);
if (isCollapsed) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{orgSelectorContent}
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<div className="text-center">
<p className="font-medium">{selectedOrg?.name || t('noneSelected')}</p>
<p className="text-xs text-muted-foreground">{t('org')}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return orgSelectorContent;
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardDescription
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { ArrowRight, Plus } from "lucide-react";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
interface Organization {
id: string;
name: string;
}
interface OrganizationLandingProps {
organizations?: Organization[];
disableCreateOrg?: boolean;
}
export default function OrganizationLanding({
organizations = [],
disableCreateOrg = false
}: OrganizationLandingProps) {
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
const { env } = useEnvContext();
const handleOrgClick = (orgId: string) => {
setSelectedOrg(orgId);
};
const t = useTranslations();
function getDescriptionText() {
if (organizations.length === 0) {
if (!disableCreateOrg) {
return t("componentsErrorNoMemberCreate");
} else {
return t("componentsErrorNoMember");
}
}
return t("componentsMember", { count: organizations.length });
}
return (
<Card>
<CardHeader>
<CardTitle>{t("welcome")}</CardTitle>
<CardDescription>{getDescriptionText()}</CardDescription>
</CardHeader>
<CardContent>
{organizations.length === 0 ? (
!disableCreateOrg && (
<Link href="/setup">
<Button
className="w-full h-auto py-3 text-lg"
size="lg"
>
<Plus className="mr-2 h-5 w-5" />
{t("componentsCreateOrg")}
</Button>
</Link>
)
) : (
<ul className="space-y-2">
{organizations.map((org) => (
<li key={org.id}>
<Link href={`/${org.id}/settings`}>
<Button
variant="outline"
className={`flex items-center justify-between w-full h-auto py-3 ${
selectedOrg === org.id
? "ring-2 ring-primary"
: ""
}`}
>
<div className="truncate">
{org.name}
</div>
<ArrowRight size={20} />
</Button>
</Link>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}

View File

@@ -23,7 +23,10 @@ function getActionsCategories(root: boolean) {
[t('actionGetOrg')]: "getOrg",
[t('actionUpdateOrg')]: "updateOrg",
[t('actionGetOrgUser')]: "getOrgUser",
[t('actionListOrgDomains')]: "listOrgDomains",
[t('actionInviteUser')]: "inviteUser",
[t('actionRemoveUser')]: "removeUser",
[t('actionListUsers')]: "listUsers",
[t('actionListOrgDomains')]: "listOrgDomains"
},
Site: {
@@ -65,16 +68,9 @@ function getActionsCategories(root: boolean) {
[t('actionGetRole')]: "getRole",
[t('actionListRole')]: "listRoles",
[t('actionUpdateRole')]: "updateRole",
[t('actionListAllowedRoleResources')]: "listRoleResources"
},
User: {
[t('actionInviteUser')]: "inviteUser",
[t('actionRemoveUser')]: "removeUser",
[t('actionListUsers')]: "listUsers",
[t('actionListAllowedRoleResources')]: "listRoleResources",
[t('actionAddUserRole')]: "addUserRole"
},
"Access Token": {
[t('actionGenerateAccessToken')]: "generateAccessToken",
[t('actionDeleteAccessToken')]: "deleteAcessToken",
@@ -86,6 +82,14 @@ function getActionsCategories(root: boolean) {
[t('actionDeleteResourceRule')]: "deleteResourceRule",
[t('actionListResourceRules')]: "listResourceRules",
[t('actionUpdateResourceRule')]: "updateResourceRule"
},
"Client": {
[t('actionCreateClient')]: "createClient",
[t('actionDeleteClient')]: "deleteClient",
[t('actionUpdateClient')]: "updateClient",
[t('actionListClients')]: "listClients",
[t('actionGetClient')]: "getClient"
}
};
@@ -114,6 +118,11 @@ function getActionsCategories(root: boolean) {
[t('actionListIdpOrgs')]: "listIdpOrgs",
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
};
actionsByCategory["User"] = {
[t('actionUpdateUser')]: "updateUser",
[t('actionGetUser')]: "getUser"
};
}
return actionsByCategory;

View File

@@ -20,13 +20,13 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm";
import SecurityKeyForm from "./SecurityKeyForm";
import Enable2FaDialog from "./Enable2FaDialog";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from '@app/components/LocaleSwitcher';
import LocaleSwitcher from "@app/components/LocaleSwitcher";
import { useTranslations } from "next-intl";
export default function ProfileIcon() {
const { setTheme, theme } = useTheme();
const { env } = useEnvContext();
@@ -40,6 +40,7 @@ export default function ProfileIcon() {
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const [openSecurityKey, setOpenSecurityKey] = useState(false);
const t = useTranslations();
@@ -57,10 +58,10 @@ export default function ProfileIcon() {
function logout() {
api.post("/auth/logout")
.catch((e) => {
console.error(t('logoutError'), e);
console.error(t("logoutError"), e);
toast({
title: t('logoutError'),
description: formatAxiosError(e, t('logoutError'))
title: t("logoutError"),
description: formatAxiosError(e, t("logoutError"))
});
})
.then(() => {
@@ -71,107 +72,105 @@ export default function ProfileIcon() {
return (
<>
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Enable2FaDialog open={openEnable2fa} setOpen={setOpenEnable2fa} />
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
<SecurityKeyForm
open={openSecurityKey}
setOpen={setOpenSecurityKey}
/>
<div className="flex items-center md:gap-2 grow min-w-0 gap-2 md:gap-0">
<span className="truncate max-w-full font-medium min-w-0">
{user.email || user.name || user.username}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="relative h-10 w-10 rounded-full"
>
<Avatar className="h-9 w-9">
<AvatarFallback>{getInitials()}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
align="start"
forceMount
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="relative h-10 w-10 rounded-full"
>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{t('signingAs')}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email || user.name || user.username}
</p>
</div>
{user.serverAdmin ? (
<p className="text-xs leading-none text-muted-foreground mt-2">
{t('serverAdmin')}
</p>
) : (
<p className="text-xs leading-none text-muted-foreground mt-2">
{user.idpName || t('idpNameInternal')}
</p>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{user?.type === UserType.Internal && (
<>
{!user.twoFactorEnabled && (
<DropdownMenuItem
onClick={() => setOpenEnable2fa(true)}
>
<span>{t('otpEnable')}</span>
</DropdownMenuItem>
)}
{user.twoFactorEnabled && (
<DropdownMenuItem
onClick={() => setOpenDisable2fa(true)}
>
<span>{t('otpDisable')}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
<Avatar className="h-9 w-9">
<AvatarFallback>{getInitials()}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{t("signingAs")}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email || user.name || user.username}
</p>
</div>
{user.serverAdmin ? (
<p className="text-xs leading-none text-muted-foreground mt-2">
{t("serverAdmin")}
</p>
) : (
<p className="text-xs leading-none text-muted-foreground mt-2">
{user.idpName || t("idpNameInternal")}
</p>
)}
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
{(["light", "dark", "system"] as const).map(
(themeOption) => (
</DropdownMenuLabel>
<DropdownMenuSeparator />
{user?.type === UserType.Internal && (
<>
{!user.twoFactorEnabled && (
<DropdownMenuItem
key={themeOption}
onClick={() =>
handleThemeChange(themeOption)
}
onClick={() => setOpenEnable2fa(true)}
>
{themeOption === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{themeOption === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{themeOption === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">
{t(themeOption)}
</span>
{userTheme === themeOption && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-primary"></span>
</span>
)}
<span>{t("otpEnable")}</span>
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
<LocaleSwitcher />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
{/* <LogOut className="mr-2 h-4 w-4" /> */}
<span>{t('logout')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{user.twoFactorEnabled && (
<DropdownMenuItem
onClick={() => setOpenDisable2fa(true)}
>
<span>{t("otpDisable")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setOpenSecurityKey(true)}
>
<span>{t("securityKeyManage")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
{(["light", "dark", "system"] as const).map(
(themeOption) => (
<DropdownMenuItem
key={themeOption}
onClick={() => handleThemeChange(themeOption)}
>
{themeOption === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{themeOption === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{themeOption === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">
{t(themeOption)}
</span>
{userTheme === themeOption && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-primary"></span>
</span>
)}
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
<LocaleSwitcher />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
{/* <LogOut className="mr-2 h-4 w-4" /> */}
<span>{t("logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -0,0 +1,858 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { startRegistration } from "@simplewebauthn/browser";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Card, CardContent } from "@app/components/ui/card";
import { Badge } from "@app/components/ui/badge";
import { Loader2, KeyRound, Trash2, Plus, Shield, Info } from "lucide-react";
import { cn } from "@app/lib/cn";
type SecurityKeyFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
};
type SecurityKey = {
credentialId: string;
name: string;
lastUsed: string;
};
type DeleteSecurityKeyData = {
credentialId: string;
name: string;
};
type RegisterFormValues = {
name: string;
password: string;
code?: string;
};
type DeleteFormValues = {
password: string;
code?: string;
};
type FieldProps = {
field: {
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onBlur: () => void;
name: string;
ref: React.Ref<HTMLInputElement>;
};
};
export default function SecurityKeyForm({
open,
setOpen
}: SecurityKeyFormProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [securityKeys, setSecurityKeys] = useState<SecurityKey[]>([]);
const [isRegistering, setIsRegistering] = useState(false);
const [dialogState, setDialogState] = useState<
"list" | "register" | "register2fa" | "delete" | "delete2fa"
>("list");
const [selectedSecurityKey, setSelectedSecurityKey] =
useState<DeleteSecurityKeyData | null>(null);
const [deleteInProgress, setDeleteInProgress] = useState(false);
const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState<
string | null
>(null);
const [pendingDeletePassword, setPendingDeletePassword] = useState<
string | null
>(null);
const [pendingRegisterData, setPendingRegisterData] = useState<{
name: string;
password: string;
} | null>(null);
const [register2FAForm, setRegister2FAForm] = useState<{ code: string }>({
code: ""
});
useEffect(() => {
if (open) {
loadSecurityKeys();
}
}, [open]);
const registerSchema = z.object({
name: z.string().min(1, { message: t("securityKeyNameRequired") }),
password: z.string().min(1, { message: t("passwordRequired") }),
code: z.string().optional()
});
const deleteSchema = z.object({
password: z.string().min(1, { message: t("passwordRequired") }),
code: z.string().optional()
});
const registerForm = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: "",
password: "",
code: ""
}
});
const deleteForm = useForm<DeleteFormValues>({
resolver: zodResolver(deleteSchema),
defaultValues: {
password: "",
code: ""
}
});
const loadSecurityKeys = async () => {
try {
const response = await api.get("/auth/security-key/list");
setSecurityKeys(response.data.data);
} catch (error) {
toast({
variant: "destructive",
description: formatAxiosError(error, t("securityKeyLoadError"))
});
}
};
const handleRegisterSecurityKey = async (values: RegisterFormValues) => {
try {
// Check browser compatibility first
if (!window.PublicKeyCredential) {
toast({
variant: "destructive",
description: t("securityKeyBrowserNotSupported", {
defaultValue:
"Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari."
})
});
return;
}
setIsRegistering(true);
const startRes = await api.post(
"/auth/security-key/register/start",
{
name: values.name,
password: values.password,
code: values.code
}
);
// If 2FA is required
if (startRes.status === 202 && startRes.data.data?.codeRequested) {
setPendingRegisterData({
name: values.name,
password: values.password
});
setDialogState("register2fa");
setIsRegistering(false);
return;
}
const options = startRes.data.data;
try {
const credential = await startRegistration(options);
await api.post("/auth/security-key/register/verify", {
credential
});
toast({
description: t("securityKeyRegisterSuccess", {
defaultValue: "Security key registered successfully"
})
});
registerForm.reset();
setDialogState("list");
await loadSecurityKeys();
} catch (error: any) {
if (error.name === "NotAllowedError") {
if (error.message.includes("denied permission")) {
toast({
variant: "destructive",
description: t("securityKeyPermissionDenied", {
defaultValue:
"Please allow access to your security key to continue registration."
})
});
} else {
toast({
variant: "destructive",
description: t("securityKeyRemovedTooQuickly", {
defaultValue:
"Please keep your security key connected until the registration process completes."
})
});
}
} else if (error.name === "NotSupportedError") {
toast({
variant: "destructive",
description: t("securityKeyNotSupported", {
defaultValue:
"Your security key may not be compatible. Please try a different security key."
})
});
} else {
toast({
variant: "destructive",
description: t("securityKeyUnknownError", {
defaultValue:
"There was a problem registering your security key. Please try again."
})
});
}
throw error; // Re-throw to be caught by outer catch
}
} catch (error) {
console.error("Security key registration error:", error);
toast({
variant: "destructive",
description: formatAxiosError(
error,
t("securityKeyRegisterError", {
defaultValue: "Failed to register security key"
})
)
});
} finally {
setIsRegistering(false);
}
};
const handleDeleteSecurityKey = async (values: DeleteFormValues) => {
if (!selectedSecurityKey) return;
try {
setDeleteInProgress(true);
const encodedCredentialId = encodeURIComponent(
selectedSecurityKey.credentialId
);
const response = await api.delete(
`/auth/security-key/${encodedCredentialId}`,
{
data: {
password: values.password,
code: values.code
}
}
);
// If 2FA is required
if (response.status === 202 && response.data.data.codeRequested) {
setPendingDeleteCredentialId(encodedCredentialId);
setPendingDeletePassword(values.password);
setDialogState("delete2fa");
return;
}
toast({
description: t("securityKeyRemoveSuccess")
});
deleteForm.reset();
setSelectedSecurityKey(null);
setDialogState("list");
await loadSecurityKeys();
} catch (error) {
toast({
variant: "destructive",
description: formatAxiosError(
error,
t("securityKeyRemoveError")
)
});
} finally {
setDeleteInProgress(false);
}
};
const handle2FASubmit = async (values: DeleteFormValues) => {
if (!pendingDeleteCredentialId || !pendingDeletePassword) return;
try {
setDeleteInProgress(true);
await api.delete(
`/auth/security-key/${pendingDeleteCredentialId}`,
{
data: {
password: pendingDeletePassword,
code: values.code
}
}
);
toast({
description: t("securityKeyRemoveSuccess")
});
deleteForm.reset();
setSelectedSecurityKey(null);
setDialogState("list");
setPendingDeleteCredentialId(null);
setPendingDeletePassword(null);
await loadSecurityKeys();
} catch (error) {
toast({
variant: "destructive",
description: formatAxiosError(
error,
t("securityKeyRemoveError")
)
});
} finally {
setDeleteInProgress(false);
}
};
const handleRegister2FASubmit = async (values: { code: string }) => {
if (!pendingRegisterData) return;
try {
setIsRegistering(true);
const startRes = await api.post(
"/auth/security-key/register/start",
{
name: pendingRegisterData.name,
password: pendingRegisterData.password,
code: values.code
}
);
const options = startRes.data.data;
try {
const credential = await startRegistration(options);
await api.post("/auth/security-key/register/verify", {
credential
});
toast({
description: t("securityKeyRegisterSuccess", {
defaultValue: "Security key registered successfully"
})
});
registerForm.reset();
setDialogState("list");
setPendingRegisterData(null);
setRegister2FAForm({ code: "" });
await loadSecurityKeys();
} catch (error: any) {
if (error.name === "NotAllowedError") {
if (error.message.includes("denied permission")) {
toast({
variant: "destructive",
description: t("securityKeyPermissionDenied", {
defaultValue:
"Please allow access to your security key to continue registration."
})
});
} else {
toast({
variant: "destructive",
description: t("securityKeyRemovedTooQuickly", {
defaultValue:
"Please keep your security key connected until the registration process completes."
})
});
}
} else if (error.name === "NotSupportedError") {
toast({
variant: "destructive",
description: t("securityKeyNotSupported", {
defaultValue:
"Your security key may not be compatible. Please try a different security key."
})
});
} else {
toast({
variant: "destructive",
description: t("securityKeyUnknownError", {
defaultValue:
"There was a problem registering your security key. Please try again."
})
});
}
throw error; // Re-throw to be caught by outer catch
}
} catch (error) {
console.error("Security key registration error:", error);
toast({
variant: "destructive",
description: formatAxiosError(
error,
t("securityKeyRegisterError", {
defaultValue: "Failed to register security key"
})
)
});
setRegister2FAForm({ code: "" });
} finally {
setIsRegistering(false);
}
};
const onOpenChange = (open: boolean) => {
if (open) {
loadSecurityKeys();
} else {
registerForm.reset();
deleteForm.reset();
setSelectedSecurityKey(null);
setDialogState("list");
setPendingRegisterData(null);
setRegister2FAForm({ code: "" });
}
setOpen(open);
};
return (
<>
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent>
{dialogState === "list" && (
<>
<CredenzaHeader>
<CredenzaTitle className="flex items-center gap-2">
{t("securityKeyManage")}
</CredenzaTitle>
<CredenzaDescription>
{t("securityKeyDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">
{t("securityKeyList")}
</h3>
<Button
onClick={() =>
setDialogState("register")
}
className="gap-2"
>
<Plus className="h-4 w-4" />
{t("securityKeyAdd")}
</Button>
</div>
{securityKeys.length > 0 ? (
<div className="space-y-2">
{securityKeys.map((securityKey) => (
<Card
key={
securityKey.credentialId
}
>
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-secondary">
<KeyRound className="h-4 w-4 text-secondary-foreground" />
</div>
<div>
<p className="font-medium">
{
securityKey.name
}
</p>
<p className="text-xs text-muted-foreground">
{t(
"securityKeyLastUsed",
{
date: new Date(
securityKey.lastUsed
).toLocaleDateString()
}
)}
</p>
</div>
</div>
<Button
className="h-8 w-8 p-0 text-white hover:text-white/80"
onClick={() => {
setSelectedSecurityKey(
{
credentialId:
securityKey.credentialId,
name: securityKey.name
}
);
setDialogState(
"delete"
);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Shield className="mb-2 h-12 w-12 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{t("securityKeyNoKeysRegistered")}
</p>
<p className="text-xs text-muted-foreground">
{t("securityKeyNoKeysDescription")}
</p>
</div>
)}
{securityKeys.length === 1 && (
<Alert variant="default">
<Info className="h-4 w-4" />
<AlertDescription>
{t("securityKeyRecommendation")}
</AlertDescription>
</Alert>
)}
</div>
</CredenzaBody>
</>
)}
{dialogState === "register" && (
<>
<CredenzaHeader>
<CredenzaTitle>
{t("securityKeyRegisterTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("securityKeyRegisterDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...registerForm}>
<form
onSubmit={registerForm.handleSubmit(
handleRegisterSecurityKey
)}
className="space-y-4"
id="form"
>
<FormField
control={registerForm.control}
name="name"
render={({ field }: FieldProps) => (
<FormItem>
<FormLabel>
{t(
"securityKeyNameLabel"
)}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={
isRegistering
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={registerForm.control}
name="password"
render={({ field }: FieldProps) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
{...field}
type="password"
disabled={
isRegistering
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => {
registerForm.reset();
setDialogState("list");
}}
disabled={isRegistering}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="form"
disabled={isRegistering}
className={cn(
"min-w-[100px]",
isRegistering &&
"cursor-not-allowed opacity-50"
)}
loading={isRegistering}
>
{t("securityKeyRegister")}
</Button>
</CredenzaFooter>
</>
)}
{dialogState === "register2fa" && (
<>
<CredenzaHeader>
<CredenzaTitle>
{t("securityKeyTwoFactorRequired")}
</CredenzaTitle>
<CredenzaDescription>
{t("securityKeyTwoFactorDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t("securityKeyTwoFactorCode")}
</label>
<Input
type="text"
value={register2FAForm.code}
onChange={(e) =>
setRegister2FAForm({
code: e.target.value
})
}
maxLength={6}
disabled={isRegistering}
/>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => {
setRegister2FAForm({ code: "" });
setDialogState("list");
setPendingRegisterData(null);
}}
disabled={isRegistering}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="button"
className="min-w-[100px]"
disabled={
isRegistering ||
register2FAForm.code.length !== 6
}
loading={isRegistering}
onClick={() =>
handleRegister2FASubmit({
code: register2FAForm.code
})
}
>
{t("securityKeyRegister")}
</Button>
</CredenzaFooter>
</>
)}
{dialogState === "delete" && (
<>
<CredenzaHeader>
<CredenzaTitle className="flex items-center gap-2">
{t("securityKeyRemoveTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("securityKeyRemoveDescription", { name: selectedSecurityKey!.name! })}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...deleteForm}>
<form
onSubmit={deleteForm.handleSubmit(
handleDeleteSecurityKey
)}
className="space-y-4"
id="delete-form"
>
<FormField
control={deleteForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
{...field}
type="password"
disabled={
deleteInProgress
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => {
deleteForm.reset();
setSelectedSecurityKey(null);
setDialogState("list");
}}
disabled={deleteInProgress}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="delete-form"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteInProgress}
loading={deleteInProgress}
>
{t("securityKeyRemove")}
</Button>
</CredenzaFooter>
</>
)}
{dialogState === "delete2fa" && (
<>
<CredenzaHeader>
<CredenzaTitle>
{t("securityKeyTwoFactorRequired")}
</CredenzaTitle>
<CredenzaDescription>
{t("securityKeyTwoFactorRemoveDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...deleteForm}>
<form
onSubmit={deleteForm.handleSubmit(
handle2FASubmit
)}
className="space-y-4"
id="delete2fa-form"
>
<FormField
control={deleteForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("securityKeyTwoFactorCode")}
</FormLabel>
<FormControl>
<Input
{...field}
type="text"
maxLength={6}
disabled={
deleteInProgress
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => {
deleteForm.reset();
setDialogState("list");
setPendingDeleteCredentialId(null);
setPendingDeletePassword(null);
}}
disabled={deleteInProgress}
>
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="delete2fa-form"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteInProgress}
loading={deleteInProgress}
>
{t("securityKeyRemove")}
</Button>
</CredenzaFooter>
</>
)}
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { useEffect } from "react";
interface SetLastOrgCookieProps {
orgId: string;
}
export default function SetLastOrgCookie({ orgId }: SetLastOrgCookieProps) {
useEffect(() => {
const isSecure =
typeof window !== "undefined" &&
window.location.protocol === "https:";
document.cookie = `pangolin-last-org=${orgId}; path=/; max-age=${60 * 60 * 24 * 30}; samesite=lax${isSecure ? "; secure" : ""}`;
}, [orgId]);
return null;
}

View File

@@ -19,7 +19,7 @@ export function SettingsSectionForm({
}: {
children: React.ReactNode;
}) {
return <div className="max-w-xl">{children}</div>;
return <div className="max-w-xl space-y-4">{children}</div>;
}
export function SettingsSectionTitle({

View File

@@ -1,35 +1,45 @@
"use client";
import React, { useState, useEffect } from "react";
import React from "react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
export interface SidebarNavItem {
export type SidebarNavItem = {
href: string;
title: string;
icon?: React.ReactNode;
children?: SidebarNavItem[];
autoExpand?: boolean;
showProfessional?: boolean;
}
};
export type SidebarNavSection = {
heading: string;
items: SidebarNavItem[];
};
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: SidebarNavItem[];
sections: SidebarNavSection[];
disabled?: boolean;
onItemClick?: () => void;
isCollapsed?: boolean;
}
export function SidebarNav({
className,
items,
sections,
disabled = false,
onItemClick,
isCollapsed = false,
...props
}: SidebarNavProps) {
const pathname = usePathname();
@@ -38,34 +48,10 @@ export function SidebarNav({
const niceId = params.niceId as string;
const resourceId = params.resourceId as string;
const userId = params.userId as string;
const [expandedItems, setExpandedItems] = useState<Set<string>>(() => {
const autoExpanded = new Set<string>();
function findAutoExpandedAndActivePath(
items: SidebarNavItem[],
parentHrefs: string[] = []
) {
items.forEach((item) => {
const hydratedHref = hydrateHref(item.href);
const currentPath = [...parentHrefs, hydratedHref];
if (item.autoExpand || pathname.startsWith(hydratedHref)) {
currentPath.forEach((href) => autoExpanded.add(href));
}
if (item.children) {
findAutoExpandedAndActivePath(item.children, currentPath);
}
});
}
findAutoExpandedAndActivePath(items);
return autoExpanded;
});
const apiKeyId = params.apiKeyId as string;
const clientId = params.clientId as string;
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const { user } = useUserContext();
const t = useTranslations();
function hydrateHref(val: string): string {
@@ -73,119 +59,114 @@ export function SidebarNav({
.replace("{orgId}", orgId)
.replace("{niceId}", niceId)
.replace("{resourceId}", resourceId)
.replace("{userId}", userId);
.replace("{userId}", userId)
.replace("{apiKeyId}", apiKeyId)
.replace("{clientId}", clientId);
}
function toggleItem(href: string) {
setExpandedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(href)) {
newSet.delete(href);
} else {
newSet.add(href);
}
return newSet;
});
}
const renderNavItem = (
item: SidebarNavItem,
hydratedHref: string,
isActive: boolean,
isDisabled: boolean
) => {
const tooltipText =
item.showProfessional && !isUnlocked()
? `${t(item.title)} (${t("licenseBadge")})`
: t(item.title);
function renderItems(items: SidebarNavItem[], level = 0) {
return items.map((item) => {
const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref);
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.has(hydratedHref);
const indent = level * 28; // Base indent for each level
const isProfessional = item.showProfessional && !isUnlocked();
const isDisabled = disabled || isProfessional;
return (
<div key={hydratedHref}>
<div
className="flex items-center group"
style={{ marginLeft: `${indent}px` }}
const itemContent = (
<Link
href={isDisabled ? "#" : hydratedHref}
className={cn(
"flex items-center rounded transition-colors hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
} else if (onItemClick) {
onItemClick();
}
}}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
{item.icon && (
<span
className={cn("flex-shrink-0", !isCollapsed && "mr-2")}
>
<div
className={cn(
"flex items-center w-full transition-colors rounded-md",
isActive && level === 0 && "bg-primary/10"
)}
>
<Link
href={isProfessional ? "#" : hydratedHref}
className={cn(
"flex items-center w-full px-3 py-2",
isActive
? "text-primary font-medium"
: "text-muted-foreground group-hover:text-foreground",
isDisabled && "cursor-not-allowed"
)}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
} else if (onItemClick) {
onItemClick();
}
}}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
<div
className={cn(
"flex items-center",
isDisabled && "opacity-60"
)}
>
{item.icon && (
<span className="mr-3">
{item.icon}
</span>
)}
{t(item.title)}
</div>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
{t('licenseBadge')}
</Badge>
)}
</Link>
{hasChildren && (
<button
onClick={() => toggleItem(hydratedHref)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
disabled={isDisabled}
>
{isExpanded ? (
<ChevronDown className="h-5 w-5" />
) : (
<ChevronRight className="h-5 w-5" />
)}
</button>
)}
</div>
</div>
{hasChildren && isExpanded && (
<div className="space-y-1 mt-1">
{renderItems(item.children || [], level + 1)}
</div>
)}
</div>
{item.icon}
</span>
)}
{!isCollapsed && (
<>
<span>{t(item.title)}</span>
{item.showProfessional && !isUnlocked() && (
<Badge variant="outlinePrimary" className="ml-2">
{t("licenseBadge")}
</Badge>
)}
</>
)}
</Link>
);
if (isCollapsed) {
return (
<TooltipProvider key={hydratedHref}>
<Tooltip>
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
}
}
return (
<React.Fragment key={hydratedHref}>{itemContent}</React.Fragment>
);
};
return (
<nav
className={cn(
"flex flex-col space-y-2",
"flex flex-col gap-2 text-sm",
disabled && "pointer-events-none opacity-60",
className
)}
{...props}
>
{renderItems(items)}
{sections.map((section) => (
<div key={section.heading} className="mb-2">
{!isCollapsed && (
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{section.heading}
</div>
)}
<div className="flex flex-col gap-1">
{section.items.map((item) => {
const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref);
const isProfessional =
item.showProfessional && !isUnlocked();
const isDisabled = disabled || isProfessional;
return renderNavItem(
item,
hydratedHref,
isActive,
isDisabled || false
);
})}
</div>
</div>
))}
</nav>
);
}

View File

@@ -1,35 +0,0 @@
"use client";
import { SidebarNav } from "@app/components/SidebarNav";
import React from "react";
interface SideBarSettingsProps {
children: React.ReactNode;
sidebarNavItems: Array<{
title: string;
href: string;
icon?: React.ReactNode;
}>;
disabled?: boolean;
limitWidth?: boolean;
}
export function SidebarSettings({
children,
sidebarNavItems,
disabled,
limitWidth
}: SideBarSettingsProps) {
return (
<div className="space-y-4">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-6 lg:space-y-0">
<aside className="lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={disabled} />
</aside>
<div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""} space-y-6`}>
{children}
</div>
</div>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { cn } from "@app/lib/cn";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import { useState } from "react";
interface StrategyOption<TValue extends string> {
export interface StrategyOption<TValue extends string> {
id: TValue;
title: string;
description: string;

View File

@@ -0,0 +1,42 @@
"use client";
import React from "react";
import confetti from "canvas-confetti";
import { Star } from "lucide-react";
import { useTranslations } from 'next-intl';
export default function SupporterMessage({ tier }: { tier: string }) {
const t = useTranslations();
return (
<div className="relative flex items-center space-x-2 whitespace-nowrap group">
<span
className="cursor-pointer"
onClick={(e) => {
// Get the bounding box of the element
const rect = (
e.target as HTMLElement
).getBoundingClientRect();
// Trigger confetti centered on the word "Pangolin"
confetti({
particleCount: 100,
spread: 70,
origin: {
x: (rect.left + rect.width / 2) / window.innerWidth,
y: rect.top / window.innerHeight
},
colors: ["#FFA500", "#FF4500", "#FFD700"]
});
}}
>
Pangolin
</span>
<Star className="w-3 h-3"/>
<div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
{t('componentsSupporterMessage', {tier: tier})}
</div>
</div>
);
}

View File

@@ -9,6 +9,12 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@app/components/ui/tooltip";
import { Button } from "./ui/button";
import {
Credenza,
@@ -46,18 +52,23 @@ import {
CardHeader,
CardTitle
} from "./ui/card";
import { Check, ExternalLink } from "lucide-react";
import { Check, ExternalLink, Heart } from "lucide-react";
import confetti from "canvas-confetti";
import { useTranslations } from "next-intl";
export default function SupporterStatus() {
interface SupporterStatusProps {
isCollapsed?: boolean;
}
export default function SupporterStatus({ isCollapsed = false }: SupporterStatusProps) {
const { supporterStatus, updateSupporterStatus } =
useSupporterStatusContext();
const [supportOpen, setSupportOpen] = useState(false);
const [keyOpen, setKeyOpen] = useState(false);
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const formSchema = z.object({
@@ -208,7 +219,7 @@ export default function SupporterStatus() {
</Link>{" "}
{t('supportKeyPurchase2')}{" "}
<Link
href="https://docs.fossorial.io/supporter-program"
href="https://docs.digpangolin.com/self-host/supporter-program"
target="_blank"
rel="noopener noreferrer"
className="underline"
@@ -411,16 +422,36 @@ export default function SupporterStatus() {
</Credenza>
{supporterStatus?.visible ? (
<Button
variant="outlinePrimary"
size="sm"
className="gap-2 w-full"
onClick={() => {
setPurchaseOptionsOpen(true);
}}
>
{t('supportKeyBuy')}
</Button>
isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="w-8 h-8"
onClick={() => {
setPurchaseOptionsOpen(true);
}}
>
<Heart className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{t('supportKeyBuy')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
size="sm"
className="gap-2 w-full"
onClick={() => {
setPurchaseOptionsOpen(true);
}}
>
{t('supportKeyBuy')}
</Button>
)
) : null}
</>
);

View File

@@ -4,8 +4,9 @@ import { Label } from "./ui/label";
interface SwitchComponentProps {
id: string;
label: string;
label?: string;
description?: string;
checked?: boolean;
defaultChecked?: boolean;
disabled?: boolean;
onCheckedChange: (checked: boolean) => void;
@@ -16,6 +17,7 @@ export function SwitchInput({
label,
description,
disabled,
checked,
defaultChecked = false,
onCheckedChange
}: SwitchComponentProps) {
@@ -24,11 +26,12 @@ export function SwitchInput({
<div className="flex items-center space-x-2 mb-2">
<Switch
id={id}
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={onCheckedChange}
disabled={disabled}
/>
<Label htmlFor={id}>{label}</Label>
{label && <Label htmlFor={id}>{label}</Label>}
</div>
{description && (
<span className="text-muted-foreground text-sm">

View File

@@ -0,0 +1,78 @@
"use client";
import { Button } from "@app/components/ui/button";
import { Laptop, Moon, Sun } from "lucide-react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export default function ThemeSwitcher() {
const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const t = useTranslations();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Button variant="ghost" size="sm" className="h-8">
<Sun className="h-4 w-4 mr-2" />
Light
</Button>
);
}
function cycleTheme() {
const currentTheme = theme || "system";
if (currentTheme === "light") {
setTheme("dark");
} else if (currentTheme === "dark") {
setTheme("system");
} else {
setTheme("light");
}
}
function getThemeIcon() {
const currentTheme = theme || "system";
if (currentTheme === "light") {
return <Sun className="h-4 w-4" />;
} else if (currentTheme === "dark") {
return <Moon className="h-4 w-4" />;
} else {
// When theme is "system", show icon based on resolved theme
if (resolvedTheme === "light") {
return <Sun className="h-4 w-4" />;
} else if (resolvedTheme === "dark") {
return <Moon className="h-4 w-4" />;
} else {
// Fallback to laptop icon if resolvedTheme is not available
return <Laptop className="h-4 w-4" />;
}
}
}
function getThemeText() {
const currentTheme = theme || "system";
const translated = t(currentTheme);
return translated.charAt(0).toUpperCase() + translated.slice(1);
}
return (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={cycleTheme}
title={`Current theme: ${theme || "system"}. Click to cycle themes.`}
>
{getThemeIcon()}
<span className="ml-2">{getThemeText()}</span>
</Button>
);
}

View File

@@ -0,0 +1,327 @@
"use client";
import { useState, forwardRef, useImperativeHandle, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CheckCircle2 } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import {
LoginResponse,
RequestTotpSecretBody,
RequestTotpSecretResponse,
VerifyTotpBody,
VerifyTotpResponse
} from "@server/routers/auth";
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 CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas } from "qrcode.react";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
type TwoFactorSetupFormProps = {
onComplete?: (email: string, password: string) => void;
onCancel?: () => void;
isDialog?: boolean;
email?: string;
password?: string;
submitButtonText?: string;
cancelButtonText?: string;
showCancelButton?: boolean;
onStepChange?: (step: number) => void;
onLoadingChange?: (loading: boolean) => void;
};
const TwoFactorSetupForm = forwardRef<
{ handleSubmit: () => void },
TwoFactorSetupFormProps
>(
(
{
onComplete,
onCancel,
isDialog = false,
email,
password: initialPassword,
submitButtonText,
cancelButtonText,
showCancelButton = false,
onStepChange,
onLoadingChange
},
ref
) => {
const [step, setStep] = useState(1);
const [secretKey, setSecretKey] = useState("");
const [secretUri, setSecretUri] = useState("");
const [loading, setLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const api = createApiClient(useEnvContext());
const t = useTranslations();
// Notify parent of step and loading changes
useEffect(() => {
onStepChange?.(step);
}, [step, onStepChange]);
useEffect(() => {
onLoadingChange?.(loading);
}, [loading, onLoadingChange]);
const enableSchema = z.object({
password: z.string().min(1, { message: t("passwordRequired") })
});
const confirmSchema = z.object({
code: z.string().length(6, { message: t("pincodeInvalid") })
});
const enableForm = useForm<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema),
defaultValues: {
password: initialPassword || ""
}
});
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
resolver: zodResolver(confirmSchema),
defaultValues: {
code: ""
}
});
const request2fa = async (values: z.infer<typeof enableSchema>) => {
setLoading(true);
const endpoint = `/auth/2fa/request`;
const payload = { email, password: values.password };
const res = await api
.post<
AxiosResponse<RequestTotpSecretResponse>
>(endpoint, payload)
.catch((e) => {
toast({
title: t("otpErrorEnable"),
description: formatAxiosError(
e,
t("otpErrorEnableDescription")
),
variant: "destructive"
});
});
if (res && res.data.data.secret) {
setSecretKey(res.data.data.secret);
setSecretUri(res.data.data.uri);
setStep(2);
}
setLoading(false);
};
const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
setLoading(true);
const endpoint = `/auth/2fa/enable`;
const payload = {
email,
password: enableForm.getValues().password,
code: values.code
};
const res = await api
.post<AxiosResponse<VerifyTotpResponse>>(endpoint, payload)
.catch((e) => {
toast({
title: t("otpErrorEnable"),
description: formatAxiosError(
e,
t("otpErrorEnableDescription")
),
variant: "destructive"
});
});
if (res && res.data.data.valid) {
setBackupCodes(res.data.data.backupCodes || []);
await api
.post<AxiosResponse<LoginResponse>>("/auth/login", {
email,
password: enableForm.getValues().password,
code: values.code
})
.catch((e) => {
console.error(e);
});
setStep(3);
}
setLoading(false);
};
const handleSubmit = () => {
if (step === 1) {
enableForm.handleSubmit(request2fa)();
} else if (step === 2) {
confirmForm.handleSubmit(confirm2fa)();
}
};
const handleComplete = (email: string, password: string) => {
if (onComplete) {
onComplete(email, password);
}
};
useImperativeHandle(ref, () => ({
handleSubmit
}));
return (
<div className="space-y-4">
{step === 1 && (
<Form {...enableForm}>
<form
onSubmit={enableForm.handleSubmit(request2fa)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={enableForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<p>{t("otpSetupScanQr")}</p>
<div className="h-[250px] mx-auto flex items-center justify-center">
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox text={secretUri} wrapText={false} />
</div>
<Form {...confirmForm}>
<form
onSubmit={confirmForm.handleSubmit(confirm2fa)}
className="space-y-4"
id="form"
>
<FormField
control={confirmForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("otpSetupSecretCode")}
</FormLabel>
<FormControl>
<Input type="code" {...field} />
</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("otpSetupSuccess")}
</p>
<p>{t("otpSetupSuccessStoreBackupCodes")}</p>
{backupCodes.length > 0 && (
<div className="max-w-md mx-auto">
<CopyTextBox text={backupCodes.join("\n")} />
</div>
)}
</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(
email!,
enableForm.getValues().password
)
}
className="w-full"
>
{t("continueToApplication")}
</Button>
)}
</div>
)}
</div>
);
}
);
export default TwoFactorSetupForm;

View File

@@ -173,7 +173,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(maxTags !== undefined && maxTags < 0) ||
(props.minTags !== undefined && props.minTags < 0)
) {
console.warn(t('tagsWarnCannotBeLessThanZero'));
console.warn(t("tagsWarnCannotBeLessThanZero"));
// error
return null;
}
@@ -197,22 +197,28 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
(option) => option.text === newTagText
)
) {
console.warn(t('tagsWarnNotAllowedAutocompleteOptions'));
console.warn(
t("tagsWarnNotAllowedAutocompleteOptions")
);
return;
}
if (validateTag && !validateTag(newTagText)) {
console.warn(t('tagsWarnInvalid'));
console.warn(t("tagsWarnInvalid"));
return;
}
if (minLength && newTagText.length < minLength) {
console.warn(t('tagWarnTooShort', {tagText: newTagText}));
console.warn(
t("tagWarnTooShort", { tagText: newTagText })
);
return;
}
if (maxLength && newTagText.length > maxLength) {
console.warn(t('tagWarnTooLong', {tagText: newTagText}));
console.warn(
t("tagWarnTooLong", { tagText: newTagText })
);
return;
}
@@ -229,10 +235,12 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
setTags((prevTags) => [...prevTags, newTag]);
onTagAdd?.(newTagText);
} else {
console.warn(t('tagsWarnReachedMaxNumber'));
console.warn(t("tagsWarnReachedMaxNumber"));
}
} else {
console.warn(t('tagWarnDuplicate', {tagText: newTagText}));
console.warn(
t("tagWarnDuplicate", { tagText: newTagText })
);
}
});
setInputValue("");
@@ -258,12 +266,12 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
}
if (minLength && newTagText.length < minLength) {
console.warn(t('tagWarnTooShort'));
console.warn(t("tagWarnTooShort"));
return;
}
if (maxLength && newTagText.length > maxLength) {
console.warn(t('tagWarnTooLong'));
console.warn(t("tagWarnTooLong"));
return;
}
@@ -308,7 +316,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
}
if (minLength && newTagText.length < minLength) {
console.warn(t('tagWarnTooShort'));
console.warn(t("tagWarnTooShort"));
// error
return;
}
@@ -316,7 +324,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
// Validate maxLength
if (maxLength && newTagText.length > maxLength) {
// error
console.warn(t('tagWarnTooLong'));
console.warn(t("tagWarnTooLong"));
return;
}
@@ -489,7 +497,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
<div className="w-full">
<div
className={cn(
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-transparent`,
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50`,
styleClasses?.inlineTagsContainer
)}
>
@@ -536,7 +544,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className,
styleClasses?.input
)}
@@ -622,7 +630,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className,
styleClasses?.input
)}
@@ -643,7 +651,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
) : (
<div
className={cn(
`flex flex-row flex-wrap items-center p-1.5 gap-1.5 h-fit w-full bg-transparent text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`,
`flex flex-row flex-wrap items-center p-1.5 gap-1.5 h-fit w-full bg-transparent text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50`,
styleClasses?.inlineTagsContainer
)}
>
@@ -710,7 +718,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className,
styleClasses?.input
)}
@@ -791,7 +799,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur}
{...inputProps}
className={cn(
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit",
"border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none",
// className,
styleClasses?.input
)}
@@ -834,7 +842,8 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onBlur={handleInputBlur}
{...inputProps}
className={cn(
styleClasses?.input
styleClasses?.input,
"shadow-none inset-shadow-none"
// className
)}
autoComplete={
@@ -908,7 +917,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
tags.length >= maxTags)
}
className={cn(
"border-0 w-full",
"border-0 w-full shadow-none inset-shadow-none",
styleClasses?.input
// className
)}

View File

@@ -127,7 +127,7 @@ export const Tag: React.FC<TagProps> = ({
{
"justify-between w-full": direction === "column",
"cursor-pointer": draggable,
"ring-ring ring-offset-2 ring-2 ring-offset-background":
"ring-ring ring-offset-0 ring-2 ring-offset-background":
isActiveTag
},
tagClasses?.body

View File

@@ -14,14 +14,13 @@ const alertVariants = cva(
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
info:
"border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500",
},
info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500"
}
},
defaultVariants: {
variant: "default",
},
},
variant: "default"
}
}
);
const Alert = React.forwardRef<
@@ -45,7 +44,7 @@ const AlertTitle = React.forwardRef<
ref={ref}
className={cn(
"mb-1 font-medium leading-none tracking-tight",
className,
className
)}
{...props}
/>

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0",
{
variants: {
variant: {
@@ -16,9 +16,9 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
green: "border-transparent bg-green-500",
yellow: "border-transparent bg-yellow-500",
red: "border-transparent bg-red-300",
green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300",
yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300",
red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300",
},
},
defaultVariants: {

View File

@@ -6,35 +6,35 @@ import { cn } from "@app/lib/cn";
import { Loader2 } from "lucide-react";
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center rounded-full whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
"bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xs",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 shadow-2xs",
outline:
"border border-input bg-card hover:bg-accent hover:text-accent-foreground",
"border border-input bg-card hover:bg-accent hover:text-accent-foreground shadow-2xs",
outlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary",
"border border-primary bg-card hover:bg-primary/10 text-primary shadow-2xs",
secondary:
"bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80",
"bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80 shadow-2xs",
ghost: "hover:bg-accent hover:text-accent-foreground",
squareOutlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary rounded-md",
"border border-primary bg-card hover:bg-primary/10 text-primary rounded-md shadow-2xs",
squareOutline:
"border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md",
"border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md shadow-2xs",
squareDefault:
"bg-primary text-primary-foreground hover:bg-primary/90 rounded-md",
"bg-primary text-primary-foreground hover:bg-primary/90 rounded-md shadow-2xs",
text: "",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-9 px-4 py-2",
default: "h-9 rounded-md px-3",
sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9"
icon: "h-9 w-9 rounded-md"
}
},
defaultVariants: {

View File

@@ -9,7 +9,7 @@ import { cva, type VariantProps } from "class-variance-authority";
// Define checkbox variants
const checkboxVariants = cva(
"peer h-4 w-4 shrink-0 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"peer h-4 w-4 shrink-0 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {

View File

@@ -1,155 +1,183 @@
"use client";
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { SearchIcon } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import { cn } from "@app/lib/cn";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
);
}
interface CommandDialogProps extends DialogProps {}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-base md:text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
));
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
);
}
CommandInput.displayName = CommandPrimitive.Input.displayName;
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
);
}
CommandList.displayName = CommandPrimitive.List.displayName;
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none rounded-md items-center px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator
};

View File

@@ -23,13 +23,14 @@ import { Button } from "@app/components/ui/button";
import { useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search } from "lucide-react";
import { Plus, Search, RefreshCw } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { useTranslations } from "next-intl";
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
@@ -37,6 +38,8 @@ type DataTableProps<TData, TValue> = {
title?: string;
addButtonText?: string;
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
searchPlaceholder?: string;
searchColumn?: string;
defaultSort?: {
@@ -51,6 +54,8 @@ export function DataTable<TData, TValue>({
title,
addButtonText,
onAdd,
onRefresh,
isRefreshing,
searchPlaceholder = "Search...",
searchColumn = "name",
defaultSort
@@ -60,6 +65,7 @@ export function DataTable<TData, TValue>({
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState<any>([]);
const t = useTranslations();
const table = useReactTable({
data,
@@ -87,8 +93,8 @@ export function DataTable<TData, TValue>({
return (
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<div className="flex items-center max-w-sm w-full relative mr-2">
<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 items-center w-full sm:max-w-sm sm:mr-2 relative">
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
@@ -99,12 +105,26 @@ export function DataTable<TData, TValue>({
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
{onAdd && addButtonText && (
<Button onClick={onAdd}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
)}
<div className="flex items-center gap-2 sm:justify-end">
{onRefresh && (
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("refresh")}
</Button>
)}
{onAdd && addButtonText && (
<Button onClick={onAdd}>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
)}
</div>
</CardHeader>
<CardContent>
<Table>

View File

@@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-99 data-[state=open]:zoom-in-99 data-[state=closed]:slide-out-to-bottom-[5%] data-[state=open]:slide-in-from-bottom-[5%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:scale-95 data-[state=open]:scale-100 sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -2,199 +2,263 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@app/lib/cn";
const DropdownMenu = DropdownMenuPrimitive.Root;
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal
data-slot="dropdown-menu-portal"
{...props}
/>
);
}
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group
data-slot="dropdown-menu-group"
{...props}
/>
);
}
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return (
<DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
);
}
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent
};

View File

@@ -5,15 +5,16 @@ import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues
} from "react-hook-form";
import { cn } from "@app/lib/cn";
import { Label } from "@/components/ui/label";
import { cn } from "@app/lib/cn";
const Form = FormProvider;
@@ -44,8 +45,8 @@ const FormField = <
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
@@ -72,47 +73,44 @@ const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
}
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
}
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
@@ -123,32 +121,24 @@ const FormControl = React.forwardRef<
{...props}
/>
);
});
FormControl.displayName = "FormControl";
}
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
data-slot="form-description"
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
}
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
@@ -156,16 +146,15 @@ const FormMessage = React.forwardRef<
return (
<p
ref={ref}
data-slot="form-message"
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
}
export {
useFormField,

View File

@@ -11,11 +11,12 @@ import { Button } from "@/components/ui/button";
interface InfoPopupProps {
text?: string;
info: string;
info?: string;
trigger?: React.ReactNode;
children?: React.ReactNode;
}
export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) {
const defaultTrigger = (
<Button
variant="ghost"
@@ -35,7 +36,12 @@ export function InfoPopup({ text, info, trigger }: InfoPopupProps) {
{trigger ?? defaultTrigger}
</PopoverTrigger>
<PopoverContent className="w-80">
<p className="text-sm text-muted-foreground">{info}</p>
{children ||
(info && (
<p className="text-sm text-muted-foreground">
{info}
</p>
))}
</PopoverContent>
</Popover>
</div>

View File

@@ -2,70 +2,80 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { MinusIcon } from "lucide-react";
import { cn } from "@app/lib/cn";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput> & { obscured?: boolean }
>(({ className, containerClassName, obscured = false, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
function InputOTP({
className,
containerClassName,
obscured = false,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
obscured?: boolean;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
InputOTPGroup.displayName = "InputOTPGroup";
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
);
}
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number; obscured?: boolean }
>(({ index, className, obscured = false, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
function InputOTPSlot({
index,
className,
obscured = false,
...props
}: React.ComponentProps<"div"> & {
index: number;
obscured?: boolean;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } =
inputOTPContext?.slots[index] ?? {};
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-base md:text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char && obscured ? "•" : char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char && obscured ? "•" : char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
);
}
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

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

View File

@@ -2,25 +2,22 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@app/lib/cn";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

View File

@@ -2,39 +2,46 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@app/lib/cn";
const Popover = PopoverPrimitive.Root;
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
const PopoverTrigger = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<PopoverPrimitive.Trigger
ref={ref}
className={cn(className, "rounded-md")}
{...props}
/>
));
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
function PopoverContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
export { Popover, PopoverTrigger, PopoverContent };
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -3,24 +3,60 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@app/lib/cn";
import { cva, type VariantProps } from "class-variance-authority";
const progressVariants = cva(
"border relative h-2 w-full overflow-hidden rounded-full",
{
variants: {
variant: {
default: "bg-muted",
success: "bg-muted",
warning: "bg-muted",
danger: "bg-muted"
}
},
defaultVariants: {
variant: "default"
}
}
);
const indicatorVariants = cva(
"h-full w-full flex-1 transition-all",
{
variants: {
variant: {
default: "bg-primary",
success: "bg-green-500",
warning: "bg-yellow-500",
danger: "bg-red-500"
}
},
defaultVariants: {
variant: "default"
}
}
);
type ProgressProps = React.ComponentProps<typeof ProgressPrimitive.Root> &
VariantProps<typeof progressVariants>;
function Progress({
className,
value,
variant,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
}: ProgressProps) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"border relative h-2 w-full overflow-hidden rounded-full",
className
)}
className={cn(progressVariants({ variant }), className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
className={cn(indicatorVariants({ variant }))}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -2,160 +2,189 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@app/lib/cn";
const Select = SelectPrimitive.Root;
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
const SelectGroup = SelectPrimitive.Group;
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
const SelectValue = SelectPrimitive.Value;
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between border border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"rounded-md",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs",
className
)}
{...props}
/>
);
}
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"bg-border pointer-events-none -mx-1 my-1 h-px",
className
)}
{...props}
/>
);
}
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue
};

View File

@@ -65,7 +65,7 @@ const SheetContent = React.forwardRef<
{...props}
>
{children}
{/* <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> */}
{/* <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0 disabled:pointer-events-none data-[state=open]:bg-secondary"> */}
{/* <X className="h-4 w-4" /> */}
{/* <span className="sr-only">Close</span> */}
{/* </SheetPrimitive.Close> */}

View File

@@ -1,29 +1,30 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@app/lib/cn";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"cursor-pointer peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"cursor-pointer bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-3.5 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(112%-2px)] data-[state=unchecked]:translate-x-[calc(18%)]"
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground",
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground",
className
)}
{...props}
@@ -44,7 +44,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-6 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"mt-6 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
className
)}
{...props}

View File

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

View File

@@ -10,120 +10,120 @@ import { cn } from "@app/lib/cn";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]",
className
)}
{...props}
/>
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed bottom-0 left-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:top-0 md:bottom-auto md:left-1/2 md:-translate-x-1/2 md:max-w-[420px]",
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[swipe=end]:animate-out",
{
variants: {
variant: {
default: "border bg-card text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
"shadow-sm group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[swipe=end]:animate-out",
{
variants: {
variant: {
default: "border bg-card text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-white dark:text-destructive-foreground"
}
},
defaultVariants: {
variant: "default"
}
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction
};

View File

@@ -2,34 +2,42 @@
import { useToast } from "@/hooks/useToast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport
} from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props} className="mt-2">
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
return (
<ToastProvider>
{toasts.map(function ({
id,
title,
description,
action,
...props
}) {
return (
<Toast key={id} {...props} className="mt-2">
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>
{description}
</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@app/lib/cn";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };