mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 06:59:06 +01:00
Merge remote-tracking branch 'upstream/dev' into feature-response-headers
# Conflicts: # server/db/pg/schema/schema.ts # server/lib/blueprints/proxyResources.ts # server/routers/resource/getResource.ts # src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx # src/components/HealthCheckCredenza.tsx
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { build } from "@server/build";
|
||||
import { GetLicenseStatusResponse } from "@server/routers/license/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { cache } from "react";
|
||||
|
||||
export default async function AuthFooter() {
|
||||
const env = pullEnv();
|
||||
const t = await getTranslations();
|
||||
|
||||
let hideFooter = false;
|
||||
let licenseStatus: GetLicenseStatusResponse | null = null;
|
||||
|
||||
if (build === "enterprise") {
|
||||
const licenseStatusRes = await cache(
|
||||
async () =>
|
||||
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
|
||||
"/license/status"
|
||||
)
|
||||
)();
|
||||
licenseStatus = licenseStatusRes.data.data;
|
||||
if (
|
||||
env.branding.hideAuthLayoutFooter &&
|
||||
licenseStatusRes.data.data.isHostLicensed &&
|
||||
licenseStatusRes.data.data.isLicenseValid &&
|
||||
licenseStatusRes.data.data.tier !== "personal"
|
||||
) {
|
||||
hideFooter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hideFooter) return null;
|
||||
|
||||
return (
|
||||
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
|
||||
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-xs text-neutral-400 dark:text-neutral-600">
|
||||
<a
|
||||
href="https://pangolin.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Built by Fossorial"
|
||||
className="flex items-center space-x-2 whitespace-nowrap"
|
||||
>
|
||||
<span>© {new Date().getFullYear()} Fossorial, Inc.</span>
|
||||
</a>
|
||||
{build !== "saas" && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://pangolin.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Built by Fossorial"
|
||||
className="flex items-center space-x-2 whitespace-nowrap"
|
||||
>
|
||||
<span>
|
||||
{process.env.BRANDING_APP_NAME || "Pangolin"}
|
||||
</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<Separator orientation="vertical" />
|
||||
<span>
|
||||
{build === "oss"
|
||||
? t("communityEdition")
|
||||
: build === "enterprise"
|
||||
? t("enterpriseEdition")
|
||||
: t("pangolinCloud")}
|
||||
</span>
|
||||
{build === "enterprise" &&
|
||||
licenseStatus?.isHostLicensed &&
|
||||
licenseStatus?.isLicenseValid &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<span>{t("personalUseOnly")}</span>
|
||||
</>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
(!licenseStatus?.isHostLicensed ||
|
||||
!licenseStatus?.isLicenseValid) ? (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<span>{t("unlicensed")}</span>
|
||||
</>
|
||||
) : null}
|
||||
{build === "saas" && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://pangolin.net/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub"
|
||||
className="flex items-center space-x-2 whitespace-nowrap"
|
||||
>
|
||||
<span>{t("termsOfService")}</span>
|
||||
</a>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://pangolin.net/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub"
|
||||
className="flex items-center space-x-2 whitespace-nowrap"
|
||||
>
|
||||
<span>{t("privacyPolicy")}</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -28,15 +28,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export type AuthPageCustomizationProps = {
|
||||
orgId: string;
|
||||
@@ -92,7 +91,7 @@ export default function AuthPageBrandingForm({
|
||||
orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`,
|
||||
resourceTitle:
|
||||
branding?.resourceTitle ??
|
||||
`Authenticate to access {{resourceName}}`,
|
||||
`Authenticate to Access {{resourceName}}`,
|
||||
resourceSubtitle:
|
||||
branding?.resourceSubtitle ??
|
||||
`Choose your preferred authentication method for {{resourceName}}`,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export default function AuthPageFooterNotices() {
|
||||
const t = useTranslations();
|
||||
// const { supporterStatus } = useSupporterStatusContext();
|
||||
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* {supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("instanceIsUnlicensed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("loginPageLicenseWatermark")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
type BrandedAuthSurfaceProps = {
|
||||
primaryColor?: string | null;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function BrandedAuthSurface({
|
||||
primaryColor,
|
||||
children
|
||||
}: BrandedAuthSurfaceProps) {
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// @ts-expect-error CSS variable
|
||||
"--primary": isUnlocked() ? primaryColor : null
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "./multi-site-selector";
|
||||
import { SitesSelector, type Selectedsite } from "./site-selector";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
type BaseProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
orgId: string;
|
||||
destinationField: Path<T>;
|
||||
destinationPortField: Path<T>;
|
||||
learnMoreHref?: string;
|
||||
defaultPort: number;
|
||||
};
|
||||
|
||||
type MultiSiteFormProps<T extends FieldValues> = BaseProps<T> & {
|
||||
multiSite: true;
|
||||
sitesField: Path<T>;
|
||||
};
|
||||
|
||||
type SingleSiteFormProps<T extends FieldValues> = BaseProps<T> & {
|
||||
multiSite?: false;
|
||||
siteField: Path<T>;
|
||||
};
|
||||
|
||||
export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
|
||||
| MultiSiteFormProps<T>
|
||||
| SingleSiteFormProps<T>;
|
||||
|
||||
export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||
props: BrowserGatewayTargetFormProps<T>
|
||||
) {
|
||||
// IDK MAN REMOVING THIS SEEMS TO CAUSE ISSUES
|
||||
// Opt out of the React Compiler for this component.
|
||||
//
|
||||
// The parent (create page) shares a single `bgTargetForm` instance across
|
||||
// multiple conditionally-rendered Form sections (SSH passthrough/push, RDP,
|
||||
// VNC) and calls `bgTargetForm.reset(...)` in a useEffect when the
|
||||
// resource type changes. react-hook-form's Controller uses an external
|
||||
// subscription that the React Compiler cannot statically reason about, so
|
||||
// with `reactCompiler: true` (see next.config.ts) the Compiler can memoize
|
||||
// the render prop and skip re-rendering the <Input> elements when their
|
||||
// bound form values change. The visible symptom is that typing into the
|
||||
// destination/port inputs updates form state but the input itself never
|
||||
// visually updates. The escape hatch is the canonical fix here.
|
||||
"use no memo";
|
||||
const t = useTranslations();
|
||||
const [siteOpen, setSiteOpen] = useState(false);
|
||||
|
||||
const sitesFieldName =
|
||||
props.multiSite === true ? props.sitesField : props.siteField;
|
||||
|
||||
// Subscribe to field values via useWatch and drive the controlled <Input>
|
||||
// elements from these values rather than from the `field.value` returned
|
||||
// by the Controller render prop. Combined with the "use no memo" directive
|
||||
// above, this makes the inputs reliably re-render when their bound form
|
||||
// values change.
|
||||
const watchedSites = useWatch({
|
||||
control: props.control,
|
||||
name: sitesFieldName
|
||||
});
|
||||
|
||||
const watchedDestination = useWatch({
|
||||
control: props.control,
|
||||
name: props.destinationField
|
||||
});
|
||||
|
||||
const watchedDestinationPort = useWatch({
|
||||
control: props.control,
|
||||
name: props.destinationPortField
|
||||
});
|
||||
|
||||
const showMultiSiteDisclaimer =
|
||||
props.multiSite === true &&
|
||||
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-4 items-start">
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={sitesFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
props.multiSite === true
|
||||
? (
|
||||
field.value as Selectedsite[]
|
||||
)?.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
: !field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{props.multiSite === true
|
||||
? formatMultiSitesSelectorLabel(
|
||||
(field.value as Selectedsite[]) ??
|
||||
[],
|
||||
t
|
||||
)
|
||||
: ((
|
||||
field.value as Selectedsite | null
|
||||
)?.name ??
|
||||
t("siteSelect"))}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
{props.multiSite === true ? (
|
||||
<MultiSitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSites={
|
||||
(field.value as Selectedsite[]) ??
|
||||
[]
|
||||
}
|
||||
onSelectionChange={field.onChange}
|
||||
filterTypes={["newt"]}
|
||||
/>
|
||||
) : (
|
||||
<SitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSite={
|
||||
field.value as Selectedsite | null
|
||||
}
|
||||
onSelectSite={(site) => {
|
||||
field.onChange(site);
|
||||
setSiteOpen(false);
|
||||
}}
|
||||
filterTypes={["newt"]}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={props.destinationField}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("destination")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
value={
|
||||
(watchedDestination as
|
||||
| string
|
||||
| undefined) ?? ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={props.destinationPortField}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("port")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
value={
|
||||
(watchedDestinationPort as
|
||||
| string
|
||||
| number
|
||||
| undefined) ?? ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{showMultiSiteDisclaimer && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("bgTargetMultiSiteDisclaimer")}{" "}
|
||||
<a
|
||||
href={
|
||||
props.learnMoreHref ??
|
||||
"https://docs.pangolin.net/manage/resources/private/multi-site-routing"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function CollapsibleSessionToolbar({
|
||||
children,
|
||||
defaultOpen = false
|
||||
}: {
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto absolute inset-x-0 top-0 isolate transition-transform duration-200 ease-out",
|
||||
open ? "translate-y-0" : "-translate-y-full"
|
||||
)}
|
||||
>
|
||||
<div className="relative z-20 flex flex-wrap items-center gap-2 bg-background p-2">
|
||||
{children}
|
||||
</div>
|
||||
{/* Secondary toggle backdrop kept as a distinct style under
|
||||
the main handle button. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={
|
||||
open ? t("sessionToolbarHide") : t("sessionToolbarShow")
|
||||
}
|
||||
aria-expanded={open}
|
||||
className="absolute left-1/2 top-full -z-20 h-4 w-72 -translate-x-1/2 -translate-y-2 rounded-md bg-neutral-200 transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-primary dark:bg-neutral-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={
|
||||
open ? t("sessionToolbarHide") : t("sessionToolbarShow")
|
||||
}
|
||||
aria-expanded={open}
|
||||
className="absolute left-1/2 top-full -z-10 flex h-5 w-6 -translate-x-1/2 items-center justify-center rounded-b-sm bg-primary text-primary-foreground transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
@@ -27,7 +28,6 @@ interface ColumnFilterButtonProps {
|
||||
options: FilterOption[];
|
||||
selectedValue?: string;
|
||||
onValueChange: (value: string | undefined) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
@@ -38,7 +38,6 @@ export function ColumnFilterButton({
|
||||
options,
|
||||
selectedValue,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No options found",
|
||||
className,
|
||||
@@ -50,6 +49,8 @@ export function ColumnFilterButton({
|
||||
(option) => option.value === selectedValue
|
||||
);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -94,7 +95,7 @@ export function ColumnFilterButton({
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Clear filter
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
@@ -109,6 +110,7 @@ export function ColumnFilterButton({
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="break-all"
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
|
||||
@@ -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;
|
||||
@@ -119,7 +120,7 @@ export function ColumnMultiFilterButton({
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessUsersRoleFilterClear")}
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
@@ -129,14 +130,13 @@ export function ColumnMultiFilterButton({
|
||||
onSelect={() => {
|
||||
toggle(option.value);
|
||||
}}
|
||||
className="break-all"
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedSet.has(option.value)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={selectedSet.has(option.value)}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
|
||||
@@ -130,7 +130,7 @@ const DockerContainersTable: FC<{
|
||||
useState(true);
|
||||
const [hideStoppedContainers, setHideStoppedContainers] = useState(false);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
labels: false
|
||||
labels: true
|
||||
});
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
@@ -7,12 +8,14 @@ type CopyToClipboardProps = {
|
||||
text: string;
|
||||
displayText?: string;
|
||||
isLink?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CopyToClipboard = ({
|
||||
text,
|
||||
displayText,
|
||||
isLink
|
||||
isLink,
|
||||
className
|
||||
}: CopyToClipboardProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -48,14 +51,20 @@ const CopyToClipboard = ({
|
||||
href={text}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate hover:underline text-sm min-w-0 max-w-full"
|
||||
className={cn(
|
||||
"truncate hover:underline text-sm min-w-0 max-w-full",
|
||||
className
|
||||
)}
|
||||
title={text} // Shows full text on hover
|
||||
>
|
||||
{displayValue}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="truncate text-sm min-w-0 max-w-full"
|
||||
className={cn(
|
||||
"truncate text-sm min-w-0 max-w-full",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
CredenzaTitle
|
||||
} from "./Credenza";
|
||||
import { OrgLabelForm } from "./OrgLabelForm";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export type CreateOrgLabelDialogProps = {
|
||||
@@ -35,6 +38,8 @@ export function CreateOrgLabelDialog({
|
||||
}: CreateOrgLabelDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const canManageLabels = isPaidUser(tierMatrix.labels);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
async function createOrgLabel(data: { name: string; color: string }) {
|
||||
@@ -52,12 +57,20 @@ export function CreateOrgLabelDialog({
|
||||
description: t("labelCreateSuccessMessage")
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred")),
|
||||
variant: "destructive"
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
title: t("labelDuplicateError"),
|
||||
description: t("labelDuplicateErrorDescription"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred")),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +84,11 @@ export function CreateOrgLabelDialog({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||
<OrgLabelForm
|
||||
disabled={!canManageLabels}
|
||||
onSubmit={(data) => {
|
||||
if (!canManageLabels) return;
|
||||
startTransition(async () => createOrgLabel(data));
|
||||
}}
|
||||
/>
|
||||
@@ -90,7 +106,7 @@ export function CreateOrgLabelDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="org-label-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !canManageLabels}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("labelCreate")}
|
||||
|
||||
+36
-10
@@ -19,23 +19,26 @@ import { useTranslations } from "next-intl";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
cleanForFQDN,
|
||||
InternalResourceForm,
|
||||
PrivateResourceForm,
|
||||
isHostname,
|
||||
type InternalResourceFormValues
|
||||
} from "./InternalResourceForm";
|
||||
} from "./PrivateResourceForm";
|
||||
import type { Selectedsite } from "./site-selector";
|
||||
|
||||
type CreateInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
orgId: string;
|
||||
onSuccess?: () => void;
|
||||
initialSites?: Selectedsite[];
|
||||
};
|
||||
|
||||
export default function CreateInternalResourceDialog({
|
||||
export default function CreatePrivateResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
onSuccess
|
||||
onSuccess,
|
||||
initialSites
|
||||
}: CreateInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
@@ -47,13 +50,15 @@ export default function CreateInternalResourceDialog({
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" || data.mode === "http") &&
|
||||
(data.mode === "host" ||
|
||||
data.mode === "http" ||
|
||||
data.mode === "ssh") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination.toLowerCase() === "localhost") {
|
||||
if (data.destination?.toLowerCase() === "localhost") {
|
||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||
}
|
||||
data = { ...data, alias: aliasValue };
|
||||
@@ -66,12 +71,12 @@ export default function CreateInternalResourceDialog({
|
||||
name: data.name,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
destination: data.destination,
|
||||
destination: data.destination ?? undefined,
|
||||
enabled: true,
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.httpHttpsPort ?? undefined,
|
||||
destinationPort: data.destinationPort ?? undefined,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
@@ -94,11 +99,31 @@ export default function CreateInternalResourceDialog({
|
||||
authDaemonPort: data.authDaemonPort
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode == "cidr") && {
|
||||
...(data.mode === "ssh" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
destinationPort: data.destinationPort ?? undefined,
|
||||
pamMode: data.pamMode ?? undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" &&
|
||||
data.authDaemonPort != null && {
|
||||
authDaemonPort: data.authDaemonPort
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: data.roles
|
||||
? data.roles.map((r) => parseInt(r.id))
|
||||
: [],
|
||||
@@ -146,13 +171,14 @@ export default function CreateInternalResourceDialog({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<InternalResourceForm
|
||||
<PrivateResourceForm
|
||||
variant="create"
|
||||
open={open}
|
||||
orgId={orgId}
|
||||
formId="create-internal-resource-form"
|
||||
onSubmit={handleSubmit}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
initialSites={initialSites}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
@@ -16,14 +16,16 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type {
|
||||
CreateRoleBody,
|
||||
CreateRoleResponse
|
||||
} from "@server/routers/role";
|
||||
import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTransition } from "react";
|
||||
import { RoleForm, type RoleFormValues } from "./RoleForm";
|
||||
import {
|
||||
parseSudoCommands,
|
||||
parseUnixGroups,
|
||||
RoleForm,
|
||||
type RoleFormValues
|
||||
} from "./RoleForm";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
@@ -50,29 +52,22 @@ export default function CreateRoleForm({
|
||||
requireDeviceApproval: values.requireDeviceApproval,
|
||||
allowSsh: values.allowSsh
|
||||
};
|
||||
if (isPaidUser(tierMatrix.sshPam)) {
|
||||
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
|
||||
payload.sshSudoMode = values.sshSudoMode;
|
||||
payload.sshCreateHomeDir = values.sshCreateHomeDir;
|
||||
payload.sshSudoCommands =
|
||||
values.sshSudoMode === "commands" &&
|
||||
values.sshSudoCommands?.trim()
|
||||
? values.sshSudoCommands
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
? parseSudoCommands(values.sshSudoCommands)
|
||||
: [];
|
||||
if (values.sshUnixGroups?.trim()) {
|
||||
payload.sshUnixGroups = values.sshUnixGroups
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
|
||||
}
|
||||
}
|
||||
const res = await api
|
||||
.put<AxiosResponse<CreateRoleResponse>>(
|
||||
`/org/${org?.org.orgId}/role`,
|
||||
payload
|
||||
)
|
||||
.put<
|
||||
AxiosResponse<CreateRoleResponse>
|
||||
>(`/org/${org?.org.orgId}/role`, payload)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -112,6 +113,7 @@ export default function CreateShareLinkForm({
|
||||
resourceId: z.number({ message: t("shareErrorSelectResource") }),
|
||||
resourceName: z.string(),
|
||||
resourceUrl: z.string(),
|
||||
path: z.string().optional(),
|
||||
timeUnit: z.string(),
|
||||
timeValue: z.coerce.number<number>().int().positive().min(1),
|
||||
title: z.string().optional()
|
||||
@@ -172,7 +174,8 @@ export default function CreateShareLinkForm({
|
||||
resource:
|
||||
values.resourceName ||
|
||||
"Resource" + values.resourceId
|
||||
})
|
||||
}),
|
||||
path: values.path
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -267,11 +270,11 @@ export default function CreateShareLinkForm({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<ResourceSelector
|
||||
excludeWildcard
|
||||
orgId={
|
||||
org.org
|
||||
.orgId
|
||||
}
|
||||
excludeWildcard
|
||||
orgId={
|
||||
org.org
|
||||
.orgId
|
||||
}
|
||||
selectedResource={
|
||||
selectedResource
|
||||
}
|
||||
@@ -320,6 +323,27 @@ export default function CreateShareLinkForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sharePathOptional")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"sharePathDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
|
||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
||||
return (
|
||||
<CredenzaContent
|
||||
className={cn(
|
||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100dvh-clamp(1.5rem,12vh,200px)-1.5rem)] md:translate-y-0 md:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
CredenzaTitle
|
||||
} from "./Credenza";
|
||||
import { OrgLabelForm } from "./OrgLabelForm";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export type EditOrgLabelDialogProps = {
|
||||
@@ -41,6 +44,8 @@ export function EditOrgLabelDialog({
|
||||
}: EditOrgLabelDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const canManageLabels = isPaidUser(tierMatrix.labels);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
async function editOrgLabel(data: { name: string; color: string }) {
|
||||
@@ -58,12 +63,20 @@ export function EditOrgLabelDialog({
|
||||
description: t("labelEditSuccessMessage")
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred")),
|
||||
variant: "destructive"
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
title: t("labelDuplicateError"),
|
||||
description: t("labelDuplicateErrorDescription"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred")),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +90,12 @@ export function EditOrgLabelDialog({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||
<OrgLabelForm
|
||||
disabled={!canManageLabels}
|
||||
defaultValue={label}
|
||||
onSubmit={(data) => {
|
||||
if (!canManageLabels) return;
|
||||
startTransition(async () => editOrgLabel(data));
|
||||
}}
|
||||
/>
|
||||
@@ -97,7 +113,7 @@ export function EditOrgLabelDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="org-label-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !canManageLabels}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("labelEdit")}
|
||||
|
||||
+29
-8
@@ -20,11 +20,11 @@ import { useTranslations } from "next-intl";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
cleanForFQDN,
|
||||
InternalResourceForm,
|
||||
PrivateResourceForm,
|
||||
type InternalResourceData,
|
||||
type InternalResourceFormValues,
|
||||
isHostname
|
||||
} from "./InternalResourceForm";
|
||||
} from "./PrivateResourceForm";
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
@@ -34,7 +34,7 @@ type EditInternalResourceDialogProps = {
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function EditInternalResourceDialog({
|
||||
export default function EditPrivateResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
resource,
|
||||
@@ -51,13 +51,15 @@ export default function EditInternalResourceDialog({
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" || data.mode === "http") &&
|
||||
(data.mode === "host" ||
|
||||
data.mode === "http" ||
|
||||
data.mode === "ssh") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination.toLowerCase() === "localhost") {
|
||||
if (data.destination?.toLowerCase() === "localhost") {
|
||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||
}
|
||||
data = { ...data, alias: aliasValue };
|
||||
@@ -69,11 +71,11 @@ export default function EditInternalResourceDialog({
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
niceId: data.niceId,
|
||||
destination: data.destination,
|
||||
destination: data.destination ?? undefined,
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.httpHttpsPort ?? null,
|
||||
destinationPort: data.destinationPort ?? null,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
@@ -95,11 +97,30 @@ export default function EditInternalResourceDialog({
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
})
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
destinationPort: data.destinationPort ?? null,
|
||||
pamMode: data.pamMode ?? undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" && {
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
...(data.mode === "ssh" && {
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||
@@ -166,7 +187,7 @@ export default function EditInternalResourceDialog({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<InternalResourceForm
|
||||
<PrivateResourceForm
|
||||
variant="edit"
|
||||
open={open}
|
||||
resource={resource}
|
||||
@@ -16,14 +16,16 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type { Role } from "@server/db";
|
||||
import type {
|
||||
UpdateRoleBody,
|
||||
UpdateRoleResponse
|
||||
} from "@server/routers/role";
|
||||
import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTransition } from "react";
|
||||
import { RoleForm, type RoleFormValues } from "./RoleForm";
|
||||
import {
|
||||
parseSudoCommands,
|
||||
parseUnixGroups,
|
||||
RoleForm,
|
||||
type RoleFormValues
|
||||
} from "./RoleForm";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type EditRoleFormProps = {
|
||||
@@ -53,29 +55,22 @@ export default function EditRoleForm({
|
||||
payload.name = values.name;
|
||||
payload.description = values.description || undefined;
|
||||
}
|
||||
if (isPaidUser(tierMatrix.sshPam)) {
|
||||
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
|
||||
payload.sshSudoMode = values.sshSudoMode;
|
||||
payload.sshCreateHomeDir = values.sshCreateHomeDir;
|
||||
payload.sshSudoCommands =
|
||||
values.sshSudoMode === "commands" &&
|
||||
values.sshSudoCommands?.trim()
|
||||
? values.sshSudoCommands
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
? parseSudoCommands(values.sshSudoCommands)
|
||||
: [];
|
||||
if (values.sshUnixGroups !== undefined) {
|
||||
payload.sshUnixGroups = values.sshUnixGroups
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
payload.sshUnixGroups = parseUnixGroups(values.sshUnixGroups);
|
||||
}
|
||||
}
|
||||
const res = await api
|
||||
.post<AxiosResponse<UpdateRoleResponse>>(
|
||||
`/role/${role.roleId}`,
|
||||
payload
|
||||
)
|
||||
.post<
|
||||
AxiosResponse<UpdateRoleResponse>
|
||||
>(`/role/${role.roleId}`, payload)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
|
||||
@@ -422,7 +422,7 @@ export default function GenerateLicenseKeyForm({
|
||||
{part}
|
||||
{index === 0 && (
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
href="https://pangolin.net/fcl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -601,7 +601,7 @@ export default function GenerateLicenseKeyForm({
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
href="https://pangolin.net/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -614,7 +614,7 @@ export default function GenerateLicenseKeyForm({
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
href="https://pangolin.net/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -658,12 +658,12 @@ export default function GenerateLicenseKeyForm({
|
||||
license
|
||||
details:{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
href="https://pangolin.net/fcl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
https://pangolin.net/fcl
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
@@ -987,7 +987,7 @@ export default function GenerateLicenseKeyForm({
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
href="https://pangolin.net/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -1000,7 +1000,7 @@ export default function GenerateLicenseKeyForm({
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
href="https://pangolin.net/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -1044,12 +1044,12 @@ export default function GenerateLicenseKeyForm({
|
||||
license
|
||||
details:{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
href="https://pangolin.net/fcl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
https://pangolin.net/fcl
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
|
||||
@@ -2,24 +2,36 @@
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface HeadersInputProps {
|
||||
value?: { name: string; value: string }[] | null;
|
||||
onChange: (value: { name: string; value: string }[] | null) => void;
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Mirrors the server side validation in updateResource.ts so that invalid
|
||||
// input is caught (and shown to the user) before it is ever submitted,
|
||||
// instead of being silently dropped in favor of the last known good value.
|
||||
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
|
||||
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
|
||||
const templatePattern = /\{\{[^}]+\}\}/;
|
||||
|
||||
export function HeadersInput({
|
||||
value = [],
|
||||
onChange,
|
||||
onValidityChange,
|
||||
placeholder = `X-Example-Header: example-value
|
||||
X-Another-Header: another-value`,
|
||||
rows = 4,
|
||||
className
|
||||
}: HeadersInputProps) {
|
||||
const t = useTranslations();
|
||||
const [internalValue, setInternalValue] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isUserEditingRef = useRef(false);
|
||||
|
||||
@@ -34,37 +46,56 @@ X-Another-Header: another-value`,
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
// Convert newline-separated string to header objects array
|
||||
const convertToHeadersArray = (
|
||||
// Parse newline-separated text into header objects, validating each line
|
||||
// against the same rules enforced by the server. Returns either the
|
||||
// parsed headers or an error message describing the first invalid line.
|
||||
const parseHeaders = (
|
||||
newlineSeparated: string
|
||||
): { name: string; value: string }[] | null => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
|
||||
):
|
||||
| { headers: { name: string; value: string }[]; error: null }
|
||||
| { headers: null; error: string } => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") {
|
||||
return { headers: [], error: null };
|
||||
}
|
||||
|
||||
return newlineSeparated
|
||||
const lines = newlineSeparated
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && line.includes(":"))
|
||||
.map((line) => {
|
||||
const colonIndex = line.indexOf(":");
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
// Ensure header name conforms to HTTP header requirements
|
||||
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
|
||||
const normalizedName = name
|
||||
.replace(/[^a-zA-Z0-9\-]/g, "")
|
||||
.toLowerCase();
|
||||
const headers: { name: string; value: string }[] = [];
|
||||
|
||||
return { name: normalizedName, value };
|
||||
})
|
||||
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
|
||||
for (const line of lines) {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return { headers: null, error: t("headersValidationError") };
|
||||
}
|
||||
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
if (
|
||||
!validHeaderNamePattern.test(name) ||
|
||||
!validHeaderValuePattern.test(value) ||
|
||||
templatePattern.test(name) ||
|
||||
templatePattern.test(value)
|
||||
) {
|
||||
return { headers: null, error: t("headersValidationError") };
|
||||
}
|
||||
|
||||
headers.push({ name, value });
|
||||
}
|
||||
|
||||
return { headers, error: null };
|
||||
};
|
||||
|
||||
// Update internal value when external value changes
|
||||
// But only if the user is not currently editing (textarea not focused)
|
||||
useEffect(() => {
|
||||
if (!isUserEditingRef.current) {
|
||||
setInternalValue(convertToNewlineSeparated(value));
|
||||
setInternalValue(convertToNewlineSeparated(value ?? []));
|
||||
setError(null);
|
||||
onValidityChange?.(true);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
@@ -75,31 +106,20 @@ X-Another-Header: another-value`,
|
||||
// Mark that user is actively editing
|
||||
isUserEditingRef.current = true;
|
||||
|
||||
// Only update parent if the input is in a valid state
|
||||
// Valid states: empty/whitespace only, or contains properly formatted headers
|
||||
const result = parseHeaders(newValue);
|
||||
|
||||
if (newValue.trim() === "") {
|
||||
// Empty input is valid - represents no headers
|
||||
onChange([]);
|
||||
} else {
|
||||
// Check if all non-empty lines are properly formatted (contain ':')
|
||||
const lines = newValue.split("\n");
|
||||
const nonEmptyLines = lines
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
|
||||
const isValid =
|
||||
nonEmptyLines.length === 0 ||
|
||||
nonEmptyLines.every((line) => line.includes(":"));
|
||||
|
||||
if (isValid) {
|
||||
// Safe to convert and update parent
|
||||
const headersArray = convertToHeadersArray(newValue);
|
||||
onChange(headersArray);
|
||||
}
|
||||
// If not valid, don't call onChange - let user continue typing
|
||||
if (result.error) {
|
||||
// Surface the error and do not touch the last known good value.
|
||||
// Silently dropping the update here (without telling the user)
|
||||
// is what previously let stale data get saved without warning.
|
||||
setError(result.error);
|
||||
onValidityChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
onValidityChange?.(true);
|
||||
onChange(result.headers);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -114,15 +134,20 @@ X-Another-Header: another-value`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
<div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-1.5">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -406,7 +406,7 @@ export default function HealthChecksTable({
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
|
||||
href={`/${orgId}/settings/resources/public/${r.resourceNiceId}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{r.resourceName}
|
||||
@@ -627,7 +627,7 @@ export default function HealthChecksTable({
|
||||
</DropdownMenu>
|
||||
{r.resourceId && r.resourceName && r.resourceNiceId ? (
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
|
||||
href={`/${orgId}/settings/resources/public/${r.resourceNiceId}`}
|
||||
>
|
||||
<Button variant="outline" disabled={!isPaid}>
|
||||
{t("edit")}
|
||||
|
||||
@@ -5,12 +5,15 @@ import { cn } from "@app/lib/cn";
|
||||
export function InfoSections({
|
||||
children,
|
||||
cols,
|
||||
columnSizing = "content"
|
||||
columnSizing = "content",
|
||||
layout = "default"
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols?: number;
|
||||
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
||||
columnSizing?: "fill" | "content";
|
||||
/** panel: 2 columns until xl for narrow containers such as side panels */
|
||||
layout?: "default" | "panel";
|
||||
}) {
|
||||
const n = cols || 1;
|
||||
const track =
|
||||
@@ -19,9 +22,14 @@ export function InfoSections({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
|
||||
"grid w-full min-w-0 gap-4",
|
||||
layout === "panel"
|
||||
? "grid-cols-2 xl:grid-cols-(--columns) xl:items-start xl:space-x-16"
|
||||
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
|
||||
columnSizing === "content" &&
|
||||
"md:justify-items-start md:justify-start"
|
||||
(layout === "panel"
|
||||
? "xl:justify-items-start xl:justify-start"
|
||||
: "md:justify-items-start md:justify-start")
|
||||
)}
|
||||
style={{
|
||||
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -16,14 +8,14 @@ import {
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { CheckIcon, Funnel } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Funnel } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LabelBadge } from "./label-badge";
|
||||
import { LabelOverflowBadge } from "./label-overflow-badge";
|
||||
import { LabelsFilterSelector } from "./LabelsFilterSelector";
|
||||
import { LABEL_COLORS } from "./labels-selector";
|
||||
|
||||
function areSelectionsEqual(a: string[], b: string[]) {
|
||||
@@ -53,13 +45,9 @@ export function LabelColumnFilterButton({
|
||||
const [draftValues, setDraftValues] = useState<string[]>(selectedValues);
|
||||
const t = useTranslations();
|
||||
|
||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
|
||||
|
||||
const { data: labels = [] } = useQuery(
|
||||
orgQueries.labels({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 500
|
||||
})
|
||||
);
|
||||
@@ -151,55 +139,17 @@ export function LabelColumnFilterButton({
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("labelSearch")}
|
||||
value={labelSearchQuery}
|
||||
onValueChange={setlabelsSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{draftValues.length > 0 && (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setDraftValues([]);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessLabelFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{labels.map((label) => (
|
||||
<CommandItem
|
||||
key={label.name}
|
||||
value={label.name}
|
||||
onSelect={() => {
|
||||
toggle(label.name);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
draftSet.has(label.name)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className="size-2 rounded-full bg-(--color) flex-none"
|
||||
style={{
|
||||
// @ts-expect-error css color
|
||||
"--color": label.color
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<LabelsFilterSelector
|
||||
orgId={orgId}
|
||||
isSelected={(label) => draftSet.has(label.name)}
|
||||
onToggle={(label) => {
|
||||
toggle(label.name);
|
||||
}}
|
||||
showClear={draftValues.length > 0}
|
||||
onClear={() => {
|
||||
setDraftValues([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
||||
export type LabelFilterOption = {
|
||||
labelId: number;
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type LabelsFilterSelectorProps = {
|
||||
orgId: string;
|
||||
isSelected: (label: LabelFilterOption) => boolean;
|
||||
onToggle: (label: LabelFilterOption) => void;
|
||||
selectedLabels?: LabelFilterOption[];
|
||||
onClear?: () => void;
|
||||
showClear?: boolean;
|
||||
scope?: "org" | "launcher";
|
||||
};
|
||||
|
||||
export function LabelsFilterSelector({
|
||||
orgId,
|
||||
isSelected,
|
||||
onToggle,
|
||||
selectedLabels = [],
|
||||
onClear,
|
||||
showClear = false,
|
||||
scope = "org"
|
||||
}: LabelsFilterSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
|
||||
|
||||
const orgLabelsQuery = useQuery({
|
||||
...orgQueries.labels({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 500
|
||||
}),
|
||||
enabled: scope === "org"
|
||||
});
|
||||
const launcherLabelsQuery = useQuery({
|
||||
...launcherQueries.labels({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 20
|
||||
}),
|
||||
enabled: scope === "launcher"
|
||||
});
|
||||
const labels =
|
||||
scope === "launcher"
|
||||
? (launcherLabelsQuery.data ?? [])
|
||||
: (orgLabelsQuery.data ?? []);
|
||||
|
||||
const labelsShown = useMemo(() => {
|
||||
const base = [...labels];
|
||||
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
|
||||
const selectedNotInBase = selectedLabels.filter(
|
||||
(selected) =>
|
||||
!base.some((label) => label.labelId === selected.labelId)
|
||||
);
|
||||
return [...selectedNotInBase, ...base];
|
||||
}
|
||||
return base;
|
||||
}, [debouncedQuery, labels, selectedLabels]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("labelSearch")}
|
||||
value={labelSearchQuery}
|
||||
onValueChange={setlabelsSearchQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showClear && onClear && (
|
||||
<CommandItem
|
||||
onSelect={onClear}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{labelsShown.map((label) => (
|
||||
<CommandItem
|
||||
key={label.labelId}
|
||||
value={label.name}
|
||||
onSelect={() => {
|
||||
onToggle(label);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={isSelected(label)}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
className="size-2 rounded-full bg-(--color) flex-none"
|
||||
style={{
|
||||
// @ts-expect-error css color
|
||||
"--color": label.color
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -21,29 +21,32 @@ const MAX_VISIBLE_BEFORE_OVERFLOW = MAX_VISIBLE_LABELS - 1;
|
||||
|
||||
type TableLabelsCellProps = {
|
||||
orgId: string;
|
||||
localLabels: SelectedLabel[];
|
||||
toggleLabel: (label: SelectedLabel, action: "attach" | "detach") => 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<HTMLButtonElement>(null);
|
||||
const frozenAnchorRef = useRef<Measurable>({
|
||||
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 (
|
||||
<div className="grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover open={isPopoverOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverAnchor virtualRef={frozenAnchorRef} />
|
||||
<PopoverTrigger asChild>
|
||||
@@ -80,9 +87,8 @@ export function TableLabelsCell({
|
||||
>
|
||||
<LabelsSelector
|
||||
orgId={orgId}
|
||||
selectedLabels={localLabels}
|
||||
toggleLabel={toggleLabel}
|
||||
onClose={() => handleOpenChange(false)}
|
||||
selectedLabels={selectedLabels}
|
||||
toggleLabel={onToggleLabel}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -16,6 +16,8 @@ interface LayoutProps {
|
||||
showHeader?: boolean;
|
||||
showTopBar?: boolean;
|
||||
defaultSidebarCollapsed?: boolean;
|
||||
launcherMode?: boolean;
|
||||
showViewAsAdmin?: boolean;
|
||||
}
|
||||
|
||||
export async function Layout({
|
||||
@@ -26,7 +28,9 @@ export async function Layout({
|
||||
showSidebar = true,
|
||||
showHeader = true,
|
||||
showTopBar = true,
|
||||
defaultSidebarCollapsed = false
|
||||
defaultSidebarCollapsed = false,
|
||||
launcherMode = false,
|
||||
showViewAsAdmin = false
|
||||
}: LayoutProps) {
|
||||
const allCookies = await cookies();
|
||||
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
||||
@@ -64,11 +68,21 @@ export async function Layout({
|
||||
navItems={navItems}
|
||||
showSidebar={showSidebar}
|
||||
showTopBar={showTopBar}
|
||||
launcherMode={launcherMode}
|
||||
showViewAsAdmin={showViewAsAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Desktop header */}
|
||||
{showHeader && <LayoutHeader showTopBar={showTopBar} />}
|
||||
{showHeader && (
|
||||
<LayoutHeader
|
||||
showTopBar={showTopBar}
|
||||
launcherMode={launcherMode}
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
showViewAsAdmin={showViewAsAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||
|
||||
@@ -8,16 +8,31 @@ import { useTheme } from "next-themes";
|
||||
import BrandingLogo from "./BrandingLogo";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface LayoutHeaderProps {
|
||||
type LayoutHeaderProps = {
|
||||
showTopBar: boolean;
|
||||
}
|
||||
launcherMode?: boolean;
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
showViewAsAdmin?: boolean;
|
||||
};
|
||||
|
||||
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
export function LayoutHeader({
|
||||
showTopBar,
|
||||
launcherMode = false,
|
||||
orgId,
|
||||
orgs,
|
||||
showViewAsAdmin = false
|
||||
}: LayoutHeaderProps) {
|
||||
const { theme } = useTheme();
|
||||
const [path, setPath] = useState<string>("");
|
||||
const { env } = useEnvContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const logoWidth = isUnlocked()
|
||||
? env.branding.logo?.navbar?.width || 98
|
||||
@@ -53,16 +68,38 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
<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">
|
||||
<div className="flex items-center gap-5 min-w-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center shrink-0"
|
||||
>
|
||||
<BrandingLogo
|
||||
width={logoWidth}
|
||||
height={logoHeight}
|
||||
/>
|
||||
</Link>
|
||||
{/* {build === "saas" && (
|
||||
<Badge variant="secondary">Cloud Beta</Badge>
|
||||
)} */}
|
||||
{launcherMode ? (
|
||||
<>
|
||||
<LauncherOrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
{showViewAsAdmin && orgId ? (
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/${orgId}/settings`}>
|
||||
{t(
|
||||
"resourceLauncherViewAsAdmin"
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showTopBar && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, Menu, Server } from "lucide-react";
|
||||
import { Menu, Server, Settings, SquareMousePointer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
@@ -29,6 +29,8 @@ interface LayoutMobileMenuProps {
|
||||
navItems: SidebarNavSection[];
|
||||
showSidebar: boolean;
|
||||
showTopBar: boolean;
|
||||
launcherMode?: boolean;
|
||||
showViewAsAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function LayoutMobileMenu({
|
||||
@@ -36,19 +38,33 @@ export function LayoutMobileMenu({
|
||||
orgs,
|
||||
navItems,
|
||||
showSidebar,
|
||||
showTopBar
|
||||
showTopBar,
|
||||
launcherMode = false,
|
||||
showViewAsAdmin = false
|
||||
}: LayoutMobileMenuProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
const { user } = useUserContext();
|
||||
const t = useTranslations();
|
||||
const showMobileNav = showSidebar || launcherMode;
|
||||
const currentOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
const isSettingsPage = Boolean(
|
||||
orgId && pathname?.includes(`/${orgId}/settings`)
|
||||
);
|
||||
const canViewResourceLauncher = Boolean(
|
||||
currentOrg?.isAdmin || currentOrg?.isOwner
|
||||
);
|
||||
|
||||
const mobileNavLinkClassName = 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"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="shrink-0 md:hidden sticky top-0 z-50">
|
||||
<div className="h-16 flex items-center px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSidebar && (
|
||||
{showMobileNav && (
|
||||
<div>
|
||||
<Sheet
|
||||
open={isMobileMenuOpen}
|
||||
@@ -69,24 +85,24 @@ export function LayoutMobileMenu({
|
||||
<SheetDescription className="sr-only">
|
||||
{t("navbarDescription")}
|
||||
</SheetDescription>
|
||||
<div className="w-full border-b border-border">
|
||||
<div className="px-1 shrink-0">
|
||||
<OrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-3">
|
||||
{!isAdminPage &&
|
||||
user.serverAdmin && (
|
||||
{launcherMode ? (
|
||||
<>
|
||||
<div className="w-full border-b border-border">
|
||||
<div className="px-1 shrink-0">
|
||||
<OrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showViewAsAdmin && orgId ? (
|
||||
<div className="px-3">
|
||||
<div className="mb-1">
|
||||
<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"
|
||||
)}
|
||||
href={`/${orgId}/settings`}
|
||||
className={
|
||||
mobileNavLinkClassName
|
||||
}
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
@@ -94,25 +110,95 @@ export function LayoutMobileMenu({
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||
<Server className="h-4 w-4" />
|
||||
<Settings className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"serverAdmin"
|
||||
"resourceLauncherViewAsAdmin"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav
|
||||
sections={navItems}
|
||||
onItemClick={() =>
|
||||
setIsMobileMenuOpen(false)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-full border-b border-border">
|
||||
<div className="px-1 shrink-0">
|
||||
<OrgSelector
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-3">
|
||||
{!isAdminPage &&
|
||||
isSettingsPage &&
|
||||
canViewResourceLauncher &&
|
||||
orgId && (
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={
|
||||
mobileNavLinkClassName
|
||||
}
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"resourceLauncherTitle"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!isAdminPage &&
|
||||
user.serverAdmin && (
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className={
|
||||
mobileNavLinkClassName
|
||||
}
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"serverAdmin"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav
|
||||
sections={navItems}
|
||||
onItemClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { SidebarNavSection } from "@app/app/navigation";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -19,7 +18,13 @@ import { approvalQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
PanelRightOpen,
|
||||
Server,
|
||||
SquareMousePointer
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
@@ -131,6 +136,13 @@ export function LayoutSidebar({
|
||||
const showTrial =
|
||||
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
|
||||
|
||||
const isSettingsPage = Boolean(
|
||||
orgId && pathname?.includes(`/${orgId}/settings`)
|
||||
);
|
||||
const canViewResourceLauncher = Boolean(
|
||||
currentOrg?.isAdmin || currentOrg?.isOwner
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -153,6 +165,46 @@ export function LayoutSidebar({
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-2 pt-3">
|
||||
{!isAdminPage &&
|
||||
isSettingsPage &&
|
||||
canViewResourceLauncher &&
|
||||
orgId && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
|
||||
isSidebarCollapsed
|
||||
? "px-2 py-2 justify-center"
|
||||
: "px-3 py-1.5"
|
||||
)}
|
||||
title={
|
||||
isSidebarCollapsed
|
||||
? t("resourceLauncherTitle")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
|
||||
!isSidebarCollapsed && "mr-3"
|
||||
)}
|
||||
>
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="flex-1">
|
||||
{t("resourceLauncherTitle")}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -258,11 +310,6 @@ export function LayoutSidebar({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{build === "oss" && (
|
||||
<div className="px-4">
|
||||
<SupporterStatus isCollapsed={isSidebarCollapsed} />
|
||||
</div>
|
||||
)}
|
||||
{build === "saas" && (
|
||||
<div className="px-4">
|
||||
<SidebarSupportButton
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
RefreshCw
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
||||
@@ -345,6 +345,7 @@ export default function LoginForm({
|
||||
error={error}
|
||||
loading={loading}
|
||||
formId="form"
|
||||
username={form.getValues("email")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ export default function LoginPasswordForm({
|
||||
}}
|
||||
error={error}
|
||||
loading={loading}
|
||||
username={identifier}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
@@ -24,26 +24,25 @@ import {
|
||||
ArrowUp10Icon,
|
||||
ChevronsUpDownIcon,
|
||||
CircleSlash,
|
||||
MoreHorizontal,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
startTransition,
|
||||
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";
|
||||
import { type SelectedLabel } from "./labels-selector";
|
||||
import { TableLabelsCell } from "./TableLabelsCell";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
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 {
|
||||
productUpdatesQueries,
|
||||
type LatestVersionResponse
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -105,12 +104,15 @@ export default function MachineClientsTable({
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
||||
|
||||
const latestPlatformVersions = data.data?.data;
|
||||
|
||||
const defaultMachineColumnVisibility = {
|
||||
subnet: false,
|
||||
userId: false,
|
||||
niceId: false,
|
||||
labels: false
|
||||
labels: true
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
@@ -379,6 +381,37 @@ export default function MachineClientsTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
const agentVersionMap: Record<string, string> = {
|
||||
"Pangolin Windows": "windows",
|
||||
"Pangolin Android": "android",
|
||||
"Pangolin iOS": "ios",
|
||||
"Pangolin iPadOS": "ios",
|
||||
"Pangolin macOS": "mac",
|
||||
"Pangolin CLI": "cli",
|
||||
"Olm CLI": "olm"
|
||||
};
|
||||
|
||||
let updateAvailable = false;
|
||||
|
||||
if (
|
||||
originalRow.olmVersion &&
|
||||
originalRow.agent &&
|
||||
latestPlatformVersions
|
||||
) {
|
||||
const agent = agentVersionMap[
|
||||
originalRow.agent
|
||||
] as keyof LatestVersionResponse;
|
||||
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{originalRow.agent && originalRow.olmVersion ? (
|
||||
@@ -390,9 +423,9 @@ export default function MachineClientsTable({
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
{/*originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)*/}
|
||||
{updateAvailable && (
|
||||
<InfoPopup info={t("updateAvailableInfo")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -612,51 +645,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 (
|
||||
<TableLabelsCell
|
||||
<LabelsTableCell
|
||||
orgId={orgId}
|
||||
localLabels={localLabels}
|
||||
toggleLabel={toggleClientLabel}
|
||||
selectedLabels={localLabels}
|
||||
onToggleLabel={toggleLabel}
|
||||
onClosePopover={() => startTransition(refresh)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
@@ -8,17 +7,15 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "./ui/input-otp";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import * as z from "zod";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
|
||||
const MFA_OTP_INPUT_ID = "mfa-otp-code";
|
||||
|
||||
type MfaInputFormProps = {
|
||||
form: UseFormReturn<{ code: string }>;
|
||||
@@ -27,6 +24,7 @@ type MfaInputFormProps = {
|
||||
error?: string | null;
|
||||
loading?: boolean;
|
||||
formId?: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export default function MfaInputForm({
|
||||
@@ -35,55 +33,10 @@ export default function MfaInputForm({
|
||||
onBack,
|
||||
error,
|
||||
loading = false,
|
||||
formId = "mfaForm"
|
||||
formId = "mfaForm",
|
||||
username
|
||||
}: MfaInputFormProps) {
|
||||
const t = useTranslations();
|
||||
const otpContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-focus MFA input when component mounts
|
||||
useEffect(() => {
|
||||
const focusInput = () => {
|
||||
// Try using the ref first
|
||||
if (otpContainerRef.current) {
|
||||
const hiddenInput = otpContainerRef.current.querySelector(
|
||||
"input"
|
||||
) as HTMLInputElement;
|
||||
if (hiddenInput) {
|
||||
hiddenInput.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: query the DOM
|
||||
const otpContainer = document.querySelector(
|
||||
'[data-slot="input-otp"]'
|
||||
);
|
||||
if (!otpContainer) return;
|
||||
|
||||
const hiddenInput = otpContainer.querySelector(
|
||||
"input"
|
||||
) as HTMLInputElement;
|
||||
if (hiddenInput) {
|
||||
hiddenInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Last resort: click the first slot
|
||||
const firstSlot = otpContainer.querySelector(
|
||||
'[data-slot="input-otp-slot"]'
|
||||
) as HTMLElement;
|
||||
if (firstSlot) {
|
||||
firstSlot.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Use requestAnimationFrame to wait for the next paint
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
focusInput();
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -99,25 +52,45 @@ export default function MfaInputForm({
|
||||
className="space-y-4"
|
||||
id={formId}
|
||||
>
|
||||
{username ? (
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="sr-only"
|
||||
/>
|
||||
) : null}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
htmlFor={MFA_OTP_INPUT_ID}
|
||||
className="sr-only"
|
||||
>
|
||||
{t("otpAuth")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
ref={otpContainerRef}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
id={MFA_OTP_INPUT_ID}
|
||||
maxLength={6}
|
||||
{...field}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
onChange={(value: string) => {
|
||||
field.onChange(value);
|
||||
if (value.length === 6) {
|
||||
form.handleSubmit(onSubmit)();
|
||||
form.handleSubmit(
|
||||
onSubmit
|
||||
)();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -389,7 +389,7 @@ export default function NewPricingLicenseForm({
|
||||
{part}
|
||||
{index === 0 && (
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
href="https://pangolin.net/fcl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -548,7 +548,7 @@ export default function NewPricingLicenseForm({
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
href="https://pangolin.net/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -561,7 +561,7 @@ export default function NewPricingLicenseForm({
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
href="https://pangolin.net/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -601,12 +601,12 @@ export default function NewPricingLicenseForm({
|
||||
"generateLicenseKeyForm.form.complianceConfirmation"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
href="https://pangolin.net/fcl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
https://pangolin.net/fcl
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
@@ -799,7 +799,7 @@ export default function NewPricingLicenseForm({
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
href="https://pangolin.net/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -812,7 +812,7 @@ export default function NewPricingLicenseForm({
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
href="https://pangolin.net/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -852,12 +852,12 @@ export default function NewPricingLicenseForm({
|
||||
"generateLicenseKeyForm.form.complianceConfirmation"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
href="https://pangolin.net/fcl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
https://pangolin.net/fcl
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
|
||||
@@ -125,6 +125,13 @@ function IdpImportRowIcon({
|
||||
return <IdpTypeIcon type={type} variant={variant} size={20} />;
|
||||
}
|
||||
|
||||
function isUserMemberOfIdp(
|
||||
userIdpId: number | null | undefined,
|
||||
idpId: number
|
||||
) {
|
||||
return userIdpId != null && userIdpId === idpId;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
idps: IdpRow[];
|
||||
orgId: string;
|
||||
@@ -362,9 +369,17 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
<p>{t("idpDeleteGlobalDescription")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("idpConfirmDelete")}
|
||||
buttonText={
|
||||
isUserMemberOfIdp(user.idpId, selectedIdp.idpId)
|
||||
? t("idpConfirmDeleteAndRemoveMeFromOrg")
|
||||
: t("idpConfirmDelete")
|
||||
}
|
||||
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
|
||||
string={selectedIdp.name}
|
||||
string={
|
||||
isUserMemberOfIdp(user.idpId, selectedIdp.idpId)
|
||||
? t("idpConfirmDeleteAndRemoveMeFromOrg")
|
||||
: selectedIdp.name
|
||||
}
|
||||
title={t("idpDelete")}
|
||||
/>
|
||||
)}
|
||||
@@ -381,11 +396,25 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
<p>{t("idpUnassociateDescription")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("idpUnassociateConfirm")}
|
||||
buttonText={
|
||||
isUserMemberOfIdp(
|
||||
user.idpId,
|
||||
selectedUnassociateIdp.idpId
|
||||
)
|
||||
? t("idpUnassociateAndRemoveMeFromOrg")
|
||||
: t("idpUnassociateConfirm")
|
||||
}
|
||||
onConfirm={async () =>
|
||||
unassociateIdp(selectedUnassociateIdp.idpId)
|
||||
}
|
||||
string={selectedUnassociateIdp.name}
|
||||
string={
|
||||
isUserMemberOfIdp(
|
||||
user.idpId,
|
||||
selectedUnassociateIdp.idpId
|
||||
)
|
||||
? t("idpUnassociateAndRemoveMeFromOrg")
|
||||
: selectedUnassociateIdp.name
|
||||
}
|
||||
title={t("idpUnassociateTitle")}
|
||||
warningText={t("idpUnassociateWarning")}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{org.org.name}</InfoSectionContent>
|
||||
@@ -34,6 +34,14 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
|
||||
{org.org.subnet || t("none")}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("utilitySubnet")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{org.org.utilitySubnet || t("none")}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -35,9 +35,14 @@ export type LabelFormData = z.infer<typeof labelFormSchema>;
|
||||
export type OrgLabelFormProps = {
|
||||
onSubmit: (data: LabelFormData) => void;
|
||||
defaultValue?: LabelFormData;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
||||
export function OrgLabelForm({
|
||||
onSubmit,
|
||||
defaultValue,
|
||||
disabled = false
|
||||
}: OrgLabelFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const colorValues = Object.values(LABEL_COLORS);
|
||||
@@ -70,9 +75,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t("labelNameField")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
<Input {...field} disabled={disabled} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -88,6 +91,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
@@ -110,7 +114,9 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
||||
}}
|
||||
/>
|
||||
<span data-name>
|
||||
{color.charAt(0).toUpperCase() +
|
||||
{color
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
color.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
@@ -12,14 +12,7 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { type PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal,
|
||||
PencilIcon,
|
||||
PencilLineIcon
|
||||
} from "lucide-react";
|
||||
import { ArrowRight, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useActionState, useMemo, useState, useTransition } from "react";
|
||||
@@ -109,7 +102,7 @@ export default function OrgLabelsTable({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5 group">
|
||||
<div
|
||||
className="size-2.5 rounded-full bg-(--color) flex-none"
|
||||
className="size-2 rounded-full bg-(--color) flex-none"
|
||||
style={{
|
||||
// @ts-expect-error css color
|
||||
"--color": row.original.color
|
||||
@@ -125,34 +118,40 @@ export default function OrgLabelsTable({
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{t("openMenu")}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedLabel(row.original);
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedLabel(row.original);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedLabel(row.original);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedLabel(row.original);
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type OrgLoginPageProps = {
|
||||
loginPage: LoadLoginPageResponse | undefined;
|
||||
@@ -52,22 +53,8 @@ export default async function OrgLoginPage({
|
||||
const env = pullEnv();
|
||||
const t = await getTranslations();
|
||||
return (
|
||||
<div>
|
||||
{build !== "enterprise" || !env.branding.hidePoweredBy ? (
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("poweredBy")}{" "}
|
||||
<Link
|
||||
href="https://pangolin.net/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{env.branding.appName || "Pangolin"}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<BrandedAuthSurface primaryColor={branding?.primaryColor ?? null}>
|
||||
<PoweredByPangolin />
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
{branding?.logoUrl && (
|
||||
@@ -127,6 +114,6 @@ export default async function OrgLoginPage({
|
||||
{t("loginBack")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</BrandedAuthSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
|
||||
function PoweredByLabel({ brandName }: { brandName: string }) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("poweredBy")}{" "}
|
||||
{brandName === "Pangolin" ? (
|
||||
<Link
|
||||
href="https://pangolin.net/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Pangolin
|
||||
</Link>
|
||||
) : (
|
||||
brandName
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PoweredByPangolin() {
|
||||
const { env } = useEnvContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
if (isUnlocked() && build === "enterprise") {
|
||||
if (
|
||||
env.branding.resourceAuthPage?.hidePoweredBy ||
|
||||
env.branding.hidePoweredBy
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PoweredByLabel
|
||||
brandName={env.branding.appName || "Pangolin"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <PoweredByLabel brandName="Pangolin" />;
|
||||
}
|
||||
+889
-471
File diff suppressed because it is too large
Load Diff
+83
-176
@@ -2,9 +2,17 @@
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog";
|
||||
import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog";
|
||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||
import {
|
||||
ResourceSitesStatusCell,
|
||||
type ResourceSiteRow
|
||||
} from "@app/components/ResourceSitesStatusCell";
|
||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -18,52 +26,35 @@ import {
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
Funnel,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||
import {
|
||||
startTransition,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition
|
||||
} from "react";
|
||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { startTransition, useMemo, useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
||||
import {
|
||||
ResourceSitesStatusCell,
|
||||
type ResourceSiteRow
|
||||
} from "@app/components/ResourceSitesStatusCell";
|
||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||
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 { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
|
||||
|
||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||
|
||||
@@ -77,21 +68,22 @@ export type InternalResourceRow = {
|
||||
siteIds: number[];
|
||||
siteNiceIds: string[];
|
||||
// mode: "host" | "cidr" | "port";
|
||||
mode: "host" | "cidr" | "http";
|
||||
mode: "host" | "cidr" | "http" | "ssh";
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
// protocol: string | null;
|
||||
// proxyPort: number | null;
|
||||
destination: string;
|
||||
httpHttpsPort: number | null;
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
niceId: string;
|
||||
tcpPortRangeString: string | null;
|
||||
udpPortRangeString: string | null;
|
||||
disableIcmp: boolean;
|
||||
authDaemonMode?: "site" | "remote" | null;
|
||||
authDaemonMode?: "site" | "remote" | "native" | null;
|
||||
authDaemonPort?: number | null;
|
||||
pamMode?: "passthrough" | "push" | null;
|
||||
subdomain?: string | null;
|
||||
domainId?: string | null;
|
||||
fullDomain?: string | null;
|
||||
@@ -106,7 +98,7 @@ function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||
return formatSiteResourceDestinationDisplay({
|
||||
mode: row.mode,
|
||||
destination: row.destination,
|
||||
httpHttpsPort: row.httpHttpsPort,
|
||||
destinationPort: row.destinationPort,
|
||||
scheme: row.scheme
|
||||
});
|
||||
}
|
||||
@@ -128,7 +120,7 @@ type ClientResourcesTableProps = {
|
||||
initialFilterSite?: Selectedsite | null;
|
||||
};
|
||||
|
||||
export default function ClientResourcesTable({
|
||||
export default function PrivateResourcesTable({
|
||||
internalResources,
|
||||
orgId,
|
||||
pagination,
|
||||
@@ -155,7 +147,6 @@ export default function ClientResourcesTable({
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
|
||||
@@ -169,22 +160,6 @@ export default function ClientResourcesTable({
|
||||
// return () => clearInterval(interval);
|
||||
// }, [router]);
|
||||
|
||||
const siteIdQ = searchParams.get("siteId");
|
||||
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||
const selectedSite: Selectedsite | null = useMemo(() => {
|
||||
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
|
||||
return initialFilterSite;
|
||||
}
|
||||
return {
|
||||
siteId: siteIdNum,
|
||||
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
|
||||
type: "newt"
|
||||
};
|
||||
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||
|
||||
const refreshData = () => {
|
||||
startRefreshTransition(() => {
|
||||
try {
|
||||
@@ -211,11 +186,11 @@ export default function ClientResourcesTable({
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(t("resourceErrorDelete"), e);
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("v"))
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -278,58 +253,27 @@ export default function ClientResourcesTable({
|
||||
accessorFn: (row) =>
|
||||
row.sites.map((s) => s.siteName).join(", "),
|
||||
friendlyName: t("sites"),
|
||||
header: () => (
|
||||
<Popover
|
||||
open={siteFilterOpen}
|
||||
onOpenChange={setSiteFilterOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||
!selectedSite && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{t("sites")}
|
||||
<Funnel className="size-4 flex-none" />
|
||||
{selectedSite && (
|
||||
<Badge
|
||||
className="truncate max-w-[10rem]"
|
||||
variant="secondary"
|
||||
>
|
||||
{selectedSite.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
>
|
||||
<div className="border-b p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start font-normal"
|
||||
onClick={clearSiteFilter}
|
||||
>
|
||||
{t("standaloneHcFilterAnySite")}
|
||||
</Button>
|
||||
</div>
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
onSelectSite={onPickSite}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
header: () => {
|
||||
const siteIdQ = searchParams.get("siteId");
|
||||
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||
|
||||
const selectedSiteId =
|
||||
!siteIdQ ||
|
||||
!Number.isInteger(siteIdNum) ||
|
||||
siteIdNum <= 0
|
||||
? null
|
||||
: siteIdNum;
|
||||
|
||||
return (
|
||||
<SitesColumnFilterButton
|
||||
selectedSiteId={selectedSiteId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("siteId", value?.toString())
|
||||
}
|
||||
orgId={orgId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
@@ -357,6 +301,10 @@ export default function ClientResourcesTable({
|
||||
{
|
||||
value: "http",
|
||||
label: t("editInternalResourceDialogModeHttp")
|
||||
},
|
||||
{
|
||||
value: "ssh",
|
||||
label: t("editInternalResourceDialogModeSsh")
|
||||
}
|
||||
]}
|
||||
selectedValue={searchParams.get("mode") ?? undefined}
|
||||
@@ -372,13 +320,14 @@ export default function ClientResourcesTable({
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const modeLabels: Record<
|
||||
"host" | "cidr" | "port" | "http",
|
||||
"host" | "cidr" | "port" | "http" | "ssh",
|
||||
string
|
||||
> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
port: t("editInternalResourceDialogModePort"),
|
||||
http: t("editInternalResourceDialogModeHttp")
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
};
|
||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||
}
|
||||
@@ -394,13 +343,16 @@ export default function ClientResourcesTable({
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const display = formatDestinationDisplay(resourceRow);
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={display}
|
||||
isLink={false}
|
||||
displayText={display}
|
||||
/>
|
||||
);
|
||||
if (resourceRow.destination) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={display}
|
||||
isLink={false}
|
||||
displayText={display}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>-</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -411,7 +363,7 @@ export default function ClientResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||
if (resourceRow.alias) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.alias}
|
||||
@@ -571,16 +523,6 @@ export default function ClientResourcesTable({
|
||||
});
|
||||
}
|
||||
|
||||
const clearSiteFilter = () => {
|
||||
handleFilterChange("siteId", undefined);
|
||||
setSiteFilterOpen(false);
|
||||
};
|
||||
|
||||
const onPickSite = (site: Selectedsite) => {
|
||||
handleFilterChange("siteId", String(site.siteId));
|
||||
setSiteFilterOpen(false);
|
||||
};
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
@@ -650,14 +592,14 @@ export default function ClientResourcesTable({
|
||||
columnVisibility={{
|
||||
niceId: false,
|
||||
aliasAddress: false,
|
||||
labels: false
|
||||
labels: true
|
||||
}}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
|
||||
{editingResource && (
|
||||
<EditInternalResourceDialog
|
||||
<EditPrivateResourceDialog
|
||||
open={isEditDialogOpen}
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
@@ -672,7 +614,7 @@ export default function ClientResourcesTable({
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateInternalResourceDialog
|
||||
<CreatePrivateResourceDialog
|
||||
open={isCreateDialogOpen}
|
||||
setOpen={setIsCreateDialogOpen}
|
||||
orgId={orgId}
|
||||
@@ -696,54 +638,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 (
|
||||
<TableLabelsCell
|
||||
<LabelsTableCell
|
||||
orgId={orgId}
|
||||
localLabels={localLabels}
|
||||
toggleLabel={toggleResourceLabel}
|
||||
onClosePopover={() => startTransition(refresh)}
|
||||
onToggleLabel={toggleLabel}
|
||||
selectedLabels={localLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ProductUpdate,
|
||||
productUpdatesQueries
|
||||
} from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -39,22 +40,42 @@ export default function ProductUpdates({
|
||||
}) {
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const productUpdatesEnabled = env.app.notifications.product_updates;
|
||||
const versionCheckEnabled =
|
||||
env.app.notifications.new_releases && build !== "saas";
|
||||
|
||||
const data = useQueries({
|
||||
queries: [
|
||||
productUpdatesQueries.list(
|
||||
env.app.notifications.product_updates,
|
||||
env.app.version
|
||||
),
|
||||
productUpdatesQueries.list(productUpdatesEnabled, env.app.version),
|
||||
productUpdatesQueries.latestVersion(
|
||||
env.app.notifications.new_releases
|
||||
)
|
||||
],
|
||||
combine(result) {
|
||||
if (result[0].isLoading || result[1].isLoading) return null;
|
||||
return {
|
||||
updates: result[0].data?.data ?? [],
|
||||
latestVersion: result[1].data
|
||||
};
|
||||
const [updatesQuery, versionQuery] = result;
|
||||
|
||||
const updatesSettled =
|
||||
!productUpdatesEnabled ||
|
||||
updatesQuery.isFetched ||
|
||||
updatesQuery.isError;
|
||||
const versionSettled =
|
||||
!versionCheckEnabled ||
|
||||
versionQuery.isFetched ||
|
||||
versionQuery.isError;
|
||||
|
||||
if (!updatesSettled || !versionSettled) return null;
|
||||
|
||||
const updates = updatesQuery.isError
|
||||
? []
|
||||
: Array.isArray(updatesQuery.data?.data)
|
||||
? updatesQuery.data.data
|
||||
: [];
|
||||
|
||||
const latestVersion = versionQuery.isError
|
||||
? undefined
|
||||
: versionQuery.data;
|
||||
|
||||
return { updates, latestVersion };
|
||||
}
|
||||
});
|
||||
const t = useTranslations();
|
||||
@@ -76,19 +97,30 @@ export default function ProductUpdates({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const latestVersion = data?.latestVersion?.data?.pangolin.latestVersion;
|
||||
const versionResponse = data.latestVersion?.data;
|
||||
const latestVersion = versionResponse?.pangolin?.latestVersion;
|
||||
const currentVersion = env.app.version;
|
||||
|
||||
const showNewVersionPopup = Boolean(
|
||||
let showNewVersionPopup = false;
|
||||
if (
|
||||
latestVersion &&
|
||||
valid(latestVersion) &&
|
||||
valid(currentVersion) &&
|
||||
ignoredVersionUpdate !== latestVersion &&
|
||||
gt(latestVersion, currentVersion)
|
||||
);
|
||||
valid(latestVersion) &&
|
||||
valid(currentVersion) &&
|
||||
ignoredVersionUpdate !== latestVersion
|
||||
) {
|
||||
try {
|
||||
showNewVersionPopup = gt(latestVersion, currentVersion);
|
||||
} catch {
|
||||
showNewVersionPopup = false;
|
||||
}
|
||||
}
|
||||
|
||||
const readUpdateIds = Array.isArray(productUpdatesRead)
|
||||
? productUpdatesRead
|
||||
: [];
|
||||
|
||||
const filteredUpdates = data.updates.filter(
|
||||
(update) => !productUpdatesRead.includes(update.id)
|
||||
(update) => !readUpdateIds.includes(update.id)
|
||||
);
|
||||
|
||||
if (filteredUpdates.length === 0 && !showNewVersionPopup) {
|
||||
@@ -133,17 +165,14 @@ export default function ProductUpdates({
|
||||
show={filteredUpdates.length > 0}
|
||||
onDimissAll={() =>
|
||||
setProductUpdatesRead([
|
||||
...productUpdatesRead,
|
||||
...readUpdateIds,
|
||||
...filteredUpdates.map(
|
||||
(update) => update.id
|
||||
)
|
||||
])
|
||||
}
|
||||
onDimiss={(id) =>
|
||||
setProductUpdatesRead([
|
||||
...productUpdatesRead,
|
||||
id
|
||||
])
|
||||
setProductUpdatesRead([...readUpdateIds, id])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -151,11 +180,9 @@ export default function ProductUpdates({
|
||||
</div>
|
||||
|
||||
<NewVersionAvailable
|
||||
version={data.latestVersion?.data}
|
||||
version={versionResponse}
|
||||
onDimiss={() => {
|
||||
setIgnoredVersionUpdate(
|
||||
data.latestVersion?.data?.pangolin.latestVersion ?? null
|
||||
);
|
||||
setIgnoredVersionUpdate(latestVersion ?? null);
|
||||
}}
|
||||
show={showNewVersionPopup}
|
||||
/>
|
||||
@@ -346,6 +373,10 @@ function NewVersionAvailable({
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
if (!version?.pangolin?.latestVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition show={open}>
|
||||
{version && (
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { build } from "@server/build";
|
||||
import { useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Disable2FaForm from "./Disable2FaForm";
|
||||
@@ -27,7 +26,6 @@ import SecurityKeyForm from "./SecurityKeyForm";
|
||||
import Enable2FaDialog from "./Enable2FaDialog";
|
||||
import ChangePasswordDialog from "./ChangePasswordDialog";
|
||||
import ViewDevicesDialog from "./ViewDevicesDialog";
|
||||
import SupporterStatus from "./SupporterStatus";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import LocaleSwitcher from "@app/components/LocaleSwitcher";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
export const ProxyResourcesBanner = () => {
|
||||
export const PublicResourcesBanner = () => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="proxy-resources-banner-dismissed"
|
||||
version={1}
|
||||
title={t("proxyResourcesBannerTitle")}
|
||||
title={t("publicResourcesBannerTitle")}
|
||||
titleIcon={<Globe className="w-5 h-5 text-primary" />}
|
||||
description={t("proxyResourcesBannerDescription")}
|
||||
description={t("publicResourcesBannerDescription")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxyResourcesBanner;
|
||||
export default PublicResourcesBanner;
|
||||
+122
-181
@@ -73,7 +73,10 @@ 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";
|
||||
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
|
||||
|
||||
export type TargetHealth = {
|
||||
targetId: number;
|
||||
@@ -90,9 +93,8 @@ export type ResourceRow = {
|
||||
name: string;
|
||||
orgId: string;
|
||||
domain: string;
|
||||
mode: string | null;
|
||||
authState: string;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
enabled: boolean;
|
||||
domainId?: string;
|
||||
@@ -125,7 +127,7 @@ const booleanSearchFilterSchema = z
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
export default function ProxyResourcesTable({
|
||||
export default function PublicResourcesTable({
|
||||
resources,
|
||||
orgId,
|
||||
pagination,
|
||||
@@ -153,30 +155,6 @@ export default function ProxyResourcesTable({
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||
|
||||
const siteIdQ = searchParams.get("siteId");
|
||||
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||
const selectedSite: Selectedsite | null = useMemo(() => {
|
||||
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
|
||||
return initialFilterSite;
|
||||
}
|
||||
return {
|
||||
siteId: siteIdNum,
|
||||
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
|
||||
type: "newt"
|
||||
};
|
||||
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||
|
||||
// useEffect(() => {
|
||||
// const interval = setInterval(() => {
|
||||
// router.refresh();
|
||||
// }, 30_000);
|
||||
// return () => clearInterval(interval);
|
||||
// }, [router]);
|
||||
|
||||
const refreshData = () => {
|
||||
startTransition(() => {
|
||||
@@ -192,22 +170,17 @@ export default function ProxyResourcesTable({
|
||||
});
|
||||
};
|
||||
|
||||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
const deleteResource = async (resourceId: number) => {
|
||||
await api.delete(`/resource/${resourceId}`).catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
});
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
};
|
||||
|
||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||
@@ -231,28 +204,6 @@ export default function ProxyResourcesTable({
|
||||
}
|
||||
}
|
||||
|
||||
const clearSiteFilter = () => {
|
||||
handleFilterChange("siteId", undefined);
|
||||
setSiteFilterOpen(false);
|
||||
};
|
||||
|
||||
const onPickSite = (site: Selectedsite) => {
|
||||
handleFilterChange("siteId", String(site.siteId));
|
||||
setSiteFilterOpen(false);
|
||||
};
|
||||
|
||||
const siteFilterOpenRef = useRef(siteFilterOpen);
|
||||
siteFilterOpenRef.current = siteFilterOpen;
|
||||
|
||||
const selectedSiteRef = useRef(selectedSite);
|
||||
selectedSiteRef.current = selectedSite;
|
||||
|
||||
const clearSiteFilterRef = useRef(clearSiteFilter);
|
||||
clearSiteFilterRef.current = clearSiteFilter;
|
||||
|
||||
const onPickSiteRef = useRef(onPickSite);
|
||||
onPickSiteRef.current = onPickSite;
|
||||
|
||||
const proxyColumns = useMemo<ExtendedColumnDef<ResourceRow>[]>(() => {
|
||||
const cols: ExtendedColumnDef<ResourceRow>[] = [
|
||||
{
|
||||
@@ -295,61 +246,27 @@ export default function ProxyResourcesTable({
|
||||
accessorFn: (row) =>
|
||||
row.sites.map((s) => s.siteName).join(", "),
|
||||
friendlyName: t("sites"),
|
||||
header: () => (
|
||||
<Popover
|
||||
open={siteFilterOpenRef.current}
|
||||
onOpenChange={setSiteFilterOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||
!selectedSiteRef.current &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{t("sites")}
|
||||
<Funnel className="size-4 flex-none" />
|
||||
{selectedSiteRef.current && (
|
||||
<Badge
|
||||
className="truncate max-w-[10rem]"
|
||||
variant="secondary"
|
||||
>
|
||||
{selectedSiteRef.current.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
>
|
||||
<div className="border-b p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start font-normal"
|
||||
onClick={() => clearSiteFilterRef.current()}
|
||||
>
|
||||
{t("standaloneHcFilterAnySite")}
|
||||
</Button>
|
||||
</div>
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSiteRef.current}
|
||||
onSelectSite={(site) =>
|
||||
onPickSiteRef.current(site)
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
header: () => {
|
||||
const siteIdQ = searchParams.get("siteId");
|
||||
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
|
||||
|
||||
const selectedSiteId =
|
||||
!siteIdQ ||
|
||||
!Number.isInteger(siteIdNum) ||
|
||||
siteIdNum <= 0
|
||||
? null
|
||||
: siteIdNum;
|
||||
|
||||
return (
|
||||
<SitesColumnFilterButton
|
||||
selectedSiteId={selectedSiteId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("siteId", value?.toString())
|
||||
}
|
||||
orgId={orgId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<ResourceSitesStatusCell
|
||||
orgId={row.original.orgId}
|
||||
@@ -361,16 +278,59 @@ export default function ProxyResourcesTable({
|
||||
accessorKey: "protocol",
|
||||
friendlyName: t("protocol"),
|
||||
enableHiding: true,
|
||||
header: () => <span className="p-3">{t("protocol")}</span>,
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "http",
|
||||
label: t("editInternalResourceDialogModeHttp")
|
||||
},
|
||||
{
|
||||
value: "https",
|
||||
label: t("editInternalResourceDialogModeHttps")
|
||||
},
|
||||
{
|
||||
value: "tcp",
|
||||
label: t("editInternalResourceDialogTcp")
|
||||
},
|
||||
{
|
||||
value: "udp",
|
||||
label: t("editInternalResourceDialogUdp")
|
||||
},
|
||||
{
|
||||
value: "ssh",
|
||||
label: t("editInternalResourceDialogModeSsh")
|
||||
},
|
||||
{
|
||||
value: "rdp",
|
||||
label: t("rdpTitle")
|
||||
},
|
||||
{
|
||||
value: "vnc",
|
||||
label: t("vncTitle")
|
||||
}
|
||||
]}
|
||||
selectedValue={
|
||||
searchParams.get("protocol") ?? undefined
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("protocol", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("protocol")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<span>
|
||||
{resourceRow.http
|
||||
{resourceRow.mode == "http"
|
||||
? resourceRow.ssl
|
||||
? "HTTPS"
|
||||
: "HTTP"
|
||||
: resourceRow.protocol.toUpperCase()}
|
||||
: resourceRow.mode?.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -413,6 +373,9 @@ export default function ProxyResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
if (resourceRow.mode !== "http") {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
<TargetStatusCell
|
||||
targets={resourceRow.targets}
|
||||
@@ -441,6 +404,9 @@ export default function ProxyResourcesTable({
|
||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
if (resourceRow.mode !== "http") {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
|
||||
);
|
||||
@@ -453,7 +419,11 @@ export default function ProxyResourcesTable({
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
|
||||
if (!resourceRow.http) {
|
||||
if (
|
||||
!["http", "ssh", "rdp", "vnc"].includes(
|
||||
resourceRow.mode || ""
|
||||
)
|
||||
) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CopyToClipboard
|
||||
@@ -605,7 +575,7 @@ export default function ProxyResourcesTable({
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${resourceRow.orgId}/settings/resources/proxy/${resourceRow.nice}`}
|
||||
href={`/${resourceRow.orgId}/settings/resources/public/${resourceRow.nice}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
@@ -624,7 +594,7 @@ export default function ProxyResourcesTable({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/resources/proxy/${resourceRow.nice}`}
|
||||
href={`/${resourceRow.orgId}/settings/resources/public/${resourceRow.nice}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
@@ -718,7 +688,11 @@ export default function ProxyResourcesTable({
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourceDeleteConfirm")}
|
||||
onConfirm={async () => deleteResource(selectedResource!.id)}
|
||||
onConfirm={async () =>
|
||||
startTransition(() =>
|
||||
deleteResource(selectedResource!.id)
|
||||
)
|
||||
}
|
||||
string={selectedResource.name}
|
||||
title={t("resourceDelete")}
|
||||
/>
|
||||
@@ -736,7 +710,9 @@ export default function ProxyResourcesTable({
|
||||
onPaginationChange={handlePaginationChange}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(`/${orgId}/settings/resources/proxy/create`)
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/public/create`
|
||||
)
|
||||
)
|
||||
}
|
||||
addButtonText={t("resourceAdd")}
|
||||
@@ -747,7 +723,7 @@ export default function ProxyResourcesTable({
|
||||
columnVisibility={{
|
||||
niceId: false,
|
||||
protocol: false,
|
||||
labels: false
|
||||
labels: true
|
||||
}}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
@@ -762,57 +738,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 (
|
||||
<TableLabelsCell
|
||||
<LabelsTableCell
|
||||
orgId={orgId}
|
||||
localLabels={localLabels}
|
||||
toggleLabel={toggleSiteLabel}
|
||||
selectedLabels={localLabels}
|
||||
onToggleLabel={toggleLabel}
|
||||
onClosePopover={() => startTransition(refresh)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -829,7 +767,7 @@ function TargetStatusCell({
|
||||
|
||||
if (!targets || targets.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="flex items-center gap-2 px-0">
|
||||
<StatusIcon status="unknown" />
|
||||
<span className="text-sm">{t("resourcesTableNoTargets")}</span>
|
||||
</div>
|
||||
@@ -849,7 +787,7 @@ function TargetStatusCell({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8 px-2 font-normal"
|
||||
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||
>
|
||||
<StatusIcon status={overallStatus} />
|
||||
<span className="text-sm">
|
||||
@@ -941,7 +879,7 @@ function ResourceEnabledForm({
|
||||
resource,
|
||||
onToggleResourceEnabled
|
||||
}: ResourceEnabledFormProps) {
|
||||
const enabled = resource.http
|
||||
const enabled = ["http", "ssh", "rdp", "vnc"].includes(resource.mode || "")
|
||||
? !!resource.domainId && resource.enabled
|
||||
: resource.enabled;
|
||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
|
||||
@@ -959,7 +897,10 @@ function ResourceEnabledForm({
|
||||
<Switch
|
||||
checked={optimisticEnabled}
|
||||
disabled={
|
||||
(resource.http && !resource.domainId) ||
|
||||
(["http", "ssh", "rdp", "vnc"].includes(
|
||||
resource.mode || ""
|
||||
) &&
|
||||
!resource.domainId) ||
|
||||
optimisticEnabled !== enabled
|
||||
}
|
||||
name="enabled"
|
||||
@@ -6,20 +6,29 @@ import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||
|
||||
type RedirectToOrgProps = {
|
||||
targetOrgId: string;
|
||||
isAdminOrOwner?: boolean;
|
||||
};
|
||||
|
||||
export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||
export default function RedirectToOrg({
|
||||
targetOrgId,
|
||||
isAdminOrOwner = false
|
||||
}: RedirectToOrgProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const target =
|
||||
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
|
||||
getInternalRedirectTarget(targetOrgId) ??
|
||||
(isAdminOrOwner
|
||||
? `/${targetOrgId}/settings`
|
||||
: `/${targetOrgId}`);
|
||||
router.replace(target);
|
||||
} catch {
|
||||
router.replace(`/${targetOrgId}`);
|
||||
router.replace(
|
||||
isAdminOrOwner ? `/${targetOrgId}/settings` : `/${targetOrgId}`
|
||||
);
|
||||
}
|
||||
}, [targetOrgId, router]);
|
||||
}, [targetOrgId, isAdminOrOwner, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -41,9 +41,10 @@ import {
|
||||
} from "@app/actions/server";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
@@ -123,8 +124,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
function getDefaultSelectedMethod() {
|
||||
if (props.methods.sso) {
|
||||
return "sso";
|
||||
@@ -301,6 +300,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
let isAllowed = false;
|
||||
try {
|
||||
const response = await resourceAccessProxy(props.resource.id);
|
||||
console.log("response", response);
|
||||
if (response.error) {
|
||||
setAccessDenied(true);
|
||||
} else {
|
||||
@@ -366,57 +366,20 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// @ts-expect-error CSS variable
|
||||
"--primary": isUnlocked() ? props.branding?.primaryColor : null
|
||||
}}
|
||||
>
|
||||
<BrandedAuthSurface primaryColor={props.branding?.primaryColor}>
|
||||
{!accessDenied ? (
|
||||
<div>
|
||||
{isUnlocked() && build === "enterprise" ? (
|
||||
!env.branding.resourceAuthPage?.hidePoweredBy &&
|
||||
!env.branding.hidePoweredBy && (
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("poweredBy")}{" "}
|
||||
<Link
|
||||
href="https://pangolin.net/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{env.branding.appName || "Pangolin"}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("poweredBy")}{" "}
|
||||
<Link
|
||||
href="https://pangolin.net/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Pangolin
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PoweredByPangolin />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{isUnlocked() &&
|
||||
build !== "oss" &&
|
||||
(env.branding?.resourceAuthPage?.showLogo ||
|
||||
props.branding) && (
|
||||
props.branding?.logoUrl && (
|
||||
<div className="flex flex-row items-center justify-center mb-3">
|
||||
<BrandingLogo
|
||||
height={logoHeight}
|
||||
width={logoWidth}
|
||||
logoPath={props.branding?.logoUrl}
|
||||
logoPath={props.branding.logoUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -763,33 +726,11 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("instanceIsUnlicensed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("loginPageLicenseWatermark")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<AuthPageFooterNotices />
|
||||
</div>
|
||||
) : (
|
||||
<ResourceAccessDenied />
|
||||
)}
|
||||
</div>
|
||||
</BrandedAuthSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
@@ -32,12 +30,43 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||
|
||||
const showCertificate = !!(
|
||||
["http", "ssh", "rdp", "vnc"].includes(resource.mode) &&
|
||||
resource.domainId &&
|
||||
resource.fullDomain &&
|
||||
build != "oss"
|
||||
);
|
||||
const showType = !!(
|
||||
["http", "ssh", "rdp", "vnc"].includes(resource.mode) && resource.mode
|
||||
);
|
||||
const showHealth =
|
||||
!["ssh", "rdp", "vnc"].includes(resource.mode || "") &&
|
||||
!!resource.health &&
|
||||
resource.health !== "unknown";
|
||||
const showVisibility = !resource.enabled;
|
||||
|
||||
const numSections = [
|
||||
true, // URL or Protocol
|
||||
true, // Authentication or Port
|
||||
showType,
|
||||
showCertificate,
|
||||
showHealth,
|
||||
showVisibility
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{/* 4 cols because of the certs */}
|
||||
<InfoSections cols={resource.http && build != "oss" ? 5 : 4}>
|
||||
{resource.http ? (
|
||||
<InfoSections cols={numSections}>
|
||||
{/* <InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span className="inline-flex items-center">
|
||||
{resource.niceId}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection> */}
|
||||
{["http", "ssh", "rdp", "vnc"].includes(resource.mode) ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||
@@ -54,6 +83,22 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{showType && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("type")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span className="inline-flex items-center">
|
||||
{resource.mode == "http"
|
||||
? resource.ssl
|
||||
? "HTTPS"
|
||||
: "HTTP"
|
||||
: resource.mode?.toUpperCase()}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("authentication")}
|
||||
@@ -76,24 +121,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{/* {isEnabled && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Socket</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{isAvailable ? (
|
||||
<span className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Online</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
|
||||
<span>Offline</span>
|
||||
</span>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)} */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -103,7 +130,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span className="inline-flex items-center">
|
||||
{resource.protocol.toUpperCase()}
|
||||
{resource.mode?.toUpperCase()}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
@@ -141,74 +168,69 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
{/* </InfoSectionContent> */}
|
||||
{/* </InfoSection> */}
|
||||
{/* Certificate Status Column */}
|
||||
{resource.http &&
|
||||
resource.domainId &&
|
||||
resource.fullDomain &&
|
||||
build != "oss" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("certificateStatus", {
|
||||
defaultValue: "Certificate"
|
||||
})}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CertificateStatus
|
||||
orgId={resource.orgId}
|
||||
domainId={resource.domainId}
|
||||
fullDomain={resource.fullDomain}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.health === "healthy" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>{t("resourcesTableHealthy")}</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.health === "degraded" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
||||
<span>{t("resourcesTableDegraded")}</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.health === "unhealthy" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
|
||||
<span>{t("resourcesTableUnhealthy")}</span>
|
||||
</div>
|
||||
)}
|
||||
{(!resource.health ||
|
||||
resource.health === "unknown") && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t("resourcesTableUnknown")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.enabled ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Eye className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>{t("enabled")}</span>
|
||||
</div>
|
||||
) : (
|
||||
{showCertificate && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("certificateStatus", {
|
||||
defaultValue: "Certificate"
|
||||
})}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CertificateStatus
|
||||
orgId={resource.orgId}
|
||||
domainId={resource.domainId!}
|
||||
fullDomain={resource.fullDomain!}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
{showHealth && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.health === "healthy" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>
|
||||
{t("resourcesTableHealthy")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.health === "degraded" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
||||
<span>
|
||||
{t("resourcesTableDegraded")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.health === "unhealthy" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="w-4 h-4 flex-shrink-0 text-destructive" />
|
||||
<span>
|
||||
{t("resourcesTableUnhealthy")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
{showVisibility && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("visibility")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<EyeOff className="w-4 h-4 flex-shrink-0 text-neutral-500" />
|
||||
<span>{t("disabled")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Shield, ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
export const ResourcePoliciesBanner = () => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="resource-policies-banner-dismissed"
|
||||
version={1}
|
||||
title={t("resourcePoliciesBannerTitle")}
|
||||
titleIcon={<Shield className="w-5 h-5 text-primary" />}
|
||||
description={t("resourcePoliciesBannerDescription")}
|
||||
>
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/resources/public/resource-policies"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{t("resourcePoliciesBannerButtonText")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourcePoliciesBanner;
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type {
|
||||
AttachedResource,
|
||||
ListResourcePoliciesResponse
|
||||
} from "@server/routers/resource/types";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { ArrowRight, ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { Button } from "./ui/button";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import type { ExtendedColumnDef } from "./ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "./ui/dropdown-menu";
|
||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number];
|
||||
|
||||
export type ResourcePoliciesTableProps = {
|
||||
policies: Array<ResourcePolicyRow>;
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export function ResourcePoliciesTable({
|
||||
policies,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount
|
||||
}: ResourcePoliciesTableProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResourcePolicy, setSelectedResourcePolicy] =
|
||||
useState<ResourcePolicyRow | null>(null);
|
||||
|
||||
const deleteResourcePolicy = async (resourcePolicyId: number) => {
|
||||
await api
|
||||
.delete(`/resource-policy/${resourcePolicyId}`)
|
||||
.catch((e) => {
|
||||
console.error(t("resourceErrorDelte"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("resourceErrorDelte"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const refreshData = () => {
|
||||
startTransition(() => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function ResourceListCell({
|
||||
orgId,
|
||||
resources
|
||||
}: {
|
||||
orgId: string;
|
||||
resources?: AttachedResource[];
|
||||
}) {
|
||||
if (!resources || resources.length === 0) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const countLabel = t("resourcePoliciesAttachedResourcesCount", {
|
||||
count: resources.length
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||
>
|
||||
<span className="text-sm tabular-nums">
|
||||
{countLabel}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
{resources.map((resource) => (
|
||||
<DropdownMenuItem key={resource.resourceId} asChild>
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/public/${resource.niceId}`}
|
||||
className="flex cursor-pointer items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">
|
||||
{resource.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{resource.fullDomain}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const proxyColumns: ExtendedColumnDef<ResourcePolicyRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="p-3">{t("name")}</span>,
|
||||
cell: ({ row }) => <span>{row.original.name}</span>
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: () => <span className="p-3">{t("identifier")}</span>,
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.niceId || "-"}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "resources",
|
||||
accessorKey: "resources",
|
||||
friendlyName: t("resourcePoliciesAttachedResourcesColumnTitle"),
|
||||
header: () => (
|
||||
<span className="p-3">
|
||||
{t("resourcePoliciesAttachedResourcesColumnTitle")}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<ResourceListCell
|
||||
orgId={row.original.orgId}
|
||||
resources={row.original.resources}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const policyRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedResourcePolicy(policyRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({
|
||||
searchParams
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.ResourcePolicies]}
|
||||
/>
|
||||
{selectedResourcePolicy && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedResourcePolicy(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("resourcePolicyQuestionRemove")}</p>
|
||||
<p>{t("resourcePolicyMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourcePolicyDeleteConfirm")}
|
||||
onConfirm={async () =>
|
||||
deleteResourcePolicy(
|
||||
selectedResourcePolicy.resourcePolicyId
|
||||
)
|
||||
}
|
||||
string={selectedResourcePolicy.name}
|
||||
title={t("resourcePolicyDelete")}
|
||||
/>
|
||||
)}
|
||||
<ControlledDataTable
|
||||
columns={proxyColumns}
|
||||
rows={policies}
|
||||
tableId="resource-policies"
|
||||
searchPlaceholder={t("resourcePoliciesSearch")}
|
||||
pagination={pagination}
|
||||
rowCount={rowCount}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/policies/resources/public/create`
|
||||
)
|
||||
)
|
||||
}
|
||||
addButtonText={t("resourcePoliciesAdd")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
enableColumnVisibility
|
||||
columnVisibility={{ niceId: false }}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+187
-14
@@ -10,14 +10,25 @@ import {
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import {
|
||||
OptionSelect,
|
||||
type OptionSelectOption
|
||||
} from "@app/components/OptionSelect";
|
||||
import { TextFileImportDialog } from "@app/components/TextFileImportDialog";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
getTextImportFileType,
|
||||
isSupportedTextImportFile,
|
||||
parseTextFileItems,
|
||||
readFileAsText,
|
||||
type TextImportFileType
|
||||
} from "@app/lib/roleFormTextImport";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -46,15 +57,26 @@ function toSshSudoMode(value: string | null | undefined): SshSudoMode {
|
||||
return "none";
|
||||
}
|
||||
|
||||
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
|
||||
if (!value?.trim()) return true;
|
||||
export function parseUnixGroups(value: string | undefined): string[] {
|
||||
if (!value?.trim()) return [];
|
||||
|
||||
const commands = value
|
||||
.split(",")
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((group) => group.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseSudoCommands(value: string | undefined): string[] {
|
||||
if (!value?.trim()) return [];
|
||||
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((command) => command.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return commands.every((command) => {
|
||||
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
|
||||
return parseSudoCommands(value).every((command) => {
|
||||
const executable = command.split(/\s+/)[0];
|
||||
return executable.startsWith("/");
|
||||
});
|
||||
@@ -78,6 +100,15 @@ type RoleFormProps = {
|
||||
formId?: string;
|
||||
};
|
||||
|
||||
type RoleTextImportField = "sshSudoCommands" | "sshUnixGroups";
|
||||
|
||||
type PendingTextImport = {
|
||||
field: RoleTextImportField;
|
||||
fileName: string;
|
||||
fileType: TextImportFileType;
|
||||
rawContent: string;
|
||||
};
|
||||
|
||||
export function RoleForm({
|
||||
variant,
|
||||
role,
|
||||
@@ -125,10 +156,10 @@ export function RoleForm({
|
||||
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
|
||||
sshSudoMode: toSshSudoMode(role.sshSudoMode),
|
||||
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
|
||||
", "
|
||||
"\n"
|
||||
),
|
||||
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
|
||||
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
|
||||
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
|
||||
}
|
||||
: {
|
||||
name: "",
|
||||
@@ -156,17 +187,21 @@ export function RoleForm({
|
||||
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
|
||||
sshSudoMode: toSshSudoMode(role.sshSudoMode),
|
||||
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
|
||||
", "
|
||||
"\n"
|
||||
),
|
||||
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
|
||||
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
|
||||
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join("\n")
|
||||
});
|
||||
}
|
||||
}, [variant, role, form]);
|
||||
|
||||
const sshDisabled = !isPaidUser(tierMatrix.sshPam);
|
||||
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const sshSudoMode = form.watch("sshSudoMode");
|
||||
const isAdminRole = variant === "edit" && role?.isAdmin === true;
|
||||
const [pendingImport, setPendingImport] =
|
||||
useState<PendingTextImport | null>(null);
|
||||
const [dragOverField, setDragOverField] =
|
||||
useState<RoleTextImportField | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sshDisabled) {
|
||||
@@ -174,6 +209,78 @@ export function RoleForm({
|
||||
}
|
||||
}, [sshDisabled, form]);
|
||||
|
||||
async function handleFileDrop(
|
||||
file: File,
|
||||
field: RoleTextImportField
|
||||
): Promise<void> {
|
||||
if (!isSupportedTextImportFile(file)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("roleTextImportInvalidFile"),
|
||||
description: t("roleTextImportInvalidFileDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = getTextImportFileType(file);
|
||||
if (!fileType) return;
|
||||
|
||||
const rawContent = await readFileAsText(file);
|
||||
const parser =
|
||||
field === "sshSudoCommands" ? parseSudoCommands : parseUnixGroups;
|
||||
const items = parseTextFileItems({
|
||||
content: rawContent,
|
||||
fileType,
|
||||
skipHeader: false,
|
||||
parser
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("roleTextImportEmpty"),
|
||||
description: t("roleTextImportEmptyDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingImport({
|
||||
field,
|
||||
fileName: file.name,
|
||||
fileType,
|
||||
rawContent
|
||||
});
|
||||
}
|
||||
|
||||
function getTextImportDropHandlers(field: RoleTextImportField) {
|
||||
return {
|
||||
onDragOver: (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!sshDisabled) {
|
||||
setDragOverField(field);
|
||||
}
|
||||
},
|
||||
onDragLeave: (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
setDragOverField((current) =>
|
||||
current === field ? null : current
|
||||
);
|
||||
},
|
||||
onDrop: (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragOverField(null);
|
||||
if (sshDisabled) return;
|
||||
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
void handleFileDrop(file, field);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -319,7 +426,9 @@ export function RoleForm({
|
||||
{/* SSH tab - hidden when enterprise features are disabled */}
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowSsh"
|
||||
@@ -419,9 +528,25 @@ export function RoleForm({
|
||||
{t("sshSudoCommands")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
<Textarea
|
||||
{...field}
|
||||
{...getTextImportDropHandlers(
|
||||
"sshSudoCommands"
|
||||
)}
|
||||
placeholder={
|
||||
sshDisabled
|
||||
? undefined
|
||||
: t(
|
||||
"roleTextFieldPlaceholder"
|
||||
)
|
||||
}
|
||||
disabled={sshDisabled}
|
||||
className={cn(
|
||||
"h-20 min-h-20",
|
||||
dragOverField ===
|
||||
"sshSudoCommands" &&
|
||||
"border-primary"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -444,9 +569,25 @@ export function RoleForm({
|
||||
{t("sshUnixGroups")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
<Textarea
|
||||
{...field}
|
||||
{...getTextImportDropHandlers(
|
||||
"sshUnixGroups"
|
||||
)}
|
||||
placeholder={
|
||||
sshDisabled
|
||||
? undefined
|
||||
: t(
|
||||
"roleTextFieldPlaceholder"
|
||||
)
|
||||
}
|
||||
disabled={sshDisabled}
|
||||
className={cn(
|
||||
"h-20 min-h-20",
|
||||
dragOverField ===
|
||||
"sshUnixGroups" &&
|
||||
"border-primary"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -497,6 +638,38 @@ export function RoleForm({
|
||||
</HorizontalTabs>
|
||||
)}
|
||||
</form>
|
||||
{pendingImport && (
|
||||
<TextFileImportDialog
|
||||
key={`${pendingImport.field}-${pendingImport.fileName}`}
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingImport(null);
|
||||
}
|
||||
}}
|
||||
fileName={pendingImport.fileName}
|
||||
fileType={pendingImport.fileType}
|
||||
rawContent={pendingImport.rawContent}
|
||||
currentValue={form.watch(pendingImport.field) ?? ""}
|
||||
fieldLabel={
|
||||
pendingImport.field === "sshSudoCommands"
|
||||
? t("sshSudoCommands")
|
||||
: t("sshUnixGroups")
|
||||
}
|
||||
parser={
|
||||
pendingImport.field === "sshSudoCommands"
|
||||
? parseSudoCommands
|
||||
: parseUnixGroups
|
||||
}
|
||||
onConfirm={(value) => {
|
||||
form.setValue(pendingImport.field, value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
setPendingImport(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function SetResourceHeaderAuthForm({
|
||||
label={t(
|
||||
"headerAuthCompatibility"
|
||||
)}
|
||||
info={t(
|
||||
description={t(
|
||||
"headerAuthCompatibilityInfo"
|
||||
)}
|
||||
checked={field.value}
|
||||
|
||||
@@ -21,6 +21,29 @@ export function SettingsSectionHeader({
|
||||
}
|
||||
|
||||
export function SettingsSectionForm({
|
||||
children,
|
||||
className,
|
||||
variant = "compact"
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
variant?: "half" | "compact";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
variant === "half"
|
||||
? "max-w-3xl space-y-4"
|
||||
: "max-w-xl space-y-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsFormGrid({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
@@ -28,7 +51,38 @@ export function SettingsSectionForm({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("max-w-xl space-y-4", className)}>{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 md:grid-cols-4 gap-4 items-start",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsFormCell({
|
||||
children,
|
||||
span = "half",
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
span?: "quarter" | "half" | "full";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
span === "quarter" && "md:col-span-1",
|
||||
span === "half" && "md:col-span-2",
|
||||
span === "full" && "md:col-span-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +106,40 @@ export function SettingsSectionDescription({
|
||||
return <p className="text-muted-foreground text-sm">{children}</p>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionHeader({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("py-3 space-y-0.5", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionTitle({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <h3 className={cn("font-semibold", className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionDescription({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionBody({
|
||||
children
|
||||
}: {
|
||||
@@ -61,12 +149,19 @@ export function SettingsSectionBody({
|
||||
}
|
||||
|
||||
export function SettingsSectionFooter({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function ShareLinksTable({
|
||||
const r = row.original;
|
||||
return (
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
|
||||
href={`/${orgId}/settings/resources/public/${r.resourceNiceId}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{r.resourceName}
|
||||
@@ -328,9 +328,7 @@ export default function ShareLinksTable({
|
||||
onConfirm={async () =>
|
||||
deleteSharelink(selectedLink.accessTokenId)
|
||||
}
|
||||
string={
|
||||
selectedLink.title || selectedLink.resourceName
|
||||
}
|
||||
string={selectedLink.title || selectedLink.resourceName}
|
||||
title={t("shareDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from "./ui/sheet";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
|
||||
type BaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type RootSidePanelProps = BaseProps & {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type SidePanelProps = {
|
||||
className?: string;
|
||||
asChild?: true;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const desktop = "(min-width: 768px)";
|
||||
|
||||
const SidePanel = ({ children, ...props }: RootSidePanelProps) => {
|
||||
return <Sheet {...props}>{children}</Sheet>;
|
||||
};
|
||||
|
||||
const SidePanelTrigger = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SidePanelProps) => {
|
||||
return (
|
||||
<SheetTrigger className={className} {...props}>
|
||||
{children}
|
||||
</SheetTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<SheetClose className={className} {...props}>
|
||||
{children}
|
||||
</SheetClose>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SidePanelProps) => {
|
||||
const isDesktop = useMediaQuery(desktop);
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
className={cn(
|
||||
"fixed z-50 flex min-h-0 flex-col gap-4 overflow-hidden border bg-card px-6 pt-6 pb-1 shadow-lg transition ease-in-out",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||
"data-[state=closed]:duration-200 data-[state=open]:duration-300",
|
||||
isDesktop
|
||||
? "inset-y-0 right-0 h-full w-2/5 border-l"
|
||||
: "inset-x-0 bottom-0 max-h-[85dvh] w-full border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SidePanelProps) => {
|
||||
return (
|
||||
<SheetDescription className={className} {...props}>
|
||||
{children}
|
||||
</SheetDescription>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<SheetHeader
|
||||
className={cn("shrink-0 -mx-6 px-6", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SheetHeader>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<SheetTitle className={className} {...props}>
|
||||
{children}
|
||||
</SheetTitle>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="space-y-4">{children}</div>
|
||||
<div
|
||||
className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => {
|
||||
return (
|
||||
<SheetFooter
|
||||
className={cn(
|
||||
"-mt-4 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SheetFooter>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
SidePanel,
|
||||
SidePanelBody,
|
||||
SidePanelClose,
|
||||
SidePanelContent,
|
||||
SidePanelDescription,
|
||||
SidePanelFooter,
|
||||
SidePanelHeader,
|
||||
SidePanelTitle,
|
||||
SidePanelTrigger
|
||||
};
|
||||
@@ -554,7 +554,7 @@ export default function SignupForm({
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
href="https://pangolin.net/tos"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
@@ -567,7 +567,7 @@ export default function SignupForm({
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
href="https://pangolin.net/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
|
||||
type SiteInfoCardProps = {};
|
||||
|
||||
@@ -52,7 +53,11 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{formatPublicEndpoint(site.endpoint)}
|
||||
{formatPublicEndpoint(site.endpoint)}
|
||||
<span>
|
||||
{site.countryCode &&
|
||||
countryCodeToFlagEmoji(site.countryCode)}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null;
|
||||
|
||||
@@ -44,13 +44,13 @@ function isSafeUrlForLink(href: string): boolean {
|
||||
const OVERVIEW_META_CLASS = "w-full min-w-0 text-muted-foreground text-sm";
|
||||
|
||||
function publicProtocolLabel(r: PublicResourceRow): string {
|
||||
if (r.http) {
|
||||
if (r.mode == "http") {
|
||||
return r.ssl ? "HTTPS" : "HTTP";
|
||||
}
|
||||
const p = (r.protocol || "").toLowerCase();
|
||||
const p = (r.mode || "").toLowerCase();
|
||||
if (p === "tcp") return "TCP";
|
||||
if (p === "udp") return "UDP";
|
||||
return (r.protocol || "—").toUpperCase();
|
||||
return (r.mode || "—").toUpperCase();
|
||||
}
|
||||
|
||||
function PublicResourceMeta({ resource: r }: { resource: PublicResourceRow }) {
|
||||
@@ -68,12 +68,13 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
|
||||
const modeLabel: Record<SiteResourceRow["mode"], string> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
http: t("editInternalResourceDialogModeHttp")
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
};
|
||||
const dest = formatSiteResourceDestinationDisplay({
|
||||
mode: row.mode,
|
||||
destination: row.destination,
|
||||
httpHttpsPort: row.destinationPort ?? null,
|
||||
destinationPort: row.destinationPort ?? null,
|
||||
scheme: row.scheme
|
||||
});
|
||||
return (
|
||||
@@ -90,7 +91,7 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
|
||||
|
||||
function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) {
|
||||
const t = useTranslations();
|
||||
if (!r.http) {
|
||||
if (!["http", "ssh", "rdp", "vnc"].includes(r.mode || "")) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={r.proxyPort?.toString() ?? ""}
|
||||
@@ -149,7 +150,7 @@ function PrivateAccessMethod({ row }: { row: SiteResourceRow }) {
|
||||
const dest = formatSiteResourceDestinationDisplay({
|
||||
mode: row.mode,
|
||||
destination: row.destination,
|
||||
httpHttpsPort: row.destinationPort,
|
||||
destinationPort: row.destinationPort,
|
||||
scheme: row.scheme
|
||||
});
|
||||
return (
|
||||
@@ -420,15 +421,15 @@ export default function SiteResourcesOverview({
|
||||
publicList.length === 0 &&
|
||||
privateList.length === 0;
|
||||
|
||||
const publicViewAllHref = `/${orgId}/settings/resources/proxy?siteId=${siteId}`;
|
||||
const privateViewAllHref = `/${orgId}/settings/resources/client?siteId=${siteId}`;
|
||||
const publicViewAllHref = `/${orgId}/settings/resources/public?siteId=${siteId}`;
|
||||
const privateViewAllHref = `/${orgId}/settings/resources/private?siteId=${siteId}`;
|
||||
|
||||
const publicRows = publicList.map((r) => ({
|
||||
key: r.resourceId,
|
||||
meta: <PublicResourceMeta resource={r} />,
|
||||
name: r.name,
|
||||
access: <PublicAccessMethod resource={r} />,
|
||||
editHref: `/${orgId}/settings/resources/proxy/${r.niceId}`
|
||||
editHref: `/${orgId}/settings/resources/public/${r.niceId}`
|
||||
}));
|
||||
|
||||
const privateRows = privateList.map((row) => {
|
||||
@@ -441,7 +442,7 @@ export default function SiteResourcesOverview({
|
||||
meta: <PrivateResourceMeta row={row} />,
|
||||
name: row.name,
|
||||
access: <PrivateAccessMethod row={row} />,
|
||||
editHref: `/${orgId}/settings/resources/client?${qs.toString()}`
|
||||
editHref: `/${orgId}/settings/resources/private?${qs.toString()}`
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { CheckIcon, Funnel } from "lucide-react";
|
||||
import { SiteOnlineStatus, type Selectedsite } from "./site-selector";
|
||||
import { Button } from "./ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
|
||||
export type SitesColumnFilterButtonProps = {
|
||||
selectedSiteId: number | null;
|
||||
onValueChange: (value: number | undefined) => void;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export function SitesColumnFilterButton({
|
||||
selectedSiteId,
|
||||
onValueChange,
|
||||
orgId
|
||||
}: SitesColumnFilterButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 500
|
||||
})
|
||||
);
|
||||
|
||||
const selectedSite = useMemo(() => {
|
||||
let selected = undefined;
|
||||
if (selectedSiteId) {
|
||||
selected = sites.find((site) => site.siteId === selectedSiteId) ?? {
|
||||
siteId: Number(selectedSiteId),
|
||||
name: t("standaloneHcFilterSiteIdFallback", {
|
||||
id: Number(selectedSiteId)
|
||||
}),
|
||||
type: "newt"
|
||||
};
|
||||
}
|
||||
|
||||
return selected;
|
||||
}, [selectedSiteId, sites]);
|
||||
|
||||
// always include the selected site in the list of sites shown
|
||||
const sitesShown = useMemo(() => {
|
||||
const allSites: Array<Selectedsite> = [...sites];
|
||||
if (
|
||||
debouncedQuery.trim().length === 0 &&
|
||||
selectedSite &&
|
||||
!allSites.find((site) => site.siteId === selectedSite?.siteId)
|
||||
) {
|
||||
allSites.unshift(selectedSite);
|
||||
}
|
||||
return allSites;
|
||||
}, [debouncedQuery, sites, selectedSite]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||
selectedSite && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{t("sites")}
|
||||
<Funnel className="size-4 flex-none" />
|
||||
{selectedSite && (
|
||||
<Badge
|
||||
className="truncate max-w-40"
|
||||
variant="secondary"
|
||||
>
|
||||
{selectedSite.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("siteSearch")}
|
||||
value={siteSearchQuery}
|
||||
onValueChange={(v) => setSiteSearchQuery(v)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{selectedSite && (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
onValueChange(undefined);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{sitesShown.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
value={`${site.siteId}:${site.name}`}
|
||||
onSelect={() => {
|
||||
onValueChange(site.siteId);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
site.siteId === selectedSite?.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{site.name}
|
||||
</span>
|
||||
{site.online != null && (
|
||||
<SiteOnlineStatus
|
||||
type={site.type}
|
||||
online={site.online}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
+144
-75
@@ -19,6 +19,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
@@ -36,18 +37,12 @@ import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
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 +51,14 @@ 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";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { productUpdatesQueries } from "@app/lib/queries";
|
||||
import semver from "semver";
|
||||
|
||||
export type SiteRow = {
|
||||
id: number;
|
||||
@@ -109,7 +105,9 @@ export default function SitesTable({
|
||||
} = useNavigationContext();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deleteWithResources, setDeleteWithResources] = useState(false);
|
||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||
const [restartingSite, setRestartingSite] = useState<SiteRow | null>(null);
|
||||
const [resourcesDialogSite, setResourcesDialogSite] =
|
||||
useState<SiteRow | null>(null);
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
@@ -121,12 +119,11 @@ export default function SitesTable({
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
// useEffect(() => {
|
||||
// const interval = setInterval(() => {
|
||||
// router.refresh();
|
||||
// }, 30_000);
|
||||
// return () => clearInterval(interval);
|
||||
// }, []);
|
||||
const { data: latestVersions } = useQuery(
|
||||
productUpdatesQueries.latestVersion(true)
|
||||
);
|
||||
|
||||
const latestNewtVersion = latestVersions?.data?.newt?.latestVersion;
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
@@ -163,10 +160,30 @@ export default function SitesTable({
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSite(siteId: number) {
|
||||
async function restartSite(siteId: number) {
|
||||
try {
|
||||
await api.post(`/site/${siteId}/restart`);
|
||||
toast({
|
||||
title: t("siteRestarted"),
|
||||
description: t("siteRestartedDescription")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorRestart"),
|
||||
description: formatAxiosError(e, t("siteErrorRestartDescription"))
|
||||
});
|
||||
} finally {
|
||||
setRestartingSite(null);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSite(siteId: number, withResources: boolean) {
|
||||
startTransition(async () => {
|
||||
await api
|
||||
.delete(`/site/${siteId}`)
|
||||
.delete(`/site/${siteId}`, {
|
||||
params: { deleteResources: withResources }
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(t("siteErrorDelete"), e);
|
||||
toast({
|
||||
@@ -341,6 +358,11 @@ export default function SitesTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
let updateAvailable =
|
||||
latestNewtVersion &&
|
||||
originalRow.newtVersion &&
|
||||
semver.lt(originalRow.newtVersion, latestNewtVersion);
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
@@ -354,7 +376,7 @@ export default function SitesTable({
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.newtUpdateAvailable && (
|
||||
{updateAvailable && (
|
||||
<InfoPopup
|
||||
info={t("newtUpdateAvailableInfo")}
|
||||
/>
|
||||
@@ -506,7 +528,7 @@ export default function SitesTable({
|
||||
</Link>
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${siteRow.orgId}/settings/resources/proxy?siteId=${siteRow.id}`}
|
||||
href={`/${siteRow.orgId}/settings/resources/public?siteId=${siteRow.id}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("sitesTableViewPublicResources")}
|
||||
@@ -514,7 +536,7 @@ export default function SitesTable({
|
||||
</Link>
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`}
|
||||
href={`/${siteRow.orgId}/settings/resources/private?siteId=${siteRow.id}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t(
|
||||
@@ -522,6 +544,47 @@ export default function SitesTable({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuSeparator />
|
||||
{siteRow.type === "newt" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setRestartingSite(siteRow)
|
||||
}
|
||||
>
|
||||
<span className="text-orange-500">
|
||||
{t("siteRestartButton")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setDeleteWithResources(false);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("sitesTableDeleteSite")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{siteRow.resourceCount <= 250 && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setDeleteWithResources(true);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t(
|
||||
"sitesTableDeleteSiteAndResources"
|
||||
)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
@@ -559,7 +622,7 @@ export default function SitesTable({
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||
}, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]);
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
@@ -624,25 +687,66 @@ export default function SitesTable({
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{restartingSite && (
|
||||
<ConfirmDeleteDialog
|
||||
open={Boolean(restartingSite)}
|
||||
setOpen={(val) => {
|
||||
if (!val) setRestartingSite(null);
|
||||
}}
|
||||
dialog={
|
||||
<p>
|
||||
{t.rich("siteRestartDialogMessage", {
|
||||
name: restartingSite.name,
|
||||
b: (chunks) => <b>{chunks}</b>
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
buttonText={t("siteRestartButton")}
|
||||
onConfirm={() => restartSite(restartingSite.id)}
|
||||
string={restartingSite.name}
|
||||
warningText={t("siteRestartWarning")}
|
||||
title={t("siteRestartTitle")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedSite && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedSite(null);
|
||||
setDeleteWithResources(false);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("siteQuestionRemove")}</p>
|
||||
<p>{t("siteMessageRemove")}</p>
|
||||
<p>
|
||||
{deleteWithResources
|
||||
? t("siteQuestionRemoveAndResources")
|
||||
: t("siteQuestionRemove")}
|
||||
</p>
|
||||
<p>
|
||||
{deleteWithResources
|
||||
? t("siteMessageRemoveAndResources")
|
||||
: t("siteMessageRemove")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("siteConfirmDelete")}
|
||||
buttonText={
|
||||
deleteWithResources
|
||||
? t("siteConfirmDeleteAndResources")
|
||||
: t("siteConfirmDelete")
|
||||
}
|
||||
onConfirm={async () =>
|
||||
startTransition(() => deleteSite(selectedSite!.id))
|
||||
startTransition(() =>
|
||||
deleteSite(selectedSite!.id, deleteWithResources)
|
||||
)
|
||||
}
|
||||
string={selectedSite.name}
|
||||
title={t("siteDelete")}
|
||||
title={
|
||||
deleteWithResources
|
||||
? t("siteDeleteAndResources")
|
||||
: t("siteDelete")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -670,7 +774,7 @@ export default function SitesTable({
|
||||
nice: false,
|
||||
exitNode: false,
|
||||
address: false,
|
||||
labels: false
|
||||
labels: true
|
||||
}}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
@@ -686,54 +790,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 (
|
||||
<TableLabelsCell
|
||||
<LabelsTableCell
|
||||
orgId={orgId}
|
||||
localLabels={localLabels}
|
||||
toggleLabel={toggleSiteLabel}
|
||||
selectedLabels={localLabels}
|
||||
onToggleLabel={toggleLabel}
|
||||
onClosePopover={() => startTransition(refresh)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,11 +25,15 @@ export function StrategySelect<TValue extends string>({
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
onChange,
|
||||
cols
|
||||
cols = 1
|
||||
}: StrategySelectProps<TValue>) {
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<
|
||||
TValue | undefined
|
||||
>(defaultValue);
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
|
||||
const selected = isControlled
|
||||
? (controlledValue ?? undefined)
|
||||
: uncontrolledSelected;
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
@@ -39,7 +43,11 @@ export function StrategySelect<TValue extends string>({
|
||||
if (!isControlled) setUncontrolledSelected(typedValue);
|
||||
onChange?.(typedValue);
|
||||
}}
|
||||
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
|
||||
style={{
|
||||
// @ts-expect-error
|
||||
"--cols": `repeat(${cols}, 1fr)`
|
||||
}}
|
||||
className="grid md:grid-cols-(--cols) gap-4"
|
||||
>
|
||||
{options.map((option: StrategyOption<TValue>) => (
|
||||
<label
|
||||
|
||||
@@ -4,20 +4,23 @@ import { Button } from "@app/components/ui/button";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function SubscriptionViolation() {
|
||||
interface SubscriptionViolationProps {
|
||||
billingHref?: string | null;
|
||||
canViewBilling?: boolean;
|
||||
}
|
||||
|
||||
export default function SubscriptionViolation({
|
||||
billingHref,
|
||||
canViewBilling = false
|
||||
}: SubscriptionViolationProps) {
|
||||
const context = useSubscriptionStatusContext();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const params = useParams();
|
||||
const orgId = params?.orgId as string | undefined;
|
||||
const t = useTranslations();
|
||||
|
||||
if (!context?.limitsExceeded || isDismissed) return null;
|
||||
|
||||
const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 w-full bg-amber-600 text-white p-4 text-center z-50">
|
||||
<div className="flex flex-wrap justify-center items-center gap-2 sm:gap-4">
|
||||
@@ -25,16 +28,18 @@ export default function SubscriptionViolation() {
|
||||
{t("subscriptionViolationMessage")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="bg-white/20 hover:bg-white/30 text-white border-0"
|
||||
asChild
|
||||
>
|
||||
<Link href={billingHref}>
|
||||
{t("subscriptionViolationViewBilling")}
|
||||
</Link>
|
||||
</Button>
|
||||
{canViewBilling && billingHref && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="bg-white/20 hover:bg-white/30 text-white border-0"
|
||||
asChild
|
||||
>
|
||||
<Link href={billingHref}>
|
||||
{t("subscriptionViolationViewBilling")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -9,33 +9,34 @@ 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();
|
||||
<></>
|
||||
// <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>
|
||||
// // 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>
|
||||
);
|
||||
}
|
||||
|
||||
+117
-119
@@ -1,14 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
// THIS IS DEPRECATED AND IS NO LONGER SHOWED TO THE USER WITH THE DISCONTINUATION
|
||||
// OF THE SUPPORTER PROGRAM. IT MAY BE REMOVED IN A FUTURE UPDATE.
|
||||
|
||||
// import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -44,7 +40,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ValidateSupporterKeyResponse } from "@server/routers/supporterKey";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -63,134 +58,134 @@ interface SupporterStatusProps {
|
||||
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 { supporterStatus, updateSupporterStatus } =
|
||||
// useSupporterStatusContext();
|
||||
// const [supportOpen, setSupportOpen] = useState(false);
|
||||
// const [keyOpen, setKeyOpen] = useState(false);
|
||||
// const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
// const { env } = useEnvContext();
|
||||
// const api = createApiClient({ env });
|
||||
// const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
githubUsername: z.string().nonempty({
|
||||
error: "GitHub username is required"
|
||||
}),
|
||||
key: z.string().nonempty({
|
||||
error: "Supporter key is required"
|
||||
})
|
||||
});
|
||||
// const formSchema = z.object({
|
||||
// githubUsername: z.string().nonempty({
|
||||
// error: "GitHub username is required"
|
||||
// }),
|
||||
// key: z.string().nonempty({
|
||||
// error: "Supporter key is required"
|
||||
// })
|
||||
// });
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
githubUsername: "",
|
||||
key: ""
|
||||
}
|
||||
});
|
||||
// const form = useForm({
|
||||
// resolver: zodResolver(formSchema),
|
||||
// defaultValues: {
|
||||
// githubUsername: "",
|
||||
// key: ""
|
||||
// }
|
||||
// });
|
||||
|
||||
async function hide() {
|
||||
await api.post("/supporter-key/hide");
|
||||
// async function hide() {
|
||||
// await api.post("/supporter-key/hide");
|
||||
|
||||
updateSupporterStatus({
|
||||
visible: false
|
||||
});
|
||||
}
|
||||
// updateSupporterStatus({
|
||||
// visible: false
|
||||
// });
|
||||
// }
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<ValidateSupporterKeyResponse>
|
||||
>("/supporter-key/validate", {
|
||||
githubUsername: values.githubUsername,
|
||||
key: values.key
|
||||
});
|
||||
// async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
// try {
|
||||
// const res = await api.post<
|
||||
// AxiosResponse<ValidateSupporterKeyResponse>
|
||||
// >("/supporter-key/validate", {
|
||||
// githubUsername: values.githubUsername,
|
||||
// key: values.key
|
||||
// });
|
||||
|
||||
const data = res.data.data;
|
||||
// const data = res.data.data;
|
||||
|
||||
if (!data || !data.valid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("supportKeyInvalid"),
|
||||
description: t("supportKeyInvalidDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
// if (!data || !data.valid) {
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: t("supportKeyInvalid"),
|
||||
// description: t("supportKeyInvalidDescription")
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Trigger the toast
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("supportKeyValid"),
|
||||
description: t("supportKeyValidDescription")
|
||||
});
|
||||
// // Trigger the toast
|
||||
// toast({
|
||||
// variant: "default",
|
||||
// title: t("supportKeyValid"),
|
||||
// description: t("supportKeyValidDescription")
|
||||
// });
|
||||
|
||||
// Fireworks-style confetti
|
||||
const duration = 5 * 1000; // 5 seconds
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = {
|
||||
startVelocity: 30,
|
||||
spread: 360,
|
||||
ticks: 60,
|
||||
zIndex: 0,
|
||||
colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
|
||||
};
|
||||
// // Fireworks-style confetti
|
||||
// const duration = 5 * 1000; // 5 seconds
|
||||
// const animationEnd = Date.now() + duration;
|
||||
// const defaults = {
|
||||
// startVelocity: 30,
|
||||
// spread: 360,
|
||||
// ticks: 60,
|
||||
// zIndex: 0,
|
||||
// colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
|
||||
// };
|
||||
|
||||
function randomInRange(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
// function randomInRange(min: number, max: number) {
|
||||
// return Math.random() * (max - min) + min;
|
||||
// }
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
// const interval = setInterval(() => {
|
||||
// const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
// if (timeLeft <= 0) {
|
||||
// clearInterval(interval);
|
||||
// return;
|
||||
// }
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
// const particleCount = 50 * (timeLeft / duration);
|
||||
|
||||
// Launch confetti from two random horizontal positions
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: {
|
||||
x: randomInRange(0.1, 0.3),
|
||||
y: Math.random() - 0.2
|
||||
}
|
||||
});
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: {
|
||||
x: randomInRange(0.7, 0.9),
|
||||
y: Math.random() - 0.2
|
||||
}
|
||||
});
|
||||
}, 250);
|
||||
// // Launch confetti from two random horizontal positions
|
||||
// confetti({
|
||||
// ...defaults,
|
||||
// particleCount,
|
||||
// origin: {
|
||||
// x: randomInRange(0.1, 0.3),
|
||||
// y: Math.random() - 0.2
|
||||
// }
|
||||
// });
|
||||
// confetti({
|
||||
// ...defaults,
|
||||
// particleCount,
|
||||
// origin: {
|
||||
// x: randomInRange(0.7, 0.9),
|
||||
// y: Math.random() - 0.2
|
||||
// }
|
||||
// });
|
||||
// }, 250);
|
||||
|
||||
setPurchaseOptionsOpen(false);
|
||||
setKeyOpen(false);
|
||||
// setPurchaseOptionsOpen(false);
|
||||
// setKeyOpen(false);
|
||||
|
||||
updateSupporterStatus({
|
||||
visible: false
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("error"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("supportKeyErrorValidationDescription")
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// updateSupporterStatus({
|
||||
// visible: false
|
||||
// });
|
||||
// } catch (error) {
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: t("error"),
|
||||
// description: formatAxiosError(
|
||||
// error,
|
||||
// t("supportKeyErrorValidationDescription")
|
||||
// )
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
{/* <Credenza
|
||||
open={purchaseOptionsOpen}
|
||||
onOpenChange={(val) => {
|
||||
setPurchaseOptionsOpen(val);
|
||||
@@ -228,7 +223,10 @@ export default function SupporterStatus({
|
||||
|
||||
<div className="my-4 p-4 border border-blue-500/50 bg-blue-500/10 rounded-lg">
|
||||
<p className="text-sm">
|
||||
<strong>Business & Enterprise Users:</strong> For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "}
|
||||
<strong>Business & Enterprise Users:</strong>{" "}
|
||||
For larger organizations or teams requiring
|
||||
advanced features, consider our self-serve
|
||||
enterprise license and Enterprise Edition.{" "}
|
||||
<Link
|
||||
href="https://pangolin.net/pricing#Self-Hosted"
|
||||
target="_blank"
|
||||
@@ -471,7 +469,7 @@ export default function SupporterStatus({
|
||||
{t("supportKeyBuy")}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
) : null} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,9 +43,18 @@ export function SwitchInput({
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
{label && <Label htmlFor={id}>{label}</Label>}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={
|
||||
disabled ? "opacity-50 cursor-not-allowed" : ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<Switch
|
||||
id={id}
|
||||
checked={checked}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
OptionSelect,
|
||||
type OptionSelectOption
|
||||
} from "@app/components/OptionSelect";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import {
|
||||
applyTextImport,
|
||||
parsePreviewLines,
|
||||
parseTextFileItems,
|
||||
type TextImportFileType,
|
||||
type TextImportMode
|
||||
} from "@app/lib/roleFormTextImport";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type TextFileImportDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileName: string;
|
||||
fileType: TextImportFileType;
|
||||
rawContent: string;
|
||||
currentValue: string;
|
||||
fieldLabel: string;
|
||||
parser: (value: string | undefined) => string[];
|
||||
onConfirm: (value: string) => void;
|
||||
};
|
||||
|
||||
export function TextFileImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
fileType,
|
||||
rawContent,
|
||||
currentValue,
|
||||
fieldLabel,
|
||||
parser,
|
||||
onConfirm
|
||||
}: TextFileImportDialogProps) {
|
||||
const t = useTranslations();
|
||||
const [editablePreview, setEditablePreview] = useState("");
|
||||
const [skipHeader, setSkipHeader] = useState(false);
|
||||
const [mode, setMode] = useState<TextImportMode>("override");
|
||||
|
||||
const parsedFromFile = useMemo(
|
||||
() =>
|
||||
parseTextFileItems({
|
||||
content: rawContent,
|
||||
fileType,
|
||||
skipHeader,
|
||||
parser
|
||||
}),
|
||||
[rawContent, fileType, skipHeader, parser]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditablePreview(parsedFromFile.join("\n"));
|
||||
}, [parsedFromFile]);
|
||||
|
||||
const importedItems = useMemo(
|
||||
() => parsePreviewLines(editablePreview),
|
||||
[editablePreview]
|
||||
);
|
||||
|
||||
const existingCount = useMemo(
|
||||
() => parser(currentValue).length,
|
||||
[currentValue, parser]
|
||||
);
|
||||
|
||||
const totalCount =
|
||||
mode === "append"
|
||||
? existingCount + importedItems.length
|
||||
: importedItems.length;
|
||||
|
||||
const modeOptions: OptionSelectOption<TextImportMode>[] = [
|
||||
{
|
||||
value: "override",
|
||||
label: t("roleTextImportOverride")
|
||||
},
|
||||
{
|
||||
value: "append",
|
||||
label: t("roleTextImportAppend")
|
||||
}
|
||||
];
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm(
|
||||
applyTextImport({
|
||||
currentValue,
|
||||
imported: importedItems,
|
||||
mode,
|
||||
parser
|
||||
})
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("roleTextImportTitle")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("roleTextImportDescription", {
|
||||
fileName,
|
||||
fieldLabel
|
||||
})}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
|
||||
<CredenzaBody>
|
||||
{fileType === "csv" && (
|
||||
<CheckboxWithLabel
|
||||
checked={skipHeader}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked !== "indeterminate") {
|
||||
setSkipHeader(checked);
|
||||
}
|
||||
}}
|
||||
label={t("roleTextImportSkipHeader")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("roleTextImportPreview")}
|
||||
</p>
|
||||
<Textarea
|
||||
value={editablePreview}
|
||||
onChange={(event) =>
|
||||
setEditablePreview(event.target.value)
|
||||
}
|
||||
placeholder={t("roleTextImportEmpty")}
|
||||
className="min-h-32 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OptionSelect<TextImportMode>
|
||||
label={t("roleTextImportMode")}
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
cols={2}
|
||||
/>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{mode === "append"
|
||||
? t("roleTextImportTotalCount", {
|
||||
existing: existingCount,
|
||||
imported: importedItems.length,
|
||||
total: totalCount
|
||||
})
|
||||
: t("roleTextImportItemCount", {
|
||||
count: importedItems.length
|
||||
})}
|
||||
</p>
|
||||
</CredenzaBody>
|
||||
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
{t("close")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={importedItems.length === 0}
|
||||
>
|
||||
{t("roleTextImportConfirm")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,12 @@ import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import IdpTypeBadge from "./IdpTypeBadge";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import {
|
||||
productUpdatesQueries,
|
||||
type LatestVersionResponse
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -100,6 +106,9 @@ export default function UserDevicesTable({
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
||||
|
||||
const latestPlatformVersions = data.data?.data;
|
||||
|
||||
const defaultUserColumnVisibility = {
|
||||
subnet: false,
|
||||
@@ -555,6 +564,37 @@ export default function UserDevicesTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
const agentVersionMap: Record<string, string> = {
|
||||
"Pangolin Windows": "windows",
|
||||
"Pangolin Android": "android",
|
||||
"Pangolin iOS": "ios",
|
||||
"Pangolin iPadOS": "ios",
|
||||
"Pangolin macOS": "mac",
|
||||
"Pangolin CLI": "cli",
|
||||
"Olm CLI": "olm"
|
||||
};
|
||||
|
||||
let updateAvailable = false;
|
||||
|
||||
if (
|
||||
originalRow.olmVersion &&
|
||||
originalRow.agent &&
|
||||
latestPlatformVersions
|
||||
) {
|
||||
const agent = agentVersionMap[
|
||||
originalRow.agent
|
||||
] as keyof LatestVersionResponse;
|
||||
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{originalRow.agent && originalRow.olmVersion ? (
|
||||
@@ -567,9 +607,9 @@ export default function UserDevicesTable({
|
||||
"-"
|
||||
)}
|
||||
|
||||
{/*originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)*/}
|
||||
{updateAvailable && (
|
||||
<InfoPopup info={t("updateAvailableInfo")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -714,7 +754,7 @@ export default function UserDevicesTable({
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
}, [t]);
|
||||
}, [t, latestPlatformVersions]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from "./ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const labelBadgeClassName =
|
||||
"inline-flex h-auto items-center gap-1 rounded-full border border-input bg-background py-0 pl-1.5 pr-2 text-sm shadow-xs";
|
||||
"inline-flex h-auto items-center gap-1 rounded-full border border-input bg-background py-0 pl-1.5 pr-2 text-sm";
|
||||
|
||||
export type LabelBadgeProps = {
|
||||
name: string;
|
||||
|
||||
@@ -9,7 +9,7 @@ export type LabelOverflowItem = {
|
||||
};
|
||||
|
||||
const labelOverflowBadgeClassName =
|
||||
"inline-flex h-auto shrink-0 items-center gap-1.5 rounded-full border border-input bg-background py-0 pl-1.5 pr-2 text-sm shadow-xs";
|
||||
"inline-flex h-auto shrink-0 items-center gap-1.5 rounded-full border border-input bg-background py-0 pl-1.5 pr-2 text-sm";
|
||||
|
||||
export type LabelOverflowBadgeProps = {
|
||||
labels: LabelOverflowItem[];
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useActionState, useMemo, useRef, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Button } from "./ui/button";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
@@ -36,9 +36,23 @@ export type LabelsSelectorProps = {
|
||||
orgId: string;
|
||||
selectedLabels: SelectedLabel[];
|
||||
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export function formatLabelsSelectorLabel(
|
||||
selectedLabels: SelectedLabel[],
|
||||
t: (key: string, values?: { count: number }) => string
|
||||
): string {
|
||||
if (selectedLabels.length === 0) {
|
||||
return t("selectLabels");
|
||||
}
|
||||
if (selectedLabels.length === 1) {
|
||||
return selectedLabels[0]!.name;
|
||||
}
|
||||
return t("labelsSelectorLabelsCount", {
|
||||
count: selectedLabels.length
|
||||
});
|
||||
}
|
||||
|
||||
export const LABEL_COLORS = {
|
||||
red: "#ff6467",
|
||||
green: "#05df72",
|
||||
@@ -52,8 +66,7 @@ export const LABEL_COLORS = {
|
||||
export function LabelsSelector({
|
||||
orgId,
|
||||
selectedLabels,
|
||||
toggleLabel,
|
||||
onClose
|
||||
toggleLabel
|
||||
}: LabelsSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||
@@ -90,6 +103,11 @@ export function LabelsSelector({
|
||||
colorValues[Math.floor(Math.random() * colorValues.length)];
|
||||
|
||||
const [, action, isPending] = useActionState(createLabel, null);
|
||||
const createFormRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const trimmedQuery = labelSearchQuery.trim();
|
||||
const canCreateLabel =
|
||||
trimmedQuery.length > 0 && labelsShown.length === 0 && !isPending;
|
||||
|
||||
async function createLabel(_: any, formData: FormData) {
|
||||
const name = formData.get("name")?.toString();
|
||||
@@ -109,12 +127,20 @@ export function LabelsSelector({
|
||||
},
|
||||
"attach"
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred")),
|
||||
variant: "destructive"
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
title: t("labelDuplicateError"),
|
||||
description: t("labelDuplicateErrorDescription"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e, t("errorOccurred")),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}
|
||||
setlabelsSearchQuery("");
|
||||
}
|
||||
@@ -122,21 +148,28 @@ export function LabelsSelector({
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("labelSearch")}
|
||||
placeholder={t("labelSearchOrCreate")}
|
||||
value={labelSearchQuery}
|
||||
onValueChange={setlabelsSearchQuery}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && canCreateLabel) {
|
||||
e.preventDefault();
|
||||
createFormRef.current?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="px-3 break-all wrap-anywhere text-wrap">
|
||||
<CommandEmpty className="px-3 py-6 text-center text-wrap">
|
||||
{labelSearchQuery.trim().length > 0 ? (
|
||||
<div className="flex flex-col gap-2 items-center">
|
||||
<span className="max-w-34">
|
||||
<span className="max-w-34 break-words">
|
||||
{t("createNewLabel", {
|
||||
label: labelSearchQuery.trim()
|
||||
})}
|
||||
</span>
|
||||
|
||||
<form
|
||||
ref={createFormRef}
|
||||
action={action}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
@@ -147,7 +180,7 @@ export function LabelsSelector({
|
||||
/>
|
||||
|
||||
<Select defaultValue={randomColor} name="color">
|
||||
<SelectTrigger className="w-18 [&_[data-name]]:hidden [&_[svg]]:hidden!">
|
||||
<SelectTrigger className="w-auto min-w-24">
|
||||
<SelectValue
|
||||
placeholder={t("selectColor")}
|
||||
/>
|
||||
@@ -161,14 +194,17 @@ export function LabelsSelector({
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="size-4 rounded-full bg-(--color) flex-none"
|
||||
className="size-2 rounded-full bg-(--color) flex-none"
|
||||
style={{
|
||||
// @ts-expect-error css color
|
||||
"--color": value
|
||||
}}
|
||||
/>
|
||||
<span data-name>
|
||||
{color}
|
||||
{color
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
color.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
@@ -178,7 +214,6 @@ export function LabelsSelector({
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isPending}
|
||||
type="submit"
|
||||
>
|
||||
@@ -187,7 +222,14 @@ export function LabelsSelector({
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
t("labelsNotFound")
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<span className="text-muted-foreground">
|
||||
{t("labelsNotFound")}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{t("labelsEmptyCreateHint")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
@@ -202,7 +244,6 @@ export function LabelsSelector({
|
||||
? "detach"
|
||||
: "attach"
|
||||
);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
|
||||
@@ -10,8 +10,14 @@ import {
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
|
||||
export type TagValue = { text: string; id: string; isAdmin?: boolean };
|
||||
export type TagValue = {
|
||||
text: string;
|
||||
id: string;
|
||||
isAdmin?: boolean;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export type MultiSelectTagsProps<T extends TagValue> = {
|
||||
emptyPlaceholder?: string;
|
||||
@@ -23,6 +29,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
|
||||
onSearch: (query: string) => void;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
lockedIds?: Set<string>;
|
||||
};
|
||||
|
||||
export function MultiSelectContent<T extends TagValue>({
|
||||
@@ -32,7 +39,8 @@ export function MultiSelectContent<T extends TagValue>({
|
||||
value,
|
||||
options,
|
||||
onSearch,
|
||||
onChange
|
||||
onChange,
|
||||
lockedIds
|
||||
}: MultiSelectTagsProps<T>) {
|
||||
const t = useTranslations();
|
||||
const selectedValues = new Set(value.map((v) => v.id));
|
||||
@@ -48,33 +56,44 @@ export function MultiSelectContent<T extends TagValue>({
|
||||
{emptyPlaceholder ?? t("noResults")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
value={option.id}
|
||||
key={option.id}
|
||||
onSelect={() => {
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [...value, option];
|
||||
}
|
||||
onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValues.has(option.id)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
{options.map((option) => {
|
||||
const isLocked = lockedIds?.has(option.id);
|
||||
return (
|
||||
<CommandItem
|
||||
value={option.id}
|
||||
key={option.id}
|
||||
disabled={isLocked}
|
||||
onSelect={() => {
|
||||
if (isLocked) return;
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [...value, option];
|
||||
}
|
||||
onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={selectedValues.has(option.id)}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{option.color && (
|
||||
<span
|
||||
className="size-2 rounded-full flex-none"
|
||||
style={{
|
||||
backgroundColor: option.color
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{`${option.text}`}
|
||||
</CommandItem>
|
||||
))}
|
||||
{`${option.text}`}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, LockIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
type MultiSelectTagsProps,
|
||||
type TagValue,
|
||||
@@ -16,10 +16,12 @@ export interface MultiSelectInputProps<
|
||||
T extends TagValue
|
||||
> extends MultiSelectTagsProps<T> {
|
||||
buttonText?: string;
|
||||
lockedIds?: Set<string>;
|
||||
}
|
||||
|
||||
export function MultiSelectTagInput<T extends TagValue>({
|
||||
buttonText,
|
||||
lockedIds,
|
||||
...props
|
||||
}: MultiSelectInputProps<T>) {
|
||||
const selectedValues = new Set(props.value.map((v) => v.id));
|
||||
@@ -40,60 +42,85 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
buttonVariants({
|
||||
variant: "outline"
|
||||
}),
|
||||
"justify-between w-full inline-flex",
|
||||
"text-muted-foreground pl-1.5 cursor-text h-auto py-1",
|
||||
"justify-between w-full flex items-center",
|
||||
"text-muted-foreground pl-1.5 cursor-text h-auto min-h-9 py-1.5",
|
||||
"whitespace-normal",
|
||||
"hover:bg-transparent hover:text-muted-foreground",
|
||||
props.disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
"overflow-x-auto flex-wrap h-auto"
|
||||
"flex items-center gap-1 min-w-0 flex-1 flex-wrap"
|
||||
)}
|
||||
>
|
||||
{props.value.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
|
||||
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="max-w-40 text-ellipsis overflow-hidden">
|
||||
{option.text}
|
||||
</span>
|
||||
<button
|
||||
className="p-0.5 flex-none cursor-pointer"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = props.value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [
|
||||
...props.value,
|
||||
option
|
||||
];
|
||||
}
|
||||
props.onChange(newValues);
|
||||
}}
|
||||
{props.value.map((option) => {
|
||||
const isLocked = lockedIds?.has(option.id);
|
||||
return (
|
||||
<span
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm shrink-0",
|
||||
"py-0.5 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5 whitespace-nowrap",
|
||||
isLocked && "opacity-60"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{option.color && (
|
||||
<span
|
||||
className="size-2 rounded-full flex-none ml-1"
|
||||
style={{
|
||||
backgroundColor: option.color
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-40 text-ellipsis overflow-hidden">
|
||||
{option.text}
|
||||
</span>
|
||||
{isLocked ? (
|
||||
<span className="p-0.5 flex-none">
|
||||
<LockIcon className="size-3" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="p-0.5 flex-none cursor-pointer"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
let newValues = [];
|
||||
if (
|
||||
selectedValues.has(
|
||||
option.id
|
||||
)
|
||||
) {
|
||||
newValues =
|
||||
props.value.filter(
|
||||
(v) =>
|
||||
v.id !==
|
||||
option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [
|
||||
...props.value,
|
||||
option
|
||||
];
|
||||
}
|
||||
props.onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="pl-1 font-normal">{buttonText}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<MultiSelectContent {...props} />
|
||||
<MultiSelectContent {...props} lockedIds={lockedIds} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -19,6 +19,9 @@ export type MultiSitesSelectorProps = {
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectionChange: (sites: Selectedsite[]) => void;
|
||||
filterTypes?: string[];
|
||||
scope?: "org" | "launcher";
|
||||
onClear?: () => void;
|
||||
showClear?: boolean;
|
||||
};
|
||||
|
||||
export function formatMultiSitesSelectorLabel(
|
||||
@@ -40,19 +43,35 @@ export function MultiSitesSelector({
|
||||
orgId,
|
||||
selectedSites,
|
||||
onSelectionChange,
|
||||
filterTypes
|
||||
filterTypes,
|
||||
scope = "org",
|
||||
onClear,
|
||||
showClear = false
|
||||
}: MultiSitesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
orgQueries.sites({
|
||||
const orgSitesQuery = useQuery({
|
||||
...orgQueries.sites({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 10
|
||||
})
|
||||
);
|
||||
}),
|
||||
enabled: scope === "org"
|
||||
});
|
||||
const launcherSitesQuery = useQuery({
|
||||
...launcherQueries.sites({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 20
|
||||
}),
|
||||
enabled: scope === "launcher"
|
||||
});
|
||||
const sites =
|
||||
scope === "launcher"
|
||||
? (launcherSitesQuery.data ?? [])
|
||||
: (orgSitesQuery.data ?? []);
|
||||
|
||||
const sitesShown = useMemo(() => {
|
||||
const base = filterTypes
|
||||
@@ -92,6 +111,14 @@ export function MultiSitesSelector({
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showClear && onClear && (
|
||||
<CommandItem
|
||||
onSelect={onClear}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{sitesShown.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
@@ -115,7 +142,6 @@ export function MultiSitesSelector({
|
||||
<SiteOnlineStatus
|
||||
type={site.type}
|
||||
online={site.online}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,17 @@ import {
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||
import { useState } from "react";
|
||||
import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
|
||||
import {
|
||||
FaApple,
|
||||
FaCubes,
|
||||
FaDocker,
|
||||
FaHdd,
|
||||
FaLinux,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export type CommandItem = string | { title: string; command: string };
|
||||
|
||||
@@ -20,6 +29,7 @@ const PLATFORMS = [
|
||||
"macos",
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"advantech",
|
||||
"podman",
|
||||
"nixos",
|
||||
"windows"
|
||||
@@ -41,13 +51,20 @@ export function NewtSiteInstallCommands({
|
||||
version = "latest"
|
||||
}: NewtSiteInstallCommandsProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const [acceptClients, setAcceptClients] = useState(true);
|
||||
const [allowPangolinSsh, setAllowPangolinSsh] = useState(
|
||||
!env.flags.disableEnterpriseFeatures
|
||||
);
|
||||
const [platform, setPlatform] = useState<Platform>("linux");
|
||||
const [architecture, setArchitecture] = useState(
|
||||
() => getArchitectures(platform)[0]
|
||||
);
|
||||
|
||||
const showSiteConfiguration = platform !== "advantech";
|
||||
const supportsSshOption = platform === "linux" || platform === "nixos";
|
||||
|
||||
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
|
||||
const acceptClientsEnv = !acceptClients
|
||||
? "\n - DISABLE_CLIENTS=true"
|
||||
@@ -57,6 +74,15 @@ export function NewtSiteInstallCommands({
|
||||
--set newtInstances[0].acceptClients=true`
|
||||
: "";
|
||||
|
||||
const disableSshFlag =
|
||||
supportsSshOption &&
|
||||
!allowPangolinSsh &&
|
||||
!env.flags.disableEnterpriseFeatures
|
||||
? " --disable-ssh"
|
||||
: "";
|
||||
const runAsRootPrefix =
|
||||
supportsSshOption && allowPangolinSsh ? "sudo " : "";
|
||||
|
||||
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
|
||||
linux: {
|
||||
Run: [
|
||||
@@ -66,7 +92,7 @@ export function NewtSiteInstallCommands({
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
command: `${runAsRootPrefix}newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||
}
|
||||
],
|
||||
"Systemd Service": [
|
||||
@@ -86,6 +112,11 @@ PANGOLIN_ENDPOINT=${endpoint}${
|
||||
? `
|
||||
DISABLE_CLIENTS=true`
|
||||
: ""
|
||||
}${
|
||||
!allowPangolinSsh
|
||||
? `
|
||||
DISABLE_SSH=true`
|
||||
: ""
|
||||
}
|
||||
EOF
|
||||
sudo chmod 600 /etc/newt/newt.env`
|
||||
@@ -108,7 +139,6 @@ Restart=always
|
||||
RestartSec=2
|
||||
UMask=0077
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
@@ -180,6 +210,9 @@ sudo systemctl enable --now newt`
|
||||
--set-string newtInstances[0].auth.existingSecretName="newt-main-tunnel-auth"${acceptClientsHelmValue}`
|
||||
]
|
||||
},
|
||||
advantech: {
|
||||
Documentation: []
|
||||
},
|
||||
podman: {
|
||||
"Podman Quadlet": [
|
||||
`[Unit]
|
||||
@@ -205,7 +238,7 @@ WantedBy=default.target`
|
||||
},
|
||||
nixos: {
|
||||
Flake: [
|
||||
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
`${runAsRootPrefix}nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -257,45 +290,89 @@ WantedBy=default.target`
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="font-semibold mb-3">
|
||||
{t("siteConfiguration")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<CheckboxWithLabel
|
||||
id="acceptClients"
|
||||
aria-describedby="acceptClients-desc"
|
||||
checked={acceptClients}
|
||||
onCheckedChange={(checked) => {
|
||||
const value = checked as boolean;
|
||||
setAcceptClients(value);
|
||||
}}
|
||||
label={t("siteAcceptClientConnections")}
|
||||
/>
|
||||
{showSiteConfiguration && (
|
||||
<div className="pt-4">
|
||||
<p className="font-semibold mb-3">
|
||||
{t("siteConfiguration")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<CheckboxWithLabel
|
||||
id="acceptClients"
|
||||
aria-describedby="acceptClients-desc"
|
||||
checked={acceptClients}
|
||||
onCheckedChange={(checked) => {
|
||||
const value = checked as boolean;
|
||||
setAcceptClients(value);
|
||||
}}
|
||||
label={t("siteAcceptClientConnections")}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
id="acceptClients-desc"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("siteAcceptClientConnectionsDescription")}
|
||||
</p>
|
||||
{supportsSshOption &&
|
||||
!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 mb-2 mt-2">
|
||||
<CheckboxWithLabel
|
||||
id="allowPangolinSsh"
|
||||
checked={allowPangolinSsh}
|
||||
onCheckedChange={(checked) => {
|
||||
const value =
|
||||
checked as boolean;
|
||||
setAllowPangolinSsh(value);
|
||||
}}
|
||||
label="Allow Pangolin SSH"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
id="allowPangolinSsh-desc"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("sitePangolinSshDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
id="acceptClients-desc"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("siteAcceptClientConnectionsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="font-semibold mb-3">{t("commands")}</p>
|
||||
{platform === "kubernetes" && (
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
For more and up to date Kubernetes installation
|
||||
information, see{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
docs.pangolin.net/manage/sites/install-kubernetes
|
||||
</a>
|
||||
.
|
||||
{t.rich("siteInstallKubernetesDocsDescription", {
|
||||
docsLink: (chunks) => (
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{platform === "advantech" && (
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t.rich("siteInstallAdvantechDocsDescription", {
|
||||
docsLink: (chunks) => (
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/install-site"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 space-y-3">
|
||||
@@ -342,6 +419,8 @@ function getPlatformIcon(platformName: Platform) {
|
||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||
case "kubernetes":
|
||||
return <SiKubernetes className="h-4 w-4 mr-2" />;
|
||||
case "advantech":
|
||||
return <FaHdd className="h-4 w-4 mr-2" />;
|
||||
case "podman":
|
||||
return <FaCubes className="h-4 w-4 mr-2" />;
|
||||
case "nixos":
|
||||
@@ -363,6 +442,8 @@ function getPlatformName(platformName: Platform) {
|
||||
return "Docker";
|
||||
case "kubernetes":
|
||||
return "Kubernetes";
|
||||
case "advantech":
|
||||
return "Advantech";
|
||||
case "podman":
|
||||
return "Podman";
|
||||
case "nixos":
|
||||
@@ -384,6 +465,8 @@ function getArchitectures(platform: Platform) {
|
||||
return ["Docker Compose", "Docker Run"];
|
||||
case "kubernetes":
|
||||
return ["Helm Chart"];
|
||||
case "advantech":
|
||||
return ["Documentation"];
|
||||
case "podman":
|
||||
return ["Podman Quadlet", "Podman Run"];
|
||||
case "nixos":
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
type LauncherCopyIconProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LauncherCopyIcon({ text, className }: LauncherCopyIconProps) {
|
||||
const t = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("copyText")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { LayoutGrid, SearchX } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type LauncherEmptyStateVariant = "empty" | "noResults";
|
||||
|
||||
type LauncherEmptyStateProps = {
|
||||
variant: LauncherEmptyStateVariant;
|
||||
layout: "grid" | "list";
|
||||
query?: string;
|
||||
onClearFilters?: () => void;
|
||||
};
|
||||
|
||||
function GhostResourceGrid() {
|
||||
return (
|
||||
<div
|
||||
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
|
||||
aria-hidden
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-2/5 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GhostResourceList() {
|
||||
return (
|
||||
<div className="flex w-full flex-col" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-4 py-3",
|
||||
index < 2 && "border-b border-border/60"
|
||||
)}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-1/4 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherEmptyState({
|
||||
variant,
|
||||
layout,
|
||||
query,
|
||||
onClearFilters
|
||||
}: LauncherEmptyStateProps) {
|
||||
const t = useTranslations();
|
||||
const isNoResults = variant === "noResults";
|
||||
const Icon = isNoResults ? SearchX : LayoutGrid;
|
||||
const trimmedQuery = query?.trim();
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-50">
|
||||
{layout === "grid" ? (
|
||||
<GhostResourceGrid />
|
||||
) : (
|
||||
<GhostResourceList />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{isNoResults
|
||||
? t("resourceLauncherEmptyStateNoResultsTitle")
|
||||
: t("resourceLauncherEmptyStateTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isNoResults
|
||||
? trimmedQuery
|
||||
? t(
|
||||
"resourceLauncherEmptyStateNoResultsWithQuery",
|
||||
{ query: trimmedQuery }
|
||||
)
|
||||
: t(
|
||||
"resourceLauncherEmptyStateNoResultsDescription"
|
||||
)
|
||||
: t("resourceLauncherEmptyStateDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{isNoResults && onClearFilters ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
{t("clearAllFilters")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
formatMultiSitesSelectorLabel,
|
||||
MultiSitesSelector
|
||||
} from "@app/components/multi-site-selector";
|
||||
import {
|
||||
formatLabelsSelectorLabel,
|
||||
LABEL_COLORS,
|
||||
type SelectedLabel
|
||||
} from "@app/components/labels-selector";
|
||||
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronsUpDown, Funnel } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
type LauncherFilterPopoverProps = {
|
||||
orgId: string;
|
||||
selectedSites: Selectedsite[];
|
||||
selectedLabels: SelectedLabel[];
|
||||
onSitesChange: (sites: Selectedsite[]) => void;
|
||||
onLabelsChange: (labels: SelectedLabel[]) => void;
|
||||
};
|
||||
|
||||
export function LauncherFilterPopover({
|
||||
orgId,
|
||||
selectedSites,
|
||||
selectedLabels,
|
||||
onSitesChange,
|
||||
onLabelsChange
|
||||
}: LauncherFilterPopoverProps) {
|
||||
const t = useTranslations();
|
||||
const [sitesOpen, setSitesOpen] = useState(false);
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
|
||||
const { data: labels = [] } = useQuery(
|
||||
launcherQueries.labels({
|
||||
orgId,
|
||||
perPage: 20
|
||||
})
|
||||
);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
launcherQueries.sites({
|
||||
orgId,
|
||||
perPage: 20
|
||||
})
|
||||
);
|
||||
|
||||
const resolvedSelectedSites: Selectedsite[] = useMemo(
|
||||
() =>
|
||||
selectedSites.map((selected) => {
|
||||
const found = sites.find(
|
||||
(site) => site.siteId === selected.siteId
|
||||
);
|
||||
return found
|
||||
? {
|
||||
siteId: found.siteId,
|
||||
name: found.name,
|
||||
type: found.type,
|
||||
online: found.online
|
||||
}
|
||||
: selected;
|
||||
}),
|
||||
[sites, selectedSites]
|
||||
);
|
||||
|
||||
const selectedLabelIds = useMemo(
|
||||
() => new Set(selectedLabels.map((label) => label.labelId)),
|
||||
[selectedLabels]
|
||||
);
|
||||
|
||||
const resolvedSelectedLabels: SelectedLabel[] = useMemo(
|
||||
() =>
|
||||
selectedLabels.map((selected) => {
|
||||
const found = labels.find(
|
||||
(label) => label.labelId === selected.labelId
|
||||
);
|
||||
return (
|
||||
found ?? {
|
||||
...selected,
|
||||
color: selected.color || LABEL_COLORS.gray
|
||||
}
|
||||
);
|
||||
}),
|
||||
[labels, selectedLabels]
|
||||
);
|
||||
|
||||
const activeFilterCount = selectedSites.length + selectedLabels.length;
|
||||
|
||||
return (
|
||||
<Popover modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative shrink-0"
|
||||
>
|
||||
<Funnel className="size-4" />
|
||||
<span className="sr-only">
|
||||
{activeFilterCount > 0
|
||||
? t("resourceLauncherFilterWithCount", {
|
||||
count: activeFilterCount
|
||||
})
|
||||
: t("resourceLauncherFilter")}
|
||||
</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1.5 text-xs"
|
||||
>
|
||||
{activeFilterCount > 99 ? "99+" : activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">{t("sites")}</p>
|
||||
<Popover open={sitesOpen} onOpenChange={setSitesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
resolvedSelectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<MultiSitesSelector
|
||||
orgId={orgId}
|
||||
selectedSites={resolvedSelectedSites}
|
||||
onSelectionChange={onSitesChange}
|
||||
scope="launcher"
|
||||
showClear={selectedSites.length > 0}
|
||||
onClear={() => {
|
||||
onSitesChange([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">{t("labels")}</p>
|
||||
<Popover open={labelsOpen} onOpenChange={setLabelsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
selectedLabels.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatLabelsSelectorLabel(
|
||||
resolvedSelectedLabels,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<LabelsFilterSelector
|
||||
orgId={orgId}
|
||||
scope="launcher"
|
||||
selectedLabels={resolvedSelectedLabels}
|
||||
isSelected={(label) =>
|
||||
selectedLabelIds.has(label.labelId)
|
||||
}
|
||||
onToggle={(label) => {
|
||||
if (
|
||||
selectedLabelIds.has(label.labelId)
|
||||
) {
|
||||
onLabelsChange(
|
||||
selectedLabels.filter(
|
||||
(item) =>
|
||||
item.labelId !==
|
||||
label.labelId
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onLabelsChange([
|
||||
...selectedLabels,
|
||||
label
|
||||
]);
|
||||
}
|
||||
}}
|
||||
showClear={selectedLabels.length > 0}
|
||||
onClear={() => {
|
||||
onLabelsChange([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherResource,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { LauncherEmptyState } from "./LauncherEmptyState";
|
||||
import { LauncherResourceGrid } from "./LauncherResourceGrid";
|
||||
import { LauncherResourceList } from "./LauncherResourceList";
|
||||
|
||||
type LauncherFlatResourceListProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
onClearFilters?: () => void;
|
||||
onResourceSelect?: (resource: LauncherResource) => void;
|
||||
};
|
||||
|
||||
export function LauncherFlatResourceList({
|
||||
orgId,
|
||||
config,
|
||||
onClearFilters,
|
||||
onResourceSelect
|
||||
}: LauncherFlatResourceListProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const resourceFilters = useMemo(
|
||||
() => ({
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
groupKey: LAUNCHER_FLAT_GROUP_KEY,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
}),
|
||||
[
|
||||
config.groupBy,
|
||||
config.labelIds,
|
||||
config.order,
|
||||
config.query,
|
||||
config.siteIds,
|
||||
config.sortBy
|
||||
]
|
||||
);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.resources(orgId, resourceFilters)
|
||||
});
|
||||
|
||||
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
if (resources.length === 0) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LauncherEmptyState
|
||||
variant={
|
||||
hasActiveLauncherFilters(config) ? "noResults" : "empty"
|
||||
}
|
||||
layout={config.layout}
|
||||
query={config.query}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{config.layout === "grid" ? (
|
||||
<LauncherResourceGrid
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
) : (
|
||||
<LauncherResourceList
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
)}
|
||||
<div ref={loadMoreRef} className="h-4" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherResource,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { LauncherEmptyState } from "./LauncherEmptyState";
|
||||
import { LauncherGroupSection } from "./LauncherGroupSection";
|
||||
|
||||
type LauncherGroupListProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
initialGroups: LauncherGroup[];
|
||||
groupsPagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
onClearFilters?: () => void;
|
||||
onResourceSelect?: (resource: LauncherResource) => void;
|
||||
};
|
||||
|
||||
function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
|
||||
return (
|
||||
config.query.trim().length > 0 ||
|
||||
config.siteIds.length > 0 ||
|
||||
config.labelIds.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherGroupList({
|
||||
orgId,
|
||||
activeViewId,
|
||||
config,
|
||||
initialGroups,
|
||||
groupsPagination,
|
||||
onClearFilters,
|
||||
onResourceSelect
|
||||
}: LauncherGroupListProps) {
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const groupFilters = useMemo(
|
||||
() => ({
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
}),
|
||||
[
|
||||
config.groupBy,
|
||||
config.labelIds,
|
||||
config.order,
|
||||
config.query,
|
||||
config.siteIds,
|
||||
config.sortBy
|
||||
]
|
||||
);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.groups(orgId, groupFilters),
|
||||
...(initialGroups.length > 0
|
||||
? {
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
groups: initialGroups,
|
||||
pagination: groupsPagination
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
},
|
||||
refetchOnMount: false as const
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
if (groups.length === 0) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LauncherEmptyState
|
||||
variant={
|
||||
hasActiveLauncherFilters(config) ? "noResults" : "empty"
|
||||
}
|
||||
layout={config.layout}
|
||||
query={config.query}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{groups.map((group) => (
|
||||
<LauncherGroupSection
|
||||
key={group.groupKey}
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
group={group}
|
||||
config={config}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
))}
|
||||
<div ref={loadMoreRef} className="h-4" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent
|
||||
} from "@app/components/ui/collapsible";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
readLauncherGroupOpen,
|
||||
writeLauncherGroupOpen,
|
||||
type LauncherActiveViewId
|
||||
} from "@app/lib/launcherLocalStorage";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherResource,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import {
|
||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
||||
LAUNCHER_UNLABELED_GROUP_KEY
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LauncherGroupTrigger } from "./LauncherGroupTrigger";
|
||||
import { LauncherResourceGrid } from "./LauncherResourceGrid";
|
||||
import { LauncherResourceList } from "./LauncherResourceList";
|
||||
|
||||
type LauncherGroupSectionProps = {
|
||||
orgId: string;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
group: LauncherGroup;
|
||||
config: LauncherViewConfig;
|
||||
initialResources?: LauncherResource[];
|
||||
initialResourcesPagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
defaultOpen?: boolean;
|
||||
onResourceSelect?: (resource: LauncherResource) => void;
|
||||
};
|
||||
|
||||
export function LauncherGroupSection({
|
||||
orgId,
|
||||
activeViewId,
|
||||
group,
|
||||
config,
|
||||
initialResources,
|
||||
initialResourcesPagination,
|
||||
defaultOpen = true,
|
||||
onResourceSelect
|
||||
}: LauncherGroupSectionProps) {
|
||||
const t = useTranslations();
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(() =>
|
||||
readLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
defaultOpen
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(
|
||||
readLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
defaultOpen
|
||||
)
|
||||
);
|
||||
}, [activeViewId, config.groupBy, defaultOpen, group.groupKey, orgId]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
writeLauncherGroupOpen(
|
||||
orgId,
|
||||
activeViewId,
|
||||
config.groupBy,
|
||||
group.groupKey,
|
||||
open
|
||||
);
|
||||
};
|
||||
|
||||
const hasInitialResources = initialResources !== undefined;
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.resources(orgId, {
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
groupKey: group.groupKey,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
}),
|
||||
enabled: isOpen,
|
||||
refetchOnMount: false,
|
||||
...(hasInitialResources
|
||||
? {
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
resources: initialResources,
|
||||
pagination: initialResourcesPagination ?? {
|
||||
total: initialResources.length,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
}
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
}
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
|
||||
const showInitialLoader = isLoading && resources.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || !hasNextPage || !isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]);
|
||||
|
||||
const groupTitle =
|
||||
group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY
|
||||
? t("resourceLauncherUnlabeled")
|
||||
: group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY
|
||||
? t("resourceLauncherNoSite")
|
||||
: group.name;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
className="flex w-full flex-col gap-2.5"
|
||||
>
|
||||
<LauncherGroupTrigger
|
||||
group={group}
|
||||
title={groupTitle}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
<CollapsibleContent className="w-full">
|
||||
{showInitialLoader ? (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : resources.length === 0 ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">
|
||||
{t("resourceLauncherNoResourcesInGroup")}
|
||||
</p>
|
||||
) : config.layout === "grid" ? (
|
||||
<LauncherResourceGrid
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
) : (
|
||||
<LauncherResourceList
|
||||
resources={resources}
|
||||
showLabels={config.showLabels}
|
||||
onResourceSelect={onResourceSelect}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className={cn("h-4", !hasNextPage && "hidden")}
|
||||
/>
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { CollapsibleTrigger } from "@app/components/ui/collapsible";
|
||||
import type { LauncherGroup } from "@server/routers/launcher/types";
|
||||
import { ChevronDown, ChevronLeft } from "lucide-react";
|
||||
|
||||
type LauncherGroupTriggerProps = {
|
||||
group: LauncherGroup;
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
function LauncherGroupStatusDot({ group }: { group: LauncherGroup }) {
|
||||
if (group.groupType === "label") {
|
||||
return (
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: group.labelColor }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.groupType === "site") {
|
||||
if (
|
||||
(group.siteType === "newt" || group.siteType === "wireguard") &&
|
||||
typeof group.siteOnline === "boolean"
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
group.siteOnline
|
||||
? "size-2 shrink-0 rounded-full bg-green-500"
|
||||
: "size-2 shrink-0 rounded-full bg-neutral-500"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="size-2 shrink-0 rounded-full bg-neutral-500" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function LauncherGroupTrigger({
|
||||
group,
|
||||
title,
|
||||
isOpen
|
||||
}: LauncherGroupTriggerProps) {
|
||||
return (
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-md bg-accent px-4 py-2.5 text-left transition-colors cursor-pointer">
|
||||
{group.groupType === "site" || group.groupType === "label" ? (
|
||||
<LauncherGroupStatusDot group={group} />
|
||||
) : null}
|
||||
<span className="flex min-w-0 items-center gap-2.5 text-sm font-semibold text-foreground">
|
||||
<span className="truncate">
|
||||
{title} ({group.itemCount})
|
||||
</span>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronLeft className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherLabel } from "@server/routers/launcher/types";
|
||||
import { LabelBadge } from "@app/components/label-badge";
|
||||
import { LabelOverflowBadge } from "@app/components/label-overflow-badge";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
const MAX_LABEL_ROWS = 2;
|
||||
const SINGLE_ROW_MAX_LABELS = 5;
|
||||
|
||||
type LauncherLabelsRowProps = {
|
||||
labels: LauncherLabel[];
|
||||
className?: string;
|
||||
variant?: "wrap" | "single-row";
|
||||
};
|
||||
|
||||
function countFlexRows(container: HTMLElement): number {
|
||||
const rowTops = new Set<number>();
|
||||
|
||||
for (const child of container.children) {
|
||||
const element = child as HTMLElement;
|
||||
if (element.style.display === "none") {
|
||||
continue;
|
||||
}
|
||||
rowTops.add(element.offsetTop);
|
||||
}
|
||||
|
||||
return rowTops.size;
|
||||
}
|
||||
|
||||
export function LauncherLabelsRow({
|
||||
labels,
|
||||
className,
|
||||
variant = "wrap"
|
||||
}: LauncherLabelsRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(labels.length);
|
||||
|
||||
const labelKey = labels.map((label) => label.labelId).join(",");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (variant === "single-row") {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!container || !measure || labels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recompute = () => {
|
||||
const width = container.clientWidth;
|
||||
if (width <= 0) {
|
||||
setVisibleCount(labels.length);
|
||||
return;
|
||||
}
|
||||
|
||||
measure.style.width = `${width}px`;
|
||||
|
||||
const labelNodes = measure.querySelectorAll<HTMLElement>(
|
||||
"[data-measure-label]"
|
||||
);
|
||||
const overflowNode = measure.querySelector<HTMLElement>(
|
||||
"[data-measure-overflow]"
|
||||
);
|
||||
|
||||
const fits = (visible: number) => {
|
||||
labelNodes.forEach((node, index) => {
|
||||
node.style.display = index < visible ? "" : "none";
|
||||
});
|
||||
|
||||
if (overflowNode) {
|
||||
const overflowCount = labels.length - visible;
|
||||
if (overflowCount > 0) {
|
||||
overflowNode.style.display = "";
|
||||
} else {
|
||||
overflowNode.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
return countFlexRows(measure) <= MAX_LABEL_ROWS;
|
||||
};
|
||||
|
||||
let best = 0;
|
||||
for (let visible = labels.length; visible >= 0; visible--) {
|
||||
if (fits(visible)) {
|
||||
best = visible;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleCount(best);
|
||||
};
|
||||
|
||||
recompute();
|
||||
|
||||
const observer = new ResizeObserver(recompute);
|
||||
observer.observe(container);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [labelKey, labels, variant]);
|
||||
|
||||
if (labels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedVisibleCount =
|
||||
variant === "single-row"
|
||||
? Math.min(labels.length, SINGLE_ROW_MAX_LABELS)
|
||||
: visibleCount;
|
||||
const visibleLabels = labels.slice(0, resolvedVisibleCount);
|
||||
const overflowLabels = labels.slice(resolvedVisibleCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("relative min-w-0 w-full", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
variant === "single-row" ? "flex-nowrap" : "flex-wrap"
|
||||
)}
|
||||
>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelBadge
|
||||
key={label.labelId}
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
))}
|
||||
{overflowLabels.length > 0 ? (
|
||||
<LabelOverflowBadge
|
||||
labels={overflowLabels.map((label) => ({
|
||||
color: label.color,
|
||||
name: label.name
|
||||
}))}
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{variant === "wrap" ? (
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex flex-wrap items-center gap-1"
|
||||
aria-hidden
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<span key={label.labelId} data-measure-label>
|
||||
<LabelBadge
|
||||
name={label.name}
|
||||
color={label.color}
|
||||
displayOnly
|
||||
className="shrink-0"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
data-measure-overflow
|
||||
className="inline-flex shrink-0"
|
||||
>
|
||||
<LabelOverflowBadge labels={labels} displayOnly />
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
|
||||
type LauncherOrgSelectorProps = {
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
};
|
||||
|
||||
export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
|
||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
|
||||
const sortedOrgs = useMemo(() => {
|
||||
if (!orgs?.length) {
|
||||
return orgs ?? [];
|
||||
}
|
||||
return [...orgs].sort((a, b) => {
|
||||
const aPrimary = Boolean(a.isPrimaryOrg);
|
||||
const bPrimary = Boolean(b.isPrimaryOrg);
|
||||
if (aPrimary && !bPrimary) {
|
||||
return -1;
|
||||
}
|
||||
if (!aPrimary && bPrimary) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [orgs]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="inline-flex items-center gap-1 p-0"
|
||||
variant="text"
|
||||
size="sm"
|
||||
>
|
||||
<span className="truncate max-w-[200px]">
|
||||
{selectedOrg?.name ?? t("noneSelected")}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command className="rounded-lg border-0">
|
||||
<CommandInput placeholder={t("searchPlaceholder")} />
|
||||
<CommandList className="max-h-[280px]">
|
||||
<CommandEmpty>{t("orgNotFound2")}</CommandEmpty>
|
||||
<CommandGroup heading={t("orgs")}>
|
||||
{sortedOrgs.map((org) => (
|
||||
<CommandItem
|
||||
key={org.orgId}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
const newPath = pathname.includes(
|
||||
"/settings/"
|
||||
)
|
||||
? pathname.replace(
|
||||
/^\/[^/]+/,
|
||||
`/${org.orgId}`
|
||||
)
|
||||
: `/${org.orgId}`;
|
||||
router.push(newPath);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-medium truncate text-sm">
|
||||
{org.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{org.orgId}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary shrink-0",
|
||||
orgId === org.orgId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
type LauncherRefreshButtonProps = {
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
};
|
||||
|
||||
export function LauncherRefreshButton({
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: LauncherRefreshButtonProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
|
||||
import Link from "next/link";
|
||||
import { LauncherCopyIcon } from "./LauncherCopyIcon";
|
||||
|
||||
type LauncherResourceAccessProps = {
|
||||
accessDisplay: string;
|
||||
accessCopyValue: string;
|
||||
accessUrl?: string | null;
|
||||
variant: "grid" | "list";
|
||||
};
|
||||
|
||||
export function LauncherResourceAccess({
|
||||
accessDisplay,
|
||||
accessCopyValue,
|
||||
accessUrl,
|
||||
variant
|
||||
}: LauncherResourceAccessProps) {
|
||||
if (!accessDisplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const href = accessUrl ?? undefined;
|
||||
const canLink = href && isSafeUrlForLink(href);
|
||||
const copyValue = canLink ? href : accessCopyValue;
|
||||
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 max-md:min-w-[12rem] max-md:shrink-0 max-md:flex-none">
|
||||
{canLink ? (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 truncate text-sm text-muted-foreground hover:underline max-md:overflow-visible max-md:whitespace-nowrap"
|
||||
>
|
||||
{accessDisplay}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground max-md:overflow-visible max-md:whitespace-nowrap">
|
||||
{accessDisplay}
|
||||
</span>
|
||||
)}
|
||||
<LauncherCopyIcon text={copyValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center gap-2.5">
|
||||
{canLink ? (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 flex-1 truncate text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
{accessDisplay}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
{accessDisplay}
|
||||
</span>
|
||||
)}
|
||||
<LauncherCopyIcon text={copyValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
import { LauncherLabelsRow } from "./LauncherLabelsRow";
|
||||
import { LauncherResourceAccess } from "./LauncherResourceAccess";
|
||||
import { LauncherResourceIcon } from "./LauncherResourceIcon";
|
||||
import { getLauncherResourceSelectProps } from "./useLauncherResourceAction";
|
||||
|
||||
type LauncherResourceCardProps = {
|
||||
resource: LauncherResource;
|
||||
showLabels: boolean;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
export function LauncherResourceCard({
|
||||
resource,
|
||||
showLabels,
|
||||
onSelect
|
||||
}: LauncherResourceCardProps) {
|
||||
const hasIcon = Boolean(resource.iconUrl);
|
||||
const clickProps = onSelect
|
||||
? getLauncherResourceSelectProps(onSelect)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-2.5 overflow-hidden rounded-xl border border-border bg-background p-4",
|
||||
clickProps?.className
|
||||
)}
|
||||
onClick={clickProps?.onClick}
|
||||
onKeyDown={clickProps?.onKeyDown}
|
||||
role={clickProps?.role}
|
||||
tabIndex={clickProps?.tabIndex}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center",
|
||||
hasIcon ? "gap-5" : "gap-0"
|
||||
)}
|
||||
>
|
||||
{hasIcon ? (
|
||||
<LauncherResourceIcon
|
||||
iconUrl={resource.iconUrl}
|
||||
name={resource.name}
|
||||
variant="grid"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{resource.name}
|
||||
</div>
|
||||
<LauncherResourceAccess
|
||||
accessDisplay={resource.accessDisplay}
|
||||
accessCopyValue={resource.accessCopyValue}
|
||||
accessUrl={resource.accessUrl}
|
||||
variant="grid"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLabels && resource.labels.length > 0 ? (
|
||||
<LauncherLabelsRow labels={resource.labels} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
import { LauncherResourceCard } from "./LauncherResourceCard";
|
||||
|
||||
type LauncherResourceGridProps = {
|
||||
resources: LauncherResource[];
|
||||
showLabels: boolean;
|
||||
onResourceSelect?: (resource: LauncherResource) => void;
|
||||
};
|
||||
|
||||
export function LauncherResourceGrid({
|
||||
resources,
|
||||
showLabels,
|
||||
onResourceSelect
|
||||
}: LauncherResourceGridProps) {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0">
|
||||
{resources.map((resource) => (
|
||||
<LauncherResourceCard
|
||||
key={resource.launcherResourceKey}
|
||||
resource={resource}
|
||||
showLabels={showLabels}
|
||||
onSelect={
|
||||
onResourceSelect
|
||||
? () => onResourceSelect(resource)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type LauncherResourceIconProps = {
|
||||
iconUrl?: string | null;
|
||||
name: string;
|
||||
className?: string;
|
||||
variant?: "grid" | "list";
|
||||
};
|
||||
|
||||
export function LauncherResourceIcon({
|
||||
iconUrl,
|
||||
name,
|
||||
className,
|
||||
variant = "grid"
|
||||
}: LauncherResourceIconProps) {
|
||||
const dimension = variant === "list" ? "size-5" : "size-10";
|
||||
|
||||
if (iconUrl) {
|
||||
return (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt={name}
|
||||
className={cn(dimension, "shrink-0 object-cover", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
dimension,
|
||||
"flex shrink-0 items-center justify-center text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-semibold">-</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
import { LauncherResourceRow } from "./LauncherResourceRow";
|
||||
|
||||
type LauncherResourceListProps = {
|
||||
resources: LauncherResource[];
|
||||
showLabels: boolean;
|
||||
onResourceSelect?: (resource: LauncherResource) => void;
|
||||
};
|
||||
|
||||
export function LauncherResourceList({
|
||||
resources,
|
||||
showLabels,
|
||||
onResourceSelect
|
||||
}: LauncherResourceListProps) {
|
||||
return (
|
||||
<div className="w-full max-md:overflow-x-auto max-md:overflow-y-hidden">
|
||||
<div className="flex w-full flex-col max-md:w-max">
|
||||
{resources.map((resource, index) => (
|
||||
<LauncherResourceRow
|
||||
key={resource.launcherResourceKey}
|
||||
resource={resource}
|
||||
showLabels={showLabels}
|
||||
isLast={index === resources.length - 1}
|
||||
onSelect={
|
||||
onResourceSelect
|
||||
? () => onResourceSelect(resource)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
"use client";
|
||||
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
SidePanel,
|
||||
SidePanelBody,
|
||||
SidePanelContent,
|
||||
SidePanelFooter,
|
||||
SidePanelHeader,
|
||||
SidePanelTitle
|
||||
} from "@app/components/SidePanel";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
derivePublicAuthState,
|
||||
formatPortRestrictionDisplay,
|
||||
formatPublicResourceType
|
||||
} from "@app/lib/launcherResourceDetails";
|
||||
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import {
|
||||
formatSiteResourceDestinationDisplay,
|
||||
isSafeUrlForLink
|
||||
} from "@app/lib/launcherResourceAccess";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
type LauncherResourcePanelProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
resource: LauncherResource | null;
|
||||
orgId: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
type LauncherResourceDetailResult =
|
||||
| {
|
||||
resourceType: "public";
|
||||
data: GetResourceResponse;
|
||||
authInfo: GetResourceAuthInfoResponse;
|
||||
}
|
||||
| { resourceType: "site"; data: GetSiteResourceResponse };
|
||||
|
||||
function AccessMethodContent({
|
||||
accessDisplay,
|
||||
accessCopyValue,
|
||||
accessUrl
|
||||
}: {
|
||||
accessDisplay: string;
|
||||
accessCopyValue: string;
|
||||
accessUrl?: string | null;
|
||||
}) {
|
||||
if (!accessDisplay) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const href = accessUrl ?? undefined;
|
||||
const canLink = Boolean(href && isSafeUrlForLink(href));
|
||||
|
||||
if (canLink && href) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={href}
|
||||
displayText={accessDisplay}
|
||||
isLink={true}
|
||||
className="text-base"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={accessCopyValue || accessDisplay}
|
||||
displayText={accessDisplay}
|
||||
isLink={false}
|
||||
className="text-base"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthStatusDisplay({
|
||||
health
|
||||
}: {
|
||||
health: string | null | undefined;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const status = health ?? "unknown";
|
||||
|
||||
if (status === "healthy") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="size-4 shrink-0 text-green-500" />
|
||||
<span>{t("resourcesTableHealthy")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "degraded") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle2 className="size-4 shrink-0 text-yellow-500" />
|
||||
<span>{t("resourcesTableDegraded")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unhealthy") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<XCircle className="size-4 shrink-0 text-destructive" />
|
||||
<span>{t("resourcesTableUnhealthy")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>{t("resourcesTableUnknown")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
|
||||
|
||||
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{enabled ? (
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-red-600" />
|
||||
)}
|
||||
<span>{enabled ? t("enabled") : t("disabled")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicResourceAuthMethods({
|
||||
authInfo
|
||||
}: {
|
||||
authInfo: GetResourceAuthInfoResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
key: "sso",
|
||||
title: t("policyAuthSsoTitle"),
|
||||
enabled: authInfo.sso
|
||||
},
|
||||
{
|
||||
key: "password",
|
||||
title: t("policyAuthPasscodeTitle"),
|
||||
enabled: authInfo.password
|
||||
},
|
||||
{
|
||||
key: "pincode",
|
||||
title: t("policyAuthPincodeTitle"),
|
||||
enabled: authInfo.pincode
|
||||
},
|
||||
{
|
||||
key: "whitelist",
|
||||
title: t("policyAuthEmailTitle"),
|
||||
enabled: authInfo.whitelist
|
||||
},
|
||||
{
|
||||
key: "headerAuth",
|
||||
title: t("policyAuthHeaderAuthTitle"),
|
||||
enabled: authInfo.headerAuth
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("authentication")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceLauncherAuthMethodsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={authMethods.length} layout="panel">
|
||||
{authMethods.map((method) => (
|
||||
<InfoSection key={method.key}>
|
||||
<InfoSectionTitle>{method.title}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<AuthMethodStatusDisplay
|
||||
enabled={method.enabled}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
))}
|
||||
</InfoSections>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicResourceDetails({
|
||||
launcherResource,
|
||||
resource,
|
||||
authInfo
|
||||
}: {
|
||||
launcherResource: LauncherResource;
|
||||
resource: GetResourceResponse;
|
||||
authInfo: GetResourceAuthInfoResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const supportsAuth = PUBLIC_AUTH_BROWSER_MODES.includes(
|
||||
resource.mode || ""
|
||||
);
|
||||
const authState = derivePublicAuthState(resource.mode, authInfo);
|
||||
const infoSectionCount = supportsAuth ? 4 : 3;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceLauncherResourceDetails")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceLauncherResourceDetailsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={infoSectionCount} layout="panel">
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{formatPublicResourceType(resource)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<AccessMethodContent
|
||||
accessDisplay={
|
||||
launcherResource.accessDisplay
|
||||
}
|
||||
accessCopyValue={
|
||||
launcherResource.accessCopyValue
|
||||
}
|
||||
accessUrl={launcherResource.accessUrl}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{supportsAuth ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("authentication")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authState === "protected" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldCheck className="size-4 shrink-0 text-green-500" />
|
||||
<span>{t("protected")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldOff className="size-4 shrink-0 text-yellow-500" />
|
||||
<span>{t("notProtected")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<HealthStatusDisplay health={resource.health} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
{supportsAuth ? (
|
||||
<PublicResourceAuthMethods authInfo={authInfo} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateResourceDetails({
|
||||
launcherResource,
|
||||
resource
|
||||
}: {
|
||||
launcherResource: LauncherResource;
|
||||
resource: GetSiteResourceResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const modeLabel: Record<GetSiteResourceResponse["mode"], string> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
};
|
||||
const destination = formatSiteResourceDestinationDisplay({
|
||||
mode: resource.mode,
|
||||
destination: resource.destination,
|
||||
destinationPort: resource.destinationPort,
|
||||
scheme: resource.scheme
|
||||
});
|
||||
const portRestrictions = formatPortRestrictionDisplay(resource);
|
||||
const showAlias = resource.mode !== "cidr" && resource.mode !== "http";
|
||||
const showDestination = !(
|
||||
resource.mode === "ssh" && resource.authDaemonMode === "native"
|
||||
);
|
||||
const infoSectionCount =
|
||||
2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="default">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>
|
||||
{t("resourceLauncherPrivateClientRequiredTitle")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span>
|
||||
{t("resourceLauncherPrivateClientRequired")}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/downloads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
{t("resourceLauncherDownloadClient")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceLauncherResourceDetails")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceLauncherResourceDetailsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={infoSectionCount} layout="panel">
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{modeLabel[resource.mode]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<AccessMethodContent
|
||||
accessDisplay={
|
||||
launcherResource.accessDisplay
|
||||
}
|
||||
accessCopyValue={
|
||||
launcherResource.accessCopyValue
|
||||
}
|
||||
accessUrl={launcherResource.accessUrl}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{showDestination ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("editInternalResourceDialogDestination")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{destination || "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
{showAlias ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("editInternalResourceDialogAlias")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.alias?.trim()
|
||||
? resource.alias
|
||||
: "-"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("portRestrictions")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{!portRestrictions.hasNonDefaultPorts ? (
|
||||
<span>
|
||||
{t(
|
||||
"resourceLauncherNoPortRestrictions"
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{portRestrictions.tcp.state !==
|
||||
"all" ? (
|
||||
<div>
|
||||
{t("resourceLauncherTcp")}:{" "}
|
||||
{portRestrictions.tcp.state ===
|
||||
"blocked"
|
||||
? t("blocked")
|
||||
: portRestrictions.tcp
|
||||
.ports}
|
||||
</div>
|
||||
) : null}
|
||||
{portRestrictions.udp.state !==
|
||||
"all" ? (
|
||||
<div>
|
||||
{t("resourceLauncherUdp")}:{" "}
|
||||
{portRestrictions.udp.state ===
|
||||
"blocked"
|
||||
? t("blocked")
|
||||
: portRestrictions.udp
|
||||
.ports}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LauncherResourcePanelBody({
|
||||
orgId,
|
||||
resource,
|
||||
open
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: LauncherResource;
|
||||
open: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const { data, isPending, isError } = useQuery({
|
||||
...launcherQueries.resourceDetail(orgId, resource),
|
||||
enabled: open
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("resourceLauncherFailedToLoadDetails")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const detail = data as LauncherResourceDetailResult;
|
||||
|
||||
if (detail.resourceType === "public") {
|
||||
return (
|
||||
<PublicResourceDetails
|
||||
launcherResource={resource}
|
||||
resource={detail.data}
|
||||
authInfo={detail.authInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PrivateResourceDetails
|
||||
launcherResource={resource}
|
||||
resource={detail.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherResourcePanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
resource,
|
||||
orgId,
|
||||
isAdmin
|
||||
}: LauncherResourcePanelProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SidePanel open={open} onOpenChange={onOpenChange}>
|
||||
<SidePanelContent>
|
||||
<SidePanelHeader>
|
||||
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
|
||||
</SidePanelHeader>
|
||||
<SidePanelBody>
|
||||
{resource ? (
|
||||
<LauncherResourcePanelBody
|
||||
orgId={orgId}
|
||||
resource={resource}
|
||||
open={open}
|
||||
/>
|
||||
) : null}
|
||||
</SidePanelBody>
|
||||
<SidePanelFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
{isAdmin && resource ? (
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={getLauncherResourceAdminHref(
|
||||
orgId,
|
||||
resource
|
||||
)}
|
||||
>
|
||||
{t("resourceLauncherViewAsAdmin")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</SidePanelFooter>
|
||||
</SidePanelContent>
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
import { LauncherLabelsRow } from "./LauncherLabelsRow";
|
||||
import { LauncherResourceAccess } from "./LauncherResourceAccess";
|
||||
import { LauncherResourceIcon } from "./LauncherResourceIcon";
|
||||
import { getLauncherResourceSelectProps } from "./useLauncherResourceAction";
|
||||
|
||||
type LauncherResourceRowProps = {
|
||||
resource: LauncherResource;
|
||||
showLabels: boolean;
|
||||
isLast?: boolean;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
export function LauncherResourceRow({
|
||||
resource,
|
||||
showLabels,
|
||||
isLast = false,
|
||||
onSelect
|
||||
}: LauncherResourceRowProps) {
|
||||
const hasTags = showLabels && resource.labels.length > 0;
|
||||
const clickProps = onSelect
|
||||
? getLauncherResourceSelectProps(onSelect)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 p-4 max-md:min-w-max max-md:whitespace-nowrap",
|
||||
isLast ? undefined : "border-b border-border",
|
||||
clickProps?.className
|
||||
)}
|
||||
onClick={clickProps?.onClick}
|
||||
onKeyDown={clickProps?.onKeyDown}
|
||||
role={clickProps?.role}
|
||||
tabIndex={clickProps?.tabIndex}
|
||||
>
|
||||
<LauncherResourceIcon
|
||||
iconUrl={resource.iconUrl}
|
||||
name={resource.name}
|
||||
variant="list"
|
||||
/>
|
||||
|
||||
<span className="shrink-0 text-sm font-semibold text-foreground">
|
||||
{resource.name}
|
||||
</span>
|
||||
|
||||
<LauncherResourceAccess
|
||||
accessDisplay={resource.accessDisplay}
|
||||
accessCopyValue={resource.accessCopyValue}
|
||||
accessUrl={resource.accessUrl}
|
||||
variant="list"
|
||||
/>
|
||||
|
||||
{hasTags ? (
|
||||
<div className="flex min-w-0 max-w-md shrink items-center justify-end gap-1 max-md:shrink-0 max-md:max-w-none md:ml-auto">
|
||||
<LauncherLabelsRow
|
||||
labels={resource.labels}
|
||||
variant="single-row"
|
||||
className="w-auto shrink-0 justify-end"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type LauncherSearchFirstGateProps = {
|
||||
layout: "grid" | "list";
|
||||
};
|
||||
|
||||
function GhostResourceGrid() {
|
||||
return (
|
||||
<div
|
||||
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
|
||||
aria-hidden
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-2/5 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GhostResourceList() {
|
||||
return (
|
||||
<div className="flex w-full flex-col" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-4 py-3",
|
||||
index < 2 && "border-b border-border/60"
|
||||
)}
|
||||
>
|
||||
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
|
||||
<div className="h-3 w-1/4 rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherSearchFirstGate({
|
||||
layout
|
||||
}: LauncherSearchFirstGateProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
|
||||
<div className="pointer-events-none absolute inset-0 opacity-50">
|
||||
{layout === "grid" ? (
|
||||
<GhostResourceGrid />
|
||||
) : (
|
||||
<GhostResourceList />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Search className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("resourceLauncherSearchFirstTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("resourceLauncherSearchFirstDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import type {
|
||||
LauncherScaleCapabilities,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
type LauncherSettingsMenuProps = {
|
||||
config: LauncherViewConfig;
|
||||
capabilities: LauncherScaleCapabilities;
|
||||
isCompactMode: boolean;
|
||||
selectedGroupBy: LauncherViewConfig["groupBy"];
|
||||
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
|
||||
};
|
||||
|
||||
export function LauncherSettingsMenu({
|
||||
config,
|
||||
capabilities,
|
||||
isCompactMode,
|
||||
selectedGroupBy,
|
||||
onConfigChange
|
||||
}: LauncherSettingsMenuProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<Settings className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("resourceLauncherSettings")}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("resourceLauncherGroupBy")}
|
||||
</p>
|
||||
<Select
|
||||
value={selectedGroupBy}
|
||||
onValueChange={(value) =>
|
||||
onConfigChange({
|
||||
groupBy:
|
||||
value as LauncherViewConfig["groupBy"]
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("resourceLauncherGroupByNone")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="site"
|
||||
disabled={
|
||||
!capabilities.allowSiteGrouping ||
|
||||
(isCompactMode &&
|
||||
config.siteIds.length === 0)
|
||||
}
|
||||
>
|
||||
{t("resourceLauncherGroupBySite")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="label"
|
||||
disabled={
|
||||
!capabilities.allowLabelGrouping ||
|
||||
(isCompactMode &&
|
||||
config.labelIds.length === 0)
|
||||
}
|
||||
>
|
||||
{t("resourceLauncherGroupByLabel")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isCompactMode ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("resourceLauncherCompactGroupingHint")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isCompactMode && !capabilities.allowSiteGrouping ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("resourceLauncherSiteGroupingDisabled")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isCompactMode && !capabilities.allowLabelGrouping ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("resourceLauncherLabelGroupingDisabled")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{t("resourceLauncherLayout")}
|
||||
</p>
|
||||
<Select
|
||||
value={config.layout}
|
||||
onValueChange={(value) =>
|
||||
onConfigChange({
|
||||
layout: value as LauncherViewConfig["layout"]
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="grid">
|
||||
{t("resourceLauncherLayoutGrid")}
|
||||
</SelectItem>
|
||||
<SelectItem value="list">
|
||||
{t("resourceLauncherLayoutList")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label
|
||||
htmlFor="show-labels"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t("resourceLauncherShowLabels")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-labels"
|
||||
checked={config.showLabels}
|
||||
onCheckedChange={(checked) =>
|
||||
onConfigChange({ showLabels: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowDown01, ArrowUp10 } from "lucide-react";
|
||||
|
||||
type LauncherSortButtonProps = {
|
||||
order: "asc" | "desc";
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
export function LauncherSortButton({
|
||||
order,
|
||||
onToggle
|
||||
}: LauncherSortButtonProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={onToggle}
|
||||
title={
|
||||
order === "asc"
|
||||
? t("resourceLauncherSortAscending")
|
||||
: t("resourceLauncherSortDescending")
|
||||
}
|
||||
>
|
||||
{order === "asc" ? (
|
||||
<ArrowDown01 className="size-4" />
|
||||
) : (
|
||||
<ArrowUp10 className="size-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("resourceLauncherSort")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type LauncherViewTabsProps = {
|
||||
activeViewId: number | "default";
|
||||
savedViews: Array<{ viewId: number; name: string }>;
|
||||
onSelectView: (viewId: number | "default") => void;
|
||||
};
|
||||
|
||||
export function LauncherViewTabs({
|
||||
activeViewId,
|
||||
savedViews,
|
||||
onSelectView
|
||||
}: LauncherViewTabsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const viewOptions: Array<{
|
||||
value: number | "default";
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "default", label: t("resourceLauncherDefaultView") },
|
||||
...savedViews.map((view) => ({
|
||||
value: view.viewId,
|
||||
label: view.name
|
||||
}))
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex w-max items-center gap-2">
|
||||
{viewOptions.map((option) => {
|
||||
const isSelected = activeViewId === option.value;
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={
|
||||
isSelected
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={cn(
|
||||
"shrink-0 min-w-30 shadow-none",
|
||||
isSelected && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => onSelectView(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LauncherSaveViewMenuProps = {
|
||||
isDefaultView: boolean;
|
||||
isAdmin: boolean;
|
||||
isOrgWideView: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
canResetSystemDefault: boolean;
|
||||
onSaveToCurrent: () => void;
|
||||
onSaveAsNew: () => void;
|
||||
onSaveForEveryone: () => void;
|
||||
onMakePersonal: () => void;
|
||||
onResetView: () => void;
|
||||
onResetSystemDefault: () => void;
|
||||
onDeleteView: () => void;
|
||||
};
|
||||
|
||||
export function LauncherSaveViewMenu({
|
||||
isDefaultView,
|
||||
isAdmin,
|
||||
isOrgWideView,
|
||||
hasUnsavedChanges,
|
||||
canResetSystemDefault,
|
||||
onSaveToCurrent,
|
||||
onSaveAsNew,
|
||||
onSaveForEveryone,
|
||||
onMakePersonal,
|
||||
onResetView,
|
||||
onResetSystemDefault,
|
||||
onDeleteView
|
||||
}: LauncherSaveViewMenuProps) {
|
||||
const t = useTranslations();
|
||||
const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="shrink-0">
|
||||
{hasUnsavedChanges ? (
|
||||
<span className="size-2 rounded-full bg-primary mr-2" />
|
||||
) : null}
|
||||
{t("resourceLauncherSaveView")}
|
||||
<ChevronDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{hasUnsavedChanges ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onResetView}>
|
||||
{t("resourceLauncherResetView")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{isDefaultView && canResetSystemDefault ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onResetSystemDefault}>
|
||||
{t("resourceLauncherResetSystemDefault")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{isDefaultView && hasUnsavedChanges ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onSaveToCurrent}>
|
||||
{t("resourceLauncherSaveDefaultPersonal")}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem onSelect={onSaveForEveryone}>
|
||||
{t("resourceLauncherSaveForEveryone")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</>
|
||||
) : !isDefaultView && (isAdmin || !isOrgWideView) ? (
|
||||
<DropdownMenuItem onSelect={onSaveToCurrent}>
|
||||
{t("resourceLauncherSaveToCurrentView")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={onSaveAsNew}>
|
||||
{t("resourceLauncherSaveAsNewView")}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && !isDefaultView && !isOrgWideView ? (
|
||||
<DropdownMenuItem onSelect={onSaveForEveryone}>
|
||||
{t("resourceLauncherSaveForEveryone")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{isAdmin && !isDefaultView && isOrgWideView ? (
|
||||
<DropdownMenuItem onSelect={onMakePersonal}>
|
||||
{t("resourceLauncherMakePersonal")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canDeleteView ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={onDeleteView}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{t("resourceLauncherDeleteView")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import {
|
||||
readLauncherLastView,
|
||||
writeLauncherLastView,
|
||||
type LauncherActiveViewId
|
||||
} from "@app/lib/launcherLocalStorage";
|
||||
import {
|
||||
buildLauncherPath,
|
||||
getLauncherUrlBaseConfig,
|
||||
isLauncherConfigEqual,
|
||||
parseLauncherUrlState,
|
||||
serializeLauncherUrlState
|
||||
} from "@app/lib/launcherUrlState";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
getEffectiveLauncherConfig,
|
||||
shouldShowFlatResourceList,
|
||||
shouldShowLauncherGroupList,
|
||||
shouldShowSearchFirstGate
|
||||
} from "@app/lib/launcherScale";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import {
|
||||
getEffectiveDefaultLauncherConfig,
|
||||
type LauncherDefaultViewOverrides,
|
||||
type LauncherGroup,
|
||||
type LauncherResource,
|
||||
type LauncherScaleInfo,
|
||||
type LauncherViewConfig,
|
||||
type LauncherViewRecord
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition
|
||||
} from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import type { SelectedLabel } from "@app/components/labels-selector";
|
||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { LauncherFilterPopover } from "./LauncherFilterPopover";
|
||||
import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
|
||||
import { LauncherGroupList } from "./LauncherGroupList";
|
||||
import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
|
||||
import { LauncherRefreshButton } from "./LauncherRefreshButton";
|
||||
import { LauncherResourcePanel } from "./LauncherResourcePanel";
|
||||
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
||||
import { LauncherSortButton } from "./LauncherSortButton";
|
||||
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
|
||||
type ResourceLauncherProps = {
|
||||
orgId: string;
|
||||
isAdmin: boolean;
|
||||
views: LauncherViewRecord[];
|
||||
defaultViewOverrides: LauncherDefaultViewOverrides;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
savedConfig: LauncherViewConfig;
|
||||
scale: LauncherScaleInfo;
|
||||
groups: LauncherGroup[];
|
||||
groupsPagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ResourceLauncher({
|
||||
orgId,
|
||||
isAdmin,
|
||||
views,
|
||||
defaultViewOverrides,
|
||||
activeViewId,
|
||||
config,
|
||||
savedConfig,
|
||||
scale: initialScale,
|
||||
groups,
|
||||
groupsPagination
|
||||
}: ResourceLauncherProps) {
|
||||
const t = useTranslations();
|
||||
const { toast } = useToast();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const { navigate, isNavigating, searchParams } = useNavigationContext();
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
const hasRestoredLastView = useRef(false);
|
||||
|
||||
const [searchInputResetKey, setSearchInputResetKey] = useState(0);
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [newViewName, setNewViewName] = useState("");
|
||||
const [saveOrgWide, setSaveOrgWide] = useState(false);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<LauncherResource | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const scaleFilters = useMemo(
|
||||
() => ({
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order
|
||||
}),
|
||||
[
|
||||
config.groupBy,
|
||||
config.labelIds,
|
||||
config.order,
|
||||
config.query,
|
||||
config.siteIds,
|
||||
config.sortBy
|
||||
]
|
||||
);
|
||||
|
||||
const { data: scale = initialScale } = useQuery({
|
||||
...launcherQueries.scale(orgId, scaleFilters),
|
||||
initialData: initialScale
|
||||
});
|
||||
|
||||
const showGroupList = shouldShowLauncherGroupList(scale, config);
|
||||
const showSearchFirstGate = shouldShowSearchFirstGate(scale, config);
|
||||
const showFlatResourceList = shouldShowFlatResourceList(scale, config);
|
||||
const effectiveConfig = useMemo(
|
||||
() => getEffectiveLauncherConfig(scale, config),
|
||||
[config, scale]
|
||||
);
|
||||
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
const searchInputRef = useRef(config.query);
|
||||
const activeViewIdRef = useRef(activeViewId);
|
||||
activeViewIdRef.current = activeViewId;
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRestoredLastView.current) {
|
||||
return;
|
||||
}
|
||||
hasRestoredLastView.current = true;
|
||||
|
||||
const parsed = parseLauncherUrlState(searchParams);
|
||||
if (parsed.hasAnyLauncherParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastView = readLauncherLastView(orgId);
|
||||
if (lastView === null || lastView === activeViewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid =
|
||||
lastView === "default" ||
|
||||
views.some((view) => view.viewId === lastView);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseConfig = getLauncherUrlBaseConfig(
|
||||
lastView,
|
||||
views,
|
||||
defaultViewOverrides
|
||||
);
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: lastView,
|
||||
config: baseConfig
|
||||
});
|
||||
navigate({ searchParams: params, replace: true });
|
||||
}, [
|
||||
activeViewId,
|
||||
defaultViewOverrides,
|
||||
navigate,
|
||||
orgId,
|
||||
searchParams,
|
||||
views
|
||||
]);
|
||||
|
||||
const navigateToConfig = useCallback(
|
||||
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId,
|
||||
config: nextConfig
|
||||
});
|
||||
navigate({ searchParams: params });
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const debouncedNavigateSearch = useDebouncedCallback(
|
||||
(viewId: LauncherActiveViewId, query: string) => {
|
||||
navigateToConfig(viewId, { ...configRef.current, query });
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
const selectView = useCallback(
|
||||
(viewId: LauncherActiveViewId) => {
|
||||
writeLauncherLastView(orgId, viewId);
|
||||
const baseConfig = getLauncherUrlBaseConfig(
|
||||
viewId,
|
||||
views,
|
||||
defaultViewOverrides
|
||||
);
|
||||
navigateToConfig(viewId, baseConfig);
|
||||
},
|
||||
[defaultViewOverrides, navigateToConfig, orgId, views]
|
||||
);
|
||||
|
||||
const activeSavedView = useMemo(
|
||||
() =>
|
||||
activeViewId === "default"
|
||||
? null
|
||||
: views.find((view) => view.viewId === activeViewId),
|
||||
[activeViewId, views]
|
||||
);
|
||||
|
||||
const isDefaultView = activeViewId === "default";
|
||||
const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
|
||||
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
|
||||
const canResetSystemDefault =
|
||||
isDefaultView &&
|
||||
(Boolean(defaultViewOverrides.personal) ||
|
||||
(isAdmin && Boolean(defaultViewOverrides.orgWide)));
|
||||
|
||||
const selectedSites: Selectedsite[] = useMemo(
|
||||
() =>
|
||||
config.siteIds.map((siteId) => ({
|
||||
siteId,
|
||||
name: String(siteId),
|
||||
type: "newt"
|
||||
})),
|
||||
[config.siteIds]
|
||||
);
|
||||
|
||||
const selectedLabels: SelectedLabel[] = useMemo(
|
||||
() =>
|
||||
config.labelIds.map((labelId) => ({
|
||||
labelId,
|
||||
name: String(labelId),
|
||||
color: "#a1a1aa"
|
||||
})),
|
||||
[config.labelIds]
|
||||
);
|
||||
|
||||
const createViewMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
name: string;
|
||||
config: LauncherViewConfig;
|
||||
orgWide: boolean;
|
||||
}) => {
|
||||
const res = await api.post(`/org/${orgId}/launcher/views`, payload);
|
||||
return res.data.data as LauncherViewRecord;
|
||||
},
|
||||
onSuccess: (view) => {
|
||||
writeLauncherLastView(orgId, view.viewId);
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: view.viewId,
|
||||
config: view.config
|
||||
});
|
||||
navigate({ searchParams: params, replace: true });
|
||||
router.refresh();
|
||||
setSaveDialogOpen(false);
|
||||
setNewViewName("");
|
||||
toast({
|
||||
title: t("resourceLauncherViewSaved"),
|
||||
description: t("resourceLauncherViewSavedDescription")
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceLauncherViewSaveFailed"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("resourceLauncherViewSaveFailedDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const updateViewMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
viewId: number;
|
||||
name?: string;
|
||||
config?: LauncherViewConfig;
|
||||
orgWide?: boolean;
|
||||
}) => {
|
||||
const { viewId, ...body } = payload;
|
||||
const res = await api.put(
|
||||
`/org/${orgId}/launcher/views/${viewId}`,
|
||||
body
|
||||
);
|
||||
return res.data.data as LauncherViewRecord;
|
||||
},
|
||||
onSuccess: (view) => {
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: view.viewId,
|
||||
config: view.config
|
||||
});
|
||||
navigate({ searchParams: params, replace: true });
|
||||
router.refresh();
|
||||
toast({
|
||||
title: t("resourceLauncherViewSaved"),
|
||||
description: t("resourceLauncherViewSavedDescription")
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceLauncherViewSaveFailed"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("resourceLauncherViewSaveFailedDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const saveDefaultViewMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
config: LauncherViewConfig;
|
||||
orgWide: boolean;
|
||||
}) => {
|
||||
const res = await api.put(
|
||||
`/org/${orgId}/launcher/default-view`,
|
||||
payload
|
||||
);
|
||||
return res.data.data as LauncherViewRecord;
|
||||
},
|
||||
onSuccess: () => {
|
||||
writeLauncherLastView(orgId, "default");
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: "default",
|
||||
config
|
||||
});
|
||||
navigate({ searchParams: params, replace: true });
|
||||
router.refresh();
|
||||
toast({
|
||||
title: t("resourceLauncherViewSaved"),
|
||||
description: t("resourceLauncherViewSavedDescription")
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceLauncherViewSaveFailed"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("resourceLauncherViewSaveFailedDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const resetSystemDefaultMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const resetAll = isAdmin && Boolean(defaultViewOverrides.orgWide);
|
||||
await api.delete(`/org/${orgId}/launcher/default-view`, {
|
||||
data: resetAll ? { all: true } : { orgWide: false }
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
writeLauncherLastView(orgId, "default");
|
||||
const nextOverrides: LauncherDefaultViewOverrides = {
|
||||
personal: null,
|
||||
orgWide:
|
||||
isAdmin && defaultViewOverrides.orgWide
|
||||
? null
|
||||
: defaultViewOverrides.orgWide
|
||||
};
|
||||
const targetConfig =
|
||||
getEffectiveDefaultLauncherConfig(nextOverrides);
|
||||
searchInputRef.current = targetConfig.query;
|
||||
setSearchInputResetKey((key) => key + 1);
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: "default",
|
||||
config: targetConfig
|
||||
});
|
||||
navigate({ searchParams: params, replace: true });
|
||||
router.refresh();
|
||||
toast({
|
||||
title: t("resourceLauncherSystemDefaultRestored"),
|
||||
description: t(
|
||||
"resourceLauncherSystemDefaultRestoredDescription"
|
||||
)
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceLauncherViewSaveFailed"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("resourceLauncherViewSaveFailedDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const deleteViewMutation = useMutation({
|
||||
mutationFn: async (viewId: number) => {
|
||||
await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
writeLauncherLastView(orgId, "default");
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: "default",
|
||||
config: getLauncherUrlBaseConfig(
|
||||
"default",
|
||||
views,
|
||||
defaultViewOverrides
|
||||
)
|
||||
});
|
||||
navigate({ searchParams: params, replace: true });
|
||||
router.refresh();
|
||||
toast({
|
||||
title: t("resourceLauncherViewDeleted"),
|
||||
description: t("resourceLauncherViewDeletedDescription")
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceLauncherViewDeleteFailed"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("resourceLauncherViewDeleteFailedDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const applyConfigPatch = useCallback(
|
||||
(patch: Partial<LauncherViewConfig>) => {
|
||||
const nextConfig = {
|
||||
...configRef.current,
|
||||
...patch,
|
||||
query: searchInputRef.current
|
||||
};
|
||||
navigateToConfig(activeViewIdRef.current, nextConfig);
|
||||
},
|
||||
[navigateToConfig]
|
||||
);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
searchInputRef.current = "";
|
||||
setSearchInputResetKey((key) => key + 1);
|
||||
navigateToConfig(activeViewIdRef.current, {
|
||||
...configRef.current,
|
||||
query: "",
|
||||
siteIds: [],
|
||||
labelIds: []
|
||||
});
|
||||
}, [navigateToConfig]);
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
searchInputRef.current = savedConfig.query;
|
||||
setSearchInputResetKey((key) => key + 1);
|
||||
navigateToConfig(activeViewIdRef.current, savedConfig);
|
||||
}, [navigateToConfig, savedConfig]);
|
||||
|
||||
const handleResetSystemDefault = useCallback(() => {
|
||||
resetSystemDefaultMutation.mutate();
|
||||
}, [resetSystemDefaultMutation]);
|
||||
|
||||
const refreshData = () => {
|
||||
startRefreshTransition(async () => {
|
||||
try {
|
||||
await api.post(`/org/${orgId}/launcher/invalidate-cache`);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER"]
|
||||
});
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveToCurrent = () => {
|
||||
if (isDefaultView) {
|
||||
saveDefaultViewMutation.mutate({
|
||||
config,
|
||||
orgWide: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isOrgWideView && !isAdmin) {
|
||||
return;
|
||||
}
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeViewId,
|
||||
config
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveAsNew = () => {
|
||||
setSaveOrgWide(false);
|
||||
setNewViewName("");
|
||||
setSaveDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveForEveryone = () => {
|
||||
if (isDefaultView) {
|
||||
saveDefaultViewMutation.mutate({
|
||||
config,
|
||||
orgWide: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeViewId,
|
||||
orgWide: true
|
||||
});
|
||||
};
|
||||
|
||||
const handleMakePersonal = () => {
|
||||
if (isDefaultView) {
|
||||
return;
|
||||
}
|
||||
updateViewMutation.mutate({
|
||||
viewId: activeViewId,
|
||||
orgWide: false
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateView = () => {
|
||||
if (!newViewName.trim()) {
|
||||
return;
|
||||
}
|
||||
createViewMutation.mutate({
|
||||
name: newViewName.trim(),
|
||||
config,
|
||||
orgWide: saveOrgWide && isAdmin
|
||||
});
|
||||
};
|
||||
|
||||
const handleResourceSelect = useCallback((resource: LauncherResource) => {
|
||||
setSelectedResource(resource);
|
||||
setPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePanelOpenChange = useCallback((open: boolean) => {
|
||||
setPanelOpen(open);
|
||||
if (!open) {
|
||||
setSelectedResource(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const savedViewTabs = views.map((view) => ({
|
||||
viewId: view.viewId,
|
||||
name: view.name
|
||||
}));
|
||||
|
||||
const renderToolbarSearch = (searchClassName: string) => (
|
||||
<div className={cn("relative shrink-0", searchClassName)}>
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
key={`${activeViewId}-${searchInputResetKey}`}
|
||||
defaultValue={config.query}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
searchInputRef.current = value;
|
||||
debouncedNavigateSearch(activeViewIdRef.current, value);
|
||||
}}
|
||||
placeholder={t("resourceLauncherSearchPlaceholder")}
|
||||
className="pl-8"
|
||||
type="search"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderToolbarFilterSort = () => (
|
||||
<>
|
||||
<LauncherFilterPopover
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
selectedLabels={selectedLabels}
|
||||
onSitesChange={(sites) =>
|
||||
applyConfigPatch({
|
||||
siteIds: sites.map((site) => site.siteId)
|
||||
})
|
||||
}
|
||||
onLabelsChange={(labels) =>
|
||||
applyConfigPatch({
|
||||
labelIds: labels.map((label) => label.labelId)
|
||||
})
|
||||
}
|
||||
/>
|
||||
<LauncherSortButton
|
||||
order={config.order}
|
||||
onToggle={() =>
|
||||
applyConfigPatch({
|
||||
order: config.order === "asc" ? "desc" : "asc"
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderToolbarActions = () => (
|
||||
<>
|
||||
<LauncherSaveViewMenu
|
||||
isDefaultView={isDefaultView}
|
||||
isAdmin={isAdmin}
|
||||
isOrgWideView={isOrgWideView}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
canResetSystemDefault={canResetSystemDefault}
|
||||
onSaveToCurrent={handleSaveToCurrent}
|
||||
onSaveAsNew={handleSaveAsNew}
|
||||
onSaveForEveryone={handleSaveForEveryone}
|
||||
onMakePersonal={handleMakePersonal}
|
||||
onResetView={handleResetView}
|
||||
onResetSystemDefault={handleResetSystemDefault}
|
||||
onDeleteView={() => setDeleteDialogOpen(true)}
|
||||
/>
|
||||
<LauncherSettingsMenu
|
||||
config={config}
|
||||
capabilities={scale.capabilities}
|
||||
isCompactMode={scale.mode === "compact"}
|
||||
selectedGroupBy={effectiveConfig.groupBy}
|
||||
onConfigChange={applyConfigPatch}
|
||||
/>
|
||||
<LauncherRefreshButton
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isNavigating}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderToolbarViews = () => (
|
||||
<LauncherViewTabs
|
||||
activeViewId={activeViewId}
|
||||
savedViews={savedViewTabs}
|
||||
onSelectView={selectView}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" aria-busy={isNavigating}>
|
||||
<SettingsSectionTitle
|
||||
title={t("resourceLauncherTitle")}
|
||||
description={t("resourceLauncherDescription")}
|
||||
/>
|
||||
|
||||
{isDesktop ? (
|
||||
<div className="mb-6 flex w-full min-w-0 items-center gap-3">
|
||||
{renderToolbarSearch("w-64")}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{renderToolbarFilterSort()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
{renderToolbarViews()}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{renderToolbarActions()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{renderToolbarActions()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{renderToolbarSearch("w-full")}
|
||||
</div>
|
||||
{renderToolbarFilterSort()}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
{renderToolbarViews()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSearchFirstGate ? (
|
||||
<LauncherSearchFirstGate layout={config.layout} />
|
||||
) : showGroupList ? (
|
||||
<LauncherGroupList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={effectiveConfig}
|
||||
initialGroups={groups}
|
||||
groupsPagination={groupsPagination}
|
||||
onClearFilters={handleClearFilters}
|
||||
onResourceSelect={handleResourceSelect}
|
||||
/>
|
||||
) : showFlatResourceList ? (
|
||||
<LauncherFlatResourceList
|
||||
orgId={orgId}
|
||||
activeViewId={activeViewId}
|
||||
config={effectiveConfig}
|
||||
onClearFilters={handleClearFilters}
|
||||
onResourceSelect={handleResourceSelect}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<LauncherResourcePanel
|
||||
open={panelOpen}
|
||||
onOpenChange={handlePanelOpenChange}
|
||||
resource={selectedResource}
|
||||
orgId={orgId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
||||
{activeSavedView ? (
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
string={activeSavedView.name}
|
||||
title={t("resourceLauncherDeleteViewTitle")}
|
||||
buttonText={t("resourceLauncherDeleteViewConfirm")}
|
||||
dialog={<p>{t("resourceLauncherDeleteViewQuestion")}</p>}
|
||||
onConfirm={async () => {
|
||||
await deleteViewMutation.mutateAsync(
|
||||
activeSavedView.viewId
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("resourceLauncherSaveAsNewView")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("resourceLauncherSaveAsNewViewDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-view-name">
|
||||
{t("resourceLauncherViewNameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="new-view-name"
|
||||
value={newViewName}
|
||||
onChange={(event) =>
|
||||
setNewViewName(event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<div className="mt-4">
|
||||
<CheckboxWithLabel
|
||||
id="save-org-wide"
|
||||
aria-describedby="save-org-wide-desc"
|
||||
label={t("resourceLauncherSaveForEveryone")}
|
||||
checked={saveOrgWide}
|
||||
onCheckedChange={(checked) =>
|
||||
setSaveOrgWide(checked === true)
|
||||
}
|
||||
/>
|
||||
<p
|
||||
id="save-org-wide-desc"
|
||||
className="text-sm text-muted-foreground mt-2"
|
||||
>
|
||||
{t(
|
||||
"resourceLauncherSaveForEveryoneDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSaveDialogOpen(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateView}
|
||||
loading={createViewMutation.isPending}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user