diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f8ad3f385..6ca067ade 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1392,6 +1392,16 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; +export type ResourcePolicyPincode = InferSelectModel< + typeof resourcePolicyPincode +>; +export type ResourcePolicyPassword = InferSelectModel< + typeof resourcePolicyPassword +>; +export type ResourcePolicyHeaderAuth = InferSelectModel< + typeof resourcePolicyHeaderAuth +>; + export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 0d03112a5..f10cd407d 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -47,6 +47,7 @@ import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; import config from "@server/lib/config"; import { sendToClient } from "#private/routers/ws"; import { ActionsEnum } from "@server/auth/actions"; +import type { SignSshKeyResponse } from "@server/routers/ssh/types"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -72,23 +73,6 @@ const bodySchema = z } ); -export type SignSshKeyResponse = { - certificate?: string; - messageIds: number[]; - messageId?: number; - sshUsername: string; - sshHost: string; - resourceId: number; - siteIds: number[]; - siteId: number; - keyId?: string; - validPrincipals?: string[]; - validAfter?: string; - validBefore?: string; - expiresIn?: number; - authDaemonMode: "site" | "remote" | "native" | null; -}; - export async function signSshKey( req: Request, res: Response, diff --git a/server/routers/ssh/types.ts b/server/routers/ssh/types.ts new file mode 100644 index 000000000..ec63da5a9 --- /dev/null +++ b/server/routers/ssh/types.ts @@ -0,0 +1,16 @@ +export type SignSshKeyResponse = { + certificate?: string; + messageIds: number[]; + messageId?: number; + sshUsername: string; + sshHost: string; + resourceId: number; + siteIds: number[]; + siteId: number; + keyId?: string; + validPrincipals?: string[]; + validAfter?: string; + validBefore?: string; + expiresIn?: number; + authDaemonMode: "site" | "remote" | "native" | null; +}; diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 6841f9858..3fb2e82e3 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import type { SignSshKeyResponse } from "@server/private/routers/ssh"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { Card, @@ -18,6 +17,7 @@ import { import Link from "next/link"; import { ExternalLink } from "lucide-react"; import { cn } from "@app/lib/cn"; +import type { SignSshKeyResponse } from "@server/routers/ssh/types"; type AuthTab = "password" | "privateKey"; diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx index 11473c164..c6f6dc16f 100644 --- a/src/app/ssh/page.tsx +++ b/src/app/ssh/page.tsx @@ -3,9 +3,9 @@ import { priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import SshClient from "./SshClient"; -import { SignSshKeyResponse } from "@server/private/routers/ssh"; import crypto from "crypto"; import AuthFooter from "@app/components/AuthFooter"; +import type { SignSshKeyResponse } from "@server/routers/ssh/types"; const pollInitialDelayMs = 250; const pollStartIntervalMs = 250; diff --git a/src/components/ColumnMultiFilterButton.tsx b/src/components/ColumnMultiFilterButton.tsx index 787a306b2..17332a9ae 100644 --- a/src/components/ColumnMultiFilterButton.tsx +++ b/src/components/ColumnMultiFilterButton.tsx @@ -20,6 +20,7 @@ import { CheckIcon, Funnel } from "lucide-react"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; +import { Checkbox } from "./ui/checkbox"; type FilterOption = { value: string; @@ -130,13 +131,11 @@ export function ColumnMultiFilterButton({ toggle(option.value); }} > - {option.label} diff --git a/src/components/LabelColumnFilterButton.tsx b/src/components/LabelColumnFilterButton.tsx index da35707d8..c6b083967 100644 --- a/src/components/LabelColumnFilterButton.tsx +++ b/src/components/LabelColumnFilterButton.tsx @@ -25,6 +25,7 @@ import { useDebounce } from "use-debounce"; import { LabelBadge } from "./label-badge"; import { LabelOverflowBadge } from "./label-overflow-badge"; import { LABEL_COLORS } from "./labels-selector"; +import { Checkbox } from "./ui/checkbox"; function areSelectionsEqual(a: string[], b: string[]) { if (a.length !== b.length) { @@ -179,13 +180,11 @@ export function LabelColumnFilterButton({ }} className="flex items-center gap-2" > -
void; + selectedLabels: SelectedLabel[]; + onToggleLabel: (label: SelectedLabel, action: "attach" | "detach") => void; + onClosePopover: () => void; }; -export function TableLabelsCell({ +export function LabelsTableCell({ orgId, - localLabels, - toggleLabel + selectedLabels, + onToggleLabel, + onClosePopover }: TableLabelsCellProps) { const t = useTranslations(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const triggerRef = useRef(null); const frozenAnchorRef = useRef({ getBoundingClientRect: () => new DOMRect() }); - const hasOverflow = localLabels.length > MAX_VISIBLE_LABELS; - const visibleLabels = localLabels.slice( + const hasOverflow = selectedLabels.length > MAX_VISIBLE_LABELS; + const visibleLabels = selectedLabels.slice( 0, hasOverflow ? MAX_VISIBLE_BEFORE_OVERFLOW : MAX_VISIBLE_LABELS ); const overflowLabels = hasOverflow - ? localLabels.slice(MAX_VISIBLE_BEFORE_OVERFLOW) + ? selectedLabels.slice(MAX_VISIBLE_BEFORE_OVERFLOW) : []; function handleOpenChange(open: boolean) { @@ -54,10 +57,14 @@ export function TableLabelsCell({ }; } setIsPopoverOpen(open); + + if (!open) { + onClosePopover(); + } } return ( -
+
@@ -80,9 +87,8 @@ export function TableLabelsCell({ > handleOpenChange(false)} + selectedLabels={selectedLabels} + toggleLabel={onToggleLabel} /> diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index d7bdba029..eba0c9762 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -34,11 +34,12 @@ import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { type SelectedLabel } from "./labels-selector"; -import { TableLabelsCell } from "./TableLabelsCell"; +import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { useLocalLabels } from "@app/hooks/useLocalLabels"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; export type ClientRow = { id: number; @@ -607,54 +608,19 @@ function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) { - const t = useTranslations(); - const api = createApiClient(useEnvContext()); - const [localLabels, setLocalLabels] = useLocalLabels( - client.labels, - client.id - ); - - function toggleClientLabel( - label: SelectedLabel, - action: "attach" | "detach" - ) { - const previousLabels = localLabels; - - void (async () => { - try { - if (action === "attach") { - setLocalLabels([...previousLabels, label]); - await api.put( - `/org/${orgId}/label/${label.labelId}/attach`, - { clientId: client.id } - ); - } else { - setLocalLabels( - previousLabels.filter( - (lb) => lb.labelId !== label.labelId - ) - ); - await api.put( - `/org/${orgId}/label/${label.labelId}/detach`, - { clientId: client.id } - ); - } - } catch (e) { - setLocalLabels(previousLabels); - toast({ - title: t("error"), - description: formatAxiosError(e, t("errorOccurred")), - variant: "destructive" - }); - } - })(); - } + const { localLabels, refresh, toggleLabel } = useOptimisticLabels({ + serverLabels: client.labels, + orgId, + entityId: client.id, + entityIdField: "clientId" + }); return ( - startTransition(refresh)} /> ); } diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index e1b88f84c..396ba9759 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -61,9 +61,10 @@ import { build } from "@server/build"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { type SelectedLabel } from "./labels-selector"; -import { TableLabelsCell } from "./TableLabelsCell"; +import { LabelsTableCell } from "./LabelsTableCell"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { useLocalLabels } from "@app/hooks/useLocalLabels"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; export type InternalResourceSiteRow = ResourceSiteRow; @@ -705,54 +706,19 @@ function ClientResourceLabelCell({ resource, orgId }: ClientResourceLabelCellProps) { - const t = useTranslations(); - const api = createApiClient(useEnvContext()); - const [localLabels, setLocalLabels] = useLocalLabels( - resource.labels, - resource.id - ); - - function toggleResourceLabel( - label: SelectedLabel, - action: "attach" | "detach" - ) { - const previousLabels = localLabels; - - void (async () => { - try { - if (action === "attach") { - setLocalLabels([...previousLabels, label]); - await api.put( - `/org/${orgId}/label/${label.labelId}/attach`, - { siteResourceId: resource.id } - ); - } else { - setLocalLabels( - previousLabels.filter( - (lb) => lb.labelId !== label.labelId - ) - ); - await api.put( - `/org/${orgId}/label/${label.labelId}/detach`, - { siteResourceId: resource.id } - ); - } - } catch (e) { - setLocalLabels(previousLabels); - toast({ - title: t("error"), - description: formatAxiosError(e, t("errorOccurred")), - variant: "destructive" - }); - } - })(); - } + const { localLabels, refresh, toggleLabel } = useOptimisticLabels({ + serverLabels: resource.labels, + orgId, + entityId: resource.id, + entityIdField: "siteResourceId" + }); return ( - startTransition(refresh)} + onToggleLabel={toggleLabel} + selectedLabels={localLabels} /> ); } diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 80d2e9d25..0b761a540 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -73,7 +73,9 @@ import UptimeMiniBar from "./UptimeMiniBar"; import { type SelectedLabel } from "./labels-selector"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { TableLabelsCell } from "./TableLabelsCell"; +import { LabelsTableCell } from "./LabelsTableCell"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { refresh } from "next/cache"; export type TargetHealth = { targetId: number; @@ -772,57 +774,19 @@ type ResourceLabelCellProps = { }; function ResourceLabelCell({ resource, orgId }: ResourceLabelCellProps) { - const t = useTranslations(); - - const api = createApiClient(useEnvContext()); - - const [localLabels, setLocalLabels] = useLocalLabels( - resource.labels, - resource.id - ); - - function toggleSiteLabel( - label: SelectedLabel, - action: "attach" | "detach" - ) { - const previousLabels = localLabels; - - void (async () => { - try { - if (action === "attach") { - setLocalLabels([...previousLabels, label]); - - await api.put( - `/org/${orgId}/label/${label.labelId}/attach`, - { resourceId: resource.id } - ); - } else { - setLocalLabels( - previousLabels.filter( - (lb) => lb.labelId !== label.labelId - ) - ); - await api.put( - `/org/${orgId}/label/${label.labelId}/detach`, - { resourceId: resource.id } - ); - } - } catch (e) { - setLocalLabels(previousLabels); - toast({ - title: t("error"), - description: formatAxiosError(e, t("errorOccurred")), - variant: "destructive" - }); - } - })(); - } + const { localLabels, refresh, toggleLabel } = useOptimisticLabels({ + serverLabels: resource.labels, + orgId, + entityId: resource.id, + entityIdField: "resourceId" + }); return ( - startTransition(refresh)} /> ); } diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index aac422de9..3e234bf79 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -41,13 +41,7 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { - startTransition, - useEffect, - useMemo, - useState, - useTransition -} from "react"; +import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; @@ -56,13 +50,11 @@ import { type ExtendedColumnDef } from "./ui/controlled-data-table"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { cn } from "@app/lib/cn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { type SelectedLabel } from "./labels-selector"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { TableLabelsCell } from "./TableLabelsCell"; +import { LabelsTableCell } from "./LabelsTableCell"; export type SiteRow = { id: number; @@ -686,54 +678,19 @@ type SiteLabelCellProps = { }; function SiteLabelCell({ site, orgId }: SiteLabelCellProps) { - const t = useTranslations(); - - const api = createApiClient(useEnvContext()); - - const [localLabels, setLocalLabels] = useLocalLabels(site.labels, site.id); - - function toggleSiteLabel( - label: SelectedLabel, - action: "attach" | "detach" - ) { - const previousLabels = localLabels; - - void (async () => { - try { - if (action === "attach") { - setLocalLabels([...previousLabels, label]); - - await api.put( - `/org/${orgId}/label/${label.labelId}/attach`, - { siteId: site.id } - ); - } else { - setLocalLabels( - previousLabels.filter( - (lb) => lb.labelId !== label.labelId - ) - ); - await api.put( - `/org/${orgId}/label/${label.labelId}/detach`, - { siteId: site.id } - ); - } - } catch (e) { - setLocalLabels(previousLabels); - toast({ - title: t("error"), - description: formatAxiosError(e, t("errorOccurred")), - variant: "destructive" - }); - } - })(); - } + const { localLabels, refresh, toggleLabel } = useOptimisticLabels({ + serverLabels: site.labels, + orgId, + entityId: site.id, + entityIdField: "siteId" + }); return ( - startTransition(refresh)} /> ); } diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index eefa87d8a..877fbc1c6 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -36,7 +36,6 @@ export type LabelsSelectorProps = { orgId: string; selectedLabels: SelectedLabel[]; toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void; - onClose?: () => void; }; export const LABEL_COLORS = { @@ -52,8 +51,7 @@ export const LABEL_COLORS = { export function LabelsSelector({ orgId, selectedLabels, - toggleLabel, - onClose + toggleLabel }: LabelsSelectorProps) { const t = useTranslations(); const [labelSearchQuery, setlabelsSearchQuery] = useState(""); @@ -202,7 +200,6 @@ export function LabelsSelector({ ? "detach" : "attach" ); - onClose?.(); }} > ({ onChange(newValues); }} > - {`${option.text}`} diff --git a/src/hooks/useNavigationContext.ts b/src/hooks/useNavigationContext.ts index 71b7c5523..1a8f085a7 100644 --- a/src/hooks/useNavigationContext.ts +++ b/src/hooks/useNavigationContext.ts @@ -1,5 +1,5 @@ import { useSearchParams, usePathname, useRouter } from "next/navigation"; -import { useTransition } from "react"; +import { useCallback, useMemo, useTransition } from "react"; export function useNavigationContext() { const router = useRouter(); @@ -7,29 +7,38 @@ export function useNavigationContext() { const path = usePathname(); const [isNavigating, startTransition] = useTransition(); - function navigate({ - searchParams: params, - pathname = path, - replace = false - }: { - pathname?: string; - searchParams?: URLSearchParams; - replace?: boolean; - }) { - startTransition(() => { - const fullPath = pathname + (params ? `?${params.toString()}` : ""); + const navigate = useCallback( + function ({ + searchParams: params, + pathname = path, + replace = false + }: { + pathname?: string; + searchParams?: URLSearchParams; + replace?: boolean; + }) { + startTransition(() => { + const fullPath = + pathname + (params ? `?${params.toString()}` : ""); - if (replace) { - router.replace(fullPath); - } else { - router.push(fullPath); - } - }); - } + if (replace) { + router.replace(fullPath); + } else { + router.push(fullPath); + } + }); + }, + [router] + ); + + const writableSearchParams = useMemo( + () => new URLSearchParams(searchParams), + [searchParams] + ); return { pathname: path, - searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable + searchParams: writableSearchParams, navigate, isNavigating }; diff --git a/src/hooks/useOptimisticLabels.ts b/src/hooks/useOptimisticLabels.ts new file mode 100644 index 000000000..f5c787474 --- /dev/null +++ b/src/hooks/useOptimisticLabels.ts @@ -0,0 +1,103 @@ +import type { SelectedLabel } from "@app/components/labels-selector"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useState, useMemo } from "react"; +import { toast } from "./useToast"; +import { useEnvContext } from "./useEnvContext"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; + +export type LabelToggleAction = { + label: SelectedLabel; + action: "attach" | "detach"; +}; + +function computeLabelToggleActions( + values: SelectedLabel[], + actions: LabelToggleAction[] +) { + let newValues = [...values]; + for (const { action, label } of actions) { + if (action === "attach") { + newValues = [...newValues, label]; + } else { + newValues = newValues.filter((lb) => lb.labelId !== label.labelId); + } + } + + return newValues; +} + +type UseOptimisticLabelsArgs = { + serverLabels: SelectedLabel[] | undefined; + orgId: string; + entityId: number; + entityIdField: string; +}; + +export function useOptimisticLabels({ + serverLabels, + orgId, + entityId, + entityIdField +}: UseOptimisticLabelsArgs) { + const router = useRouter(); + const labels = serverLabels ?? []; + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + const [pendingActions, setPendingActions] = useState( + [] + ); + + const localLabels = useMemo( + () => computeLabelToggleActions(labels ?? [], pendingActions), + [labels, pendingActions] + ); + + async function toggleLabel( + label: SelectedLabel, + action: "attach" | "detach" + ) { + const oppositeAction = action === "attach" ? "detach" : "attach"; + const existingActionIndex = pendingActions.findIndex( + (pending) => + pending.action === oppositeAction && + pending.label.labelId === label.labelId + ); + + // if there are two actions that cancel each-other + // they should just be removed + if (existingActionIndex !== -1) { + setPendingActions((prevActions) => + prevActions.toSpliced(existingActionIndex, 1) + ); + } else { + setPendingActions((actions) => [...actions, { label, action }]); + } + + try { + if (action === "attach") { + await api.put(`/org/${orgId}/label/${label.labelId}/attach`, { + [entityIdField]: entityId + }); + } else { + await api.put(`/org/${orgId}/label/${label.labelId}/detach`, { + [entityIdField]: entityId + }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e, t("errorOccurred")), + variant: "destructive" + }); + } + } + + async function refresh() { + router.refresh(); + setPendingActions([]); + } + + return { localLabels, toggleLabel, refresh }; +}