add multi site selector for ha on private resources

This commit is contained in:
miloschwartz
2026-04-12 12:17:45 -07:00
parent 83ecf53776
commit 1564c4bee7
12 changed files with 356 additions and 79 deletions

View File

@@ -1837,6 +1837,7 @@
"createInternalResourceDialogName": "Name", "createInternalResourceDialogName": "Name",
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"selectSite": "Select site...", "selectSite": "Select site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"noSitesFound": "No sites found.", "noSitesFound": "No sites found.",
"createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogProtocol": "Protocol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2673,7 +2674,7 @@
"editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationLabel": "Destination",
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it, then complete the settings that apply to your setup.", "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.",
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
"createInternalResourceDialogHttpConfiguration": "HTTP configuration", "createInternalResourceDialogHttpConfiguration": "HTTP configuration",
"createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", "createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",

View File

@@ -471,11 +471,7 @@ export default function GeneralPage() {
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}` : `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
} }
> >
<Button <Button variant="outline" size="sm">
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>

View File

@@ -451,11 +451,7 @@ export default function ConnectionLogsPage() {
<Link <Link
href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`} href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`}
> >
<Button <Button variant="outline" size="sm">
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>
@@ -497,11 +493,7 @@ export default function ConnectionLogsPage() {
<Link <Link
href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`} href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`}
> >
<Button <Button variant="outline" size="sm">
variant="outline"
size="sm"
className="text-xs h-6"
>
<Laptop className="mr-1 h-3 w-3" /> <Laptop className="mr-1 h-3 w-3" />
{row.original.clientName} {row.original.clientName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
@@ -675,9 +667,7 @@ export default function ConnectionLogsPage() {
<div> <div>
<strong>Ended At:</strong>{" "} <strong>Ended At:</strong>{" "}
{row.endedAt {row.endedAt
? new Date( ? new Date(row.endedAt * 1000).toLocaleString()
row.endedAt * 1000
).toLocaleString()
: "Active"} : "Active"}
</div> </div>
<div> <div>

View File

@@ -513,11 +513,7 @@ export default function GeneralPage() {
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`} href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Button <Button variant="outline" size="sm">
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>

View File

@@ -60,21 +60,29 @@ export default async function ClientResourcesPage(
const normalizedMode = const normalizedMode =
rawMode === "https" rawMode === "https"
? ("http" as const) ? ("http" as const)
: rawMode === "host" || rawMode === "cidr" || rawMode === "http" : rawMode === "host" ||
rawMode === "cidr" ||
rawMode === "http"
? rawMode ? rawMode
: ("host" as const); : ("host" as const);
return { return {
id: siteResource.siteResourceId, id: siteResource.siteResourceId,
name: siteResource.name, name: siteResource.name,
orgId: params.orgId, orgId: params.orgId,
sites: [
{
siteId: siteResource.siteId,
siteName: siteResource.siteName,
siteNiceId: siteResource.siteNiceId
}
],
siteName: siteResource.siteName, siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null, siteAddress: siteResource.siteAddress || null,
mode: normalizedMode, mode: normalizedMode,
scheme: scheme:
siteResource.scheme ?? siteResource.scheme ??
(rawMode === "https" ? ("https" as const) : null), (rawMode === "https" ? ("https" as const) : null),
ssl: ssl: siteResource.ssl === true || rawMode === "https",
siteResource.ssl === true || rawMode === "https",
// protocol: siteResource.protocol, // protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort, // proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId, siteId: siteResource.siteId,

View File

@@ -38,11 +38,23 @@ import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
export type InternalResourceSiteRow = {
siteId: number;
siteName: string;
siteNiceId: string;
};
export type InternalResourceRow = { export type InternalResourceRow = {
id: number; id: number;
name: string; name: string;
orgId: string; orgId: string;
sites: InternalResourceSiteRow[];
siteName: string; siteName: string;
siteAddress: string | null; siteAddress: string | null;
// mode: "host" | "cidr" | "port"; // mode: "host" | "cidr" | "port";
@@ -101,6 +113,102 @@ function isSafeUrlForLink(href: string): boolean {
} }
} }
const MAX_SITE_LINKS = 3;
function ClientResourceSiteLinks({
orgId,
sites
}: {
orgId: string;
sites: InternalResourceSiteRow[];
}) {
if (sites.length === 0) {
return <span>-</span>;
}
const visible = sites.slice(0, MAX_SITE_LINKS);
const overflow = sites.slice(MAX_SITE_LINKS);
return (
<div className="flex flex-wrap items-center gap-1">
{visible.map((site) => (
<Link
key={site.siteId}
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
>
<Button
variant="outline"
size="sm"
className="w-full gap-1"
>
<span className="max-w-[10rem] truncate">
{site.siteName}
</span>
<ArrowUpRight className="h-3.5 w-3.5 shrink-0" />
</Button>
</Link>
))}
{overflow.length > 0 ? (
<OverflowSitesPopover orgId={orgId} sites={overflow} />
) : null}
</div>
);
}
function OverflowSitesPopover({
orgId,
sites
}: {
orgId: string;
sites: InternalResourceSiteRow[];
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className="gap-1 px-2 font-normal"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
+{sites.length}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-auto max-w-xs p-2"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<ul className="flex flex-col gap-1.5 text-sm">
{sites.map((site) => (
<li key={site.siteId}>
<Link
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
>
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-1"
>
<span className="truncate">
{site.siteName}
</span>
<ArrowUpRight className="ml-auto h-3.5 w-3.5 shrink-0" />
</Button>
</Link>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
type ClientResourcesTableProps = { type ClientResourcesTableProps = {
internalResources: InternalResourceRow[]; internalResources: InternalResourceRow[];
orgId: string; orgId: string;
@@ -223,20 +331,18 @@ export default function ClientResourcesTable({
} }
}, },
{ {
accessorKey: "siteName", id: "sites",
friendlyName: t("site"), accessorFn: (row) =>
header: () => <span className="p-3">{t("site")}</span>, row.sites.map((s) => s.siteName).join(", ") || row.siteName,
friendlyName: t("sites"),
header: () => <span className="p-3">{t("sites")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<Link <ClientResourceSiteLinks
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`} orgId={resourceRow.orgId}
> sites={resourceRow.sites}
<Button variant="outline"> />
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
); );
} }
}, },

View File

@@ -68,7 +68,7 @@ export default function CreateInternalResourceDialog({
`/org/${orgId}/site-resource`, `/org/${orgId}/site-resource`,
{ {
name: data.name, name: data.name,
siteId: data.siteId, siteId: data.siteIds[0],
mode: data.mode, mode: data.mode,
destination: data.destination, destination: data.destination,
enabled: true, enabled: true,

View File

@@ -70,7 +70,7 @@ export default function EditInternalResourceDialog({
await api.post(`/site-resource/${resource.id}`, { await api.post(`/site-resource/${resource.id}`, {
name: data.name, name: data.name,
siteId: data.siteId, siteId: data.siteIds[0],
mode: data.mode, mode: data.mode,
niceId: data.niceId, niceId: data.niceId,
destination: data.destination, destination: data.destination,
@@ -78,8 +78,12 @@ export default function EditInternalResourceDialog({
scheme: data.scheme, scheme: data.scheme,
ssl: data.ssl ?? false, ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? null, destinationPort: data.httpHttpsPort ?? null,
domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined, domainId: data.httpConfigDomainId
subdomain: data.httpConfigSubdomain ? data.httpConfigSubdomain : undefined ? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}), }),
...(data.mode === "host" && { ...(data.mode === "host" && {
alias: alias:

View File

@@ -46,7 +46,11 @@ import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { SitesSelector, type Selectedsite } from "./site-selector"; import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import type { Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector"; import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
@@ -153,9 +157,32 @@ export type InternalResourceData = {
const tagSchema = z.object({ id: z.string(), text: z.string() }); const tagSchema = z.object({ id: z.string(), text: z.string() });
function buildSelectedSitesForResource(
resource: InternalResourceData,
catalog: Site[]
): Selectedsite[] {
const fromCatalog = catalog.find((s) => s.siteId === resource.siteId);
if (fromCatalog) {
return [
{
name: fromCatalog.name,
siteId: fromCatalog.siteId,
type: fromCatalog.type
}
];
}
return [
{
name: resource.siteName,
siteId: resource.siteId,
type: "newt"
}
];
}
export type InternalResourceFormValues = { export type InternalResourceFormValues = {
name: string; name: string;
siteId: number; siteIds: number[];
mode: InternalResourceMode; mode: InternalResourceMode;
destination: string; destination: string;
alias?: string | null; alias?: string | null;
@@ -272,13 +299,14 @@ export function InternalResourceForm({
? "createInternalResourceDialogHttpConfigurationDescription" ? "createInternalResourceDialogHttpConfigurationDescription"
: "editInternalResourceDialogHttpConfigurationDescription"; : "editInternalResourceDialogHttpConfigurationDescription";
const siteIdsSchema = siteRequiredKey
? z.array(z.number().int().positive()).min(1, t(siteRequiredKey))
: z.array(z.number().int().positive()).min(1);
const formSchema = z const formSchema = z
.object({ .object({
name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)),
siteId: z siteIds: siteIdsSchema,
.number()
.int()
.positive(siteRequiredKey ? t(siteRequiredKey) : undefined),
mode: z.enum(["host", "cidr", "http"]), mode: z.enum(["host", "cidr", "http"]),
destination: z destination: z
.string() .string()
@@ -467,7 +495,7 @@ export function InternalResourceForm({
variant === "edit" && resource variant === "edit" && resource
? { ? {
name: resource.name, name: resource.name,
siteId: resource.siteId, siteIds: [resource.siteId],
mode: resource.mode ?? "host", mode: resource.mode ?? "host",
destination: resource.destination ?? "", destination: resource.destination ?? "",
alias: resource.alias ?? null, alias: resource.alias ?? null,
@@ -489,7 +517,7 @@ export function InternalResourceForm({
} }
: { : {
name: "", name: "",
siteId: availableSites[0]?.siteId ?? 0, siteIds: availableSites[0] ? [availableSites[0].siteId] : [],
mode: "host", mode: "host",
destination: "", destination: "",
alias: null, alias: null,
@@ -509,8 +537,18 @@ export function InternalResourceForm({
clients: [] clients: []
}; };
const [selectedSite, setSelectedSite] = useState<Selectedsite>( const [selectedSites, setSelectedSites] = useState<Selectedsite[]>(() =>
availableSites[0] variant === "edit" && resource
? buildSelectedSitesForResource(resource, sites)
: availableSites[0]
? [
{
name: availableSites[0].name,
siteId: availableSites[0].siteId,
type: availableSites[0].type
}
]
: []
); );
const form = useForm<FormData>({ const form = useForm<FormData>({
@@ -542,7 +580,7 @@ export function InternalResourceForm({
if (variant === "create" && open) { if (variant === "create" && open) {
form.reset({ form.reset({
name: "", name: "",
siteId: availableSites[0]?.siteId ?? 0, siteIds: availableSites[0] ? [availableSites[0].siteId] : [],
mode: "host", mode: "host",
destination: "", destination: "",
alias: null, alias: null,
@@ -561,12 +599,23 @@ export function InternalResourceForm({
users: [], users: [],
clients: [] clients: []
}); });
setSelectedSites(
availableSites[0]
? [
{
name: availableSites[0].name,
siteId: availableSites[0].siteId,
type: availableSites[0].type
}
]
: []
);
setTcpPortMode("all"); setTcpPortMode("all");
setUdpPortMode("all"); setUdpPortMode("all");
setTcpCustomPorts(""); setTcpCustomPorts("");
setUdpCustomPorts(""); setUdpCustomPorts("");
} }
}, [variant, open]); }, [variant, open, form, sites]);
// Reset when edit dialog opens / resource changes // Reset when edit dialog opens / resource changes
useEffect(() => { useEffect(() => {
@@ -575,7 +624,7 @@ export function InternalResourceForm({
if (resourceChanged) { if (resourceChanged) {
form.reset({ form.reset({
name: resource.name, name: resource.name,
siteId: resource.siteId, siteIds: [resource.siteId],
mode: resource.mode ?? "host", mode: resource.mode ?? "host",
destination: resource.destination ?? "", destination: resource.destination ?? "",
alias: resource.alias ?? null, alias: resource.alias ?? null,
@@ -594,6 +643,9 @@ export function InternalResourceForm({
users: [], users: [],
clients: [] clients: []
}); });
setSelectedSites(
buildSelectedSitesForResource(resource, sites)
);
setTcpPortMode( setTcpPortMode(
getPortModeFromString(resource.tcpPortRangeString) getPortModeFromString(resource.tcpPortRangeString)
); );
@@ -615,7 +667,7 @@ export function InternalResourceForm({
previousResourceId.current = resource.id; previousResourceId.current = resource.id;
} }
} }
}, [variant, resource, form]); }, [variant, resource, form, sites]);
// When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data
useEffect(() => { useEffect(() => {
@@ -651,8 +703,10 @@ export function InternalResourceForm({
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit((values) => { onSubmit={form.handleSubmit((values) => {
const siteIds = values.siteIds;
onSubmit({ onSubmit({
...values, ...values,
siteIds,
clients: (values.clients ?? []).map((c) => ({ clients: (values.clients ?? []).map((c) => ({
id: c.clientId.toString(), id: c.clientId.toString(),
text: c.name text: c.name
@@ -729,11 +783,11 @@ export function InternalResourceForm({
<div className="min-w-0 col-span-1"> <div className="min-w-0 col-span-1">
<FormField <FormField
control={form.control} control={form.control}
name="siteId" name="siteIds"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel> <FormLabel>
{t("site")} {t("sites")}
</FormLabel> </FormLabel>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -743,40 +797,41 @@ export function InternalResourceForm({
role="combobox" role="combobox"
className={cn( className={cn(
"w-full justify-between", "w-full justify-between",
!field.value && selectedSites.length ===
0 &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{field.value <span className="truncate text-left">
? availableSites.find( {formatMultiSitesSelectorLabel(
(s) => selectedSites,
s.siteId === t
field.value
)?.name
: t(
"selectSite"
)} )}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-full p-0"> <PopoverContent className="w-full p-0">
<SitesSelector <MultiSitesSelector
orgId={orgId} orgId={orgId}
selectedSite={ selectedSites={
selectedSite selectedSites
} }
filterTypes={[ filterTypes={[
"newt" "newt"
]} ]}
onSelectSite={( onSelectionChange={(
site sites
) => { ) => {
setSelectedSite( setSelectedSites(
site sites
); );
field.onChange( field.onChange(
site.siteId sites.map(
(s) =>
s.siteId
)
); );
}} }}
/> />

View File

@@ -405,7 +405,11 @@ export function LogDataTable<TData, TValue>({
onClick={() => onClick={() =>
!disabled && onExport() !disabled && onExport()
} }
disabled={isExporting || disabled || isExportDisabled} disabled={
isExporting ||
disabled ||
isExportDisabled
}
> >
{isExporting ? ( {isExporting ? (
<Loader className="mr-2 size-4 animate-spin" /> <Loader className="mr-2 size-4 animate-spin" />

View File

@@ -0,0 +1,117 @@
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Checkbox } from "./ui/checkbox";
import { useTranslations } from "next-intl";
import { useDebounce } from "use-debounce";
import type { Selectedsite } from "./site-selector";
export type MultiSitesSelectorProps = {
orgId: string;
selectedSites: Selectedsite[];
onSelectionChange: (sites: Selectedsite[]) => void;
filterTypes?: string[];
};
export function formatMultiSitesSelectorLabel(
selectedSites: Selectedsite[],
t: (key: string, values?: { count: number }) => string
): string {
if (selectedSites.length === 0) {
return t("selectSites");
}
if (selectedSites.length === 1) {
return selectedSites[0]!.name;
}
return t("multiSitesSelectorSitesCount", {
count: selectedSites.length
});
}
export function MultiSitesSelector({
orgId,
selectedSites,
onSelectionChange,
filterTypes
}: MultiSitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
const { data: sites = [] } = useQuery(
orgQueries.sites({
orgId,
query: debouncedQuery,
perPage: 10
})
);
const sitesShown = useMemo(() => {
const base = filterTypes
? sites.filter((s) => filterTypes.includes(s.type))
: [...sites];
if (debouncedQuery.trim().length === 0 && selectedSites.length > 0) {
const selectedNotInBase = selectedSites.filter(
(sel) => !base.some((s) => s.siteId === sel.siteId)
);
return [...selectedNotInBase, ...base];
}
return base;
}, [debouncedQuery, sites, selectedSites, filterTypes]);
const selectedIds = useMemo(
() => new Set(selectedSites.map((s) => s.siteId)),
[selectedSites]
);
const toggleSite = (site: Selectedsite) => {
if (selectedIds.has(site.siteId)) {
onSelectionChange(
selectedSites.filter((s) => s.siteId !== site.siteId)
);
} else {
onSelectionChange([...selectedSites, site]);
}
};
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("siteSearch")}
value={siteSearchQuery}
onValueChange={(v) => setSiteSearchQuery(v)}
/>
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{sitesShown.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() => {
toggleSite(site);
}}
>
<Checkbox
className="pointer-events-none shrink-0"
checked={selectedIds.has(site.siteId)}
onCheckedChange={() => {}}
aria-hidden
tabIndex={-1}
/>
<span className="truncate">{site.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}

View File

@@ -43,8 +43,8 @@ const Checkbox = React.forwardRef<
className={cn(checkboxVariants({ variant }), className)} className={cn(checkboxVariants({ variant }), className)}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current"> <CheckboxPrimitive.Indicator className="flex items-center justify-center">
<Check className="h-4 w-4" /> <Check className="h-4 w-4 text-white" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
)); ));