Merge branch 'dev' into feat/command-bar

This commit is contained in:
Fred KISSIE
2026-07-08 00:33:28 +02:00
399 changed files with 32298 additions and 9787 deletions
+4 -4
View File
@@ -1,24 +1,24 @@
"use client";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
// 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 { supporterStatus } = useSupporterStatusContext();
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
return (
<>
{supporterStatus?.visible && (
{/* {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">
+12 -3
View File
@@ -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 -5
View File
@@ -39,7 +39,6 @@ export function CreateOrgLabelDialog({
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 }) {
@@ -84,11 +83,8 @@ export function CreateOrgLabelDialog({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelForm
disabled={!canManageLabels}
onSubmit={(data) => {
if (!canManageLabels) return;
startTransition(async () => createOrgLabel(data));
}}
/>
@@ -106,7 +102,7 @@ export function CreateOrgLabelDialog({
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting || !canManageLabels}
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelCreate")}
@@ -1,206 +0,0 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
import {
cleanForFQDN,
PrivateResourceForm,
isHostname,
type InternalResourceFormValues
} 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 CreatePrivateResourceDialog({
open,
setOpen,
orgId,
onSuccess,
initialSites
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
const [isSubmitting, startTransition] = useTransition();
function handleSubmit(values: InternalResourceFormValues) {
startTransition(async () => {
try {
let data = { ...values };
if (
(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") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
}
}
await api.put<
AxiosResponse<{ data: { siteResourceId: number } }>
>(`/org/${orgId}/site-resource`, {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
destination: data.destination ?? undefined,
enabled: true,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.destinationPort ?? undefined,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...(data.mode === "host" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" &&
data.authDaemonPort != null && {
authDaemonPort: data.authDaemonPort
})
}),
...(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))
: [],
userIds: data.users ? data.users.map((u) => u.id) : [],
clientIds: data.clients
? data.clients.map((c) => parseInt(c.id))
: []
});
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
}
});
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t(
"createInternalResourceDialogCreateClientResourceDescription"
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<PrivateResourceForm
variant="create"
open={open}
orgId={orgId}
formId="create-internal-resource-form"
onSubmit={handleSubmit}
onSubmitDisabledChange={setIsHttpModeDisabled}
initialSites={initialSites}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="create-internal-resource-form"
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+1 -6
View File
@@ -44,8 +44,6 @@ 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 }) {
@@ -90,12 +88,9 @@ export function EditOrgLabelDialog({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelForm
disabled={!canManageLabels}
defaultValue={label}
onSubmit={(data) => {
if (!canManageLabels) return;
startTransition(async () => editOrgLabel(data));
}}
/>
@@ -113,7 +108,7 @@ export function EditOrgLabelDialog({
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting || !canManageLabels}
disabled={isSubmitting}
loading={isSubmitting}
>
{t("labelEdit")}
@@ -1,225 +0,0 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
import {
cleanForFQDN,
PrivateResourceForm,
type InternalResourceData,
type InternalResourceFormValues,
isHostname
} from "./PrivateResourceForm";
type EditInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
resource: InternalResourceData;
orgId: string;
onSuccess?: () => void;
};
export default function EditPrivateResourceDialog({
open,
setOpen,
resource,
orgId,
onSuccess
}: EditInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const queryClient = useQueryClient();
const [isSubmitting, startTransition] = useTransition();
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) {
try {
let data = { ...values };
if (
(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") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data = { ...data, alias: aliasValue };
}
}
await api.post(`/site-resource/${resource.id}`, {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
niceId: data.niceId,
destination: data.destination ?? undefined,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.destinationPort ?? null,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...(data.mode === "host" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" && {
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))
});
await queryClient.invalidateQueries(
resourceQueries.siteResourceRoles({
siteResourceId: resource.id
})
);
await queryClient.invalidateQueries(
resourceQueries.siteResourceUsers({
siteResourceId: resource.id
})
);
await queryClient.invalidateQueries(
resourceQueries.siteResourceClients({
siteResourceId: resource.id
})
);
toast({
title: t("editInternalResourceDialogSuccess"),
description: t(
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"editInternalResourceDialogFailedToUpdateInternalResource"
)
),
variant: "destructive"
});
}
}
return (
<Credenza
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) setOpen(false);
}}
>
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
{t("editInternalResourceDialogEditClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t(
"editInternalResourceDialogUpdateResourceProperties",
{
resourceName: resource.name
}
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<PrivateResourceForm
variant="edit"
open={open}
resource={resource}
orgId={orgId}
siteResourceId={resource.id}
formId="edit-internal-resource-form"
onSubmit={(values) =>
startTransition(() => handleSubmit(values))
}
onSubmitDisabledChange={setIsHttpModeDisabled}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("editInternalResourceDialogCancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="edit-internal-resource-form"
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+77 -52
View File
@@ -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>
);
}
+11 -3
View File
@@ -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: fixed 2-column grid 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 items-start"
: "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"
? "justify-items-start 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
+15 -64
View File
@@ -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,16 +8,15 @@ 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";
import { Checkbox } from "./ui/checkbox";
function areSelectionsEqual(a: string[], b: string[]) {
if (a.length !== b.length) {
@@ -54,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
})
);
@@ -152,53 +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("accessFilterClear")}
</CommandItem>
)}
{labels.map((label) => (
<CommandItem
key={label.name}
value={label.name}
onSelect={() => {
toggle(label.name);
}}
className="flex items-center gap-2"
>
<Checkbox
className="pointer-events-none shrink-0"
checked={draftSet.has(label.name)}
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>
<LabelsFilterSelector
orgId={orgId}
isSelected={(label) => draftSet.has(label.name)}
onToggle={(label) => {
toggle(label.name);
}}
showClear={draftValues.length > 0}
onClear={() => {
setDraftValues([]);
}}
/>
</PopoverContent>
</Popover>
</div>
+127
View File
@@ -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>
);
}
+16 -2
View File
@@ -21,6 +21,8 @@ interface LayoutProps {
showHeader?: boolean;
showTopBar?: boolean;
defaultSidebarCollapsed?: boolean;
launcherMode?: boolean;
showViewAsAdmin?: boolean;
}
export async function Layout({
@@ -32,7 +34,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;
@@ -75,11 +79,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">
+45 -8
View File
@@ -8,17 +8,32 @@ 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";
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
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
@@ -54,16 +69,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 && (
+124 -30
View File
@@ -3,6 +3,14 @@
import type { SidebarNavSection } from "@app/app/navigation";
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
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 { Menu, Server, Settings, SquareMousePointer } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import ProfileIcon from "@app/components/ProfileIcon";
import { SidebarNav } from "@app/components/SidebarNav";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
@@ -29,6 +37,8 @@ interface LayoutMobileMenuProps {
navItems: SidebarNavSection[];
showSidebar: boolean;
showTopBar: boolean;
launcherMode?: boolean;
showViewAsAdmin?: boolean;
}
export function LayoutMobileMenu({
@@ -36,19 +46,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 +93,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 +118,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(
"resourceSidebarLauncherTitle"
)}
</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>
+101 -28
View File
@@ -18,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";
@@ -130,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(
@@ -152,6 +165,58 @@ 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"
)}
>
{isSidebarCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<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 px-2 py-2 justify-center"
)}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
<SquareMousePointer className="h-4 w-4" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
>
<p>
{t("resourceSidebarLauncherTitle")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<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 px-3 py-1.5"
)}
>
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
<SquareMousePointer className="h-4 w-4" />
</span>
<span className="flex-1">
{t("resourceSidebarLauncherTitle")}
</span>
</Link>
)}
</div>
)}
{!isAdminPage && user.serverAdmin && (
<div
className={cn(
@@ -159,36 +224,44 @@ export function LayoutSidebar({
isSidebarCollapsed ? "mb-4" : "mb-1"
)}
>
<Link
href="/admin"
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("serverAdmin")
: undefined
}
>
<span
{isSidebarCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/admin"
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 px-2 py-2 justify-center"
)}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
<Server className="h-4 w-4" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
>
<p>{t("serverAdmin")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link
href="/admin"
className={cn(
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
!isSidebarCollapsed && "mr-3"
"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 px-3 py-1.5"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<>
<span className="flex-1">
{t("serverAdmin")}
</span>
</>
)}
</Link>
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
<Server className="h-4 w-4" />
</span>
<span className="flex-1">
{t("serverAdmin")}
</span>
</Link>
)}
</div>
)}
<SidebarNav
+51 -18
View File
@@ -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";
@@ -31,15 +31,18 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
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 { 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 { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
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;
@@ -100,7 +103,9 @@ export default function MachineClientsTable({
const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const data = useQuery(productUpdatesQueries.latestVersion(true));
const latestPlatformVersions = data.data?.data;
const defaultMachineColumnVisibility = {
subnet: false,
@@ -375,6 +380,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 ? (
@@ -386,9 +422,9 @@ export default function MachineClientsTable({
) : (
"-"
)}
{/*originalRow.olmUpdateAvailable && (
<InfoPopup info={t("olmUpdateAvailableInfo")} />
)*/}
{updateAvailable && (
<InfoPopup info={t("updateAvailableInfo")} />
)}
</div>
);
}
@@ -397,11 +433,8 @@ export default function MachineClientsTable({
accessorKey: "subnet",
friendlyName: t("address"),
header: () => <span className="px-3">{t("address")}</span>
}
];
if (isLabelFeatureEnabled) {
baseColumns.push({
},
{
id: "labels",
accessorKey: "labels",
header: () => (
@@ -421,8 +454,8 @@ export default function MachineClientsTable({
orgId={orgId}
/>
)
});
}
}
];
// Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) {
@@ -504,7 +537,7 @@ export default function MachineClientsTable({
}
return baseColumns;
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
}, [hasRowsWithoutUserId, orgId, t, searchParams]);
function handleFilterChange(
column: string,
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -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>
-3
View File
@@ -21,9 +21,6 @@ import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import { LabelBadge } from "./label-badge";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
+72 -26
View File
@@ -12,6 +12,7 @@ import {
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { useRef } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
import { RolesSelector, type SelectedRole } from "./roles-selector";
@@ -41,6 +42,46 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
disabled
}: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations();
const isPopoverOpenRef = useRef(false);
const lastValidRolesRef = useRef<SelectedRole[]>(
(form.getValues(name) as SelectedRole[]) ?? []
);
function validateRolesSelection() {
const current = form.getValues(name) as SelectedRole[];
if (current.length === 0 && lastValidRolesRef.current.length > 0) {
form.setValue(name, lastValidRolesRef.current as never, {
shouldDirty: true
});
toast({
variant: "destructive",
title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease")
});
return false;
}
if (current.length > 0) {
lastValidRolesRef.current = current;
}
return true;
}
function handlePopoverOpenChange(open: boolean) {
isPopoverOpenRef.current = open;
if (open) {
const current = form.getValues(name) as SelectedRole[];
if (current.length > 0) {
lastValidRolesRef.current = current;
}
return;
}
validateRolesSelection();
}
function setRoleTags(nextValue: SelectedRole[]) {
const prev = form.getValues(name) as SelectedRole[];
@@ -61,39 +102,44 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
return;
}
if (next.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
form.setValue(name, next as never, { shouldDirty: true });
if (next.length > 0 && !isPopoverOpenRef.current) {
lastValidRolesRef.current = next;
} else if (!isPopoverOpenRef.current) {
validateRolesSelection();
}
}
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{label ?? t("roles")}</FormLabel>
<FormControl>
<RolesSelector
orgId={orgId}
selectedRoles={field.value ?? []}
onSelectRoles={setRoleTags}
disabled={disabled}
/>
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>{paywallMessage}</FormDescription>
)}
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const selectedRoles = (field.value ?? []) as SelectedRole[];
if (!isPopoverOpenRef.current && selectedRoles.length > 0) {
lastValidRolesRef.current = selectedRoles;
}
return (
<FormItem className="flex flex-col items-start">
<FormLabel>{label ?? t("roles")}</FormLabel>
<FormControl>
<RolesSelector
orgId={orgId}
selectedRoles={selectedRoles}
onSelectRoles={setRoleTags}
onPopoverOpenChange={handlePopoverOpenChange}
disabled={disabled}
/>
</FormControl>
{showMultiRolePaywallMessage && (
<FormDescription>{paywallMessage}</FormDescription>
)}
<FormMessage />
</FormItem>
);
}}
/>
);
}
+14
View File
@@ -111,6 +111,20 @@ function getActionsCategories(root: boolean) {
[t("actionUpdateResourceRule")]: "updateResourceRule"
},
"Resource Policy": {
[t("actionGetResourcePolicy")]: "getResourcePolicy",
[t("actionUpdateResourcePolicy")]: "updateResourcePolicy",
[t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers",
[t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles",
[t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword",
[t("actionSetResourcePolicyPincode")]: "setResourcePolicyPincode",
[t("actionSetResourcePolicyHeaderAuth")]:
"setResourcePolicyHeaderAuth",
[t("actionSetResourcePolicyWhitelist")]:
"setResourcePolicyWhitelist",
[t("actionSetResourcePolicyRules")]: "setResourcePolicyRules"
},
Client: {
[t("actionCreateClient")]: "createClient",
[t("actionDeleteClient")]: "deleteClient",
File diff suppressed because it is too large Load Diff
+58 -67
View File
@@ -2,8 +2,6 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog";
import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import {
ResourceSitesStatusCell,
@@ -34,12 +32,14 @@ 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 { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
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,
ArrowRight,
ArrowUp10Icon,
ArrowUpDown,
ChevronsUpDownIcon,
@@ -47,6 +47,7 @@ import {
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 { useDebouncedCallback } from "use-debounce";
@@ -78,6 +79,7 @@ export type InternalResourceRow = {
alias: string | null;
aliasAddress: string | null;
niceId: string;
enabled: boolean;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
@@ -138,6 +140,7 @@ export default function PrivateResourcesTable({
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isNavigatingToAddPage, startNavigation] = useTransition();
const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>(null);
@@ -149,7 +152,6 @@ export default function PrivateResourcesTable({
const [isRefreshing, startRefreshTransition] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
// useEffect(() => {
// const interval = setInterval(() => {
@@ -184,11 +186,11 @@ export default function PrivateResourcesTable({
});
});
} 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"))
});
}
};
@@ -427,6 +429,27 @@ export default function PrivateResourcesTable({
);
}
},
{
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
<ClientResourceLabelCell
resource={row.original}
orgId={orgId}
/>
)
},
{
id: "actions",
enableHiding: false,
@@ -448,6 +471,17 @@ export default function PrivateResourcesTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={getPrivateResourceSettingsHref(
resourceRow.orgId,
resourceRow.niceId
)}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
@@ -462,47 +496,25 @@ export default function PrivateResourcesTable({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
<Link
href={getPrivateResourceSettingsHref(
resourceRow.orgId,
resourceRow.niceId
)}
>
{t("edit")}
</Button>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
<ClientResourceLabelCell
resource={row.original}
orgId={orgId}
/>
)
});
}
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
}, [orgId, t, searchParams]);
function handleFilterChange(
column: string,
@@ -578,8 +590,15 @@ export default function PrivateResourcesTable({
tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchQuery={searchParams.get("query")?.toString()}
onAdd={() => setIsCreateDialogOpen(true)}
onAdd={() =>
startNavigation(() =>
router.push(
`/${orgId}/settings/resources/private/create`
)
)
}
addButtonText={t("resourceAdd")}
isNavigatingToAddPage={isNavigatingToAddPage}
onSearch={handleSearchChange}
onRefresh={refreshData}
onPaginationChange={handlePaginationChange}
@@ -595,34 +614,6 @@ export default function PrivateResourcesTable({
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
{editingResource && (
<EditPrivateResourceDialog
open={isEditDialogOpen}
setOpen={setIsEditDialogOpen}
resource={editingResource}
orgId={orgId}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
setEditingResource(null);
}, 150);
}}
/>
)}
<CreatePrivateResourceDialog
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
}, 150);
}}
/>
</>
);
}
+57 -26
View File
@@ -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 -23
View File
@@ -151,7 +151,6 @@ export default function PublicResourcesTable({
useState<ResourceRow | null>();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
@@ -552,6 +551,24 @@ export default function PublicResourcesTable({
/>
)
},
{
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: ResourceRow } }) => (
<ResourceLabelCell resource={row.original} orgId={orgId} />
)
},
{
id: "actions",
enableHiding: false,
@@ -607,29 +624,8 @@ export default function PublicResourcesTable({
}
];
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
id: "labels",
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: ResourceRow } }) => (
<ResourceLabelCell resource={row.original} orgId={orgId} />
)
});
}
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
}, [orgId, t, searchParams]);
function handleFilterChange(
column: string,
+13 -4
View File
@@ -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;
}
+5 -1
View File
@@ -90,7 +90,11 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionTitle>
<InfoSectionContent>
<span className="inline-flex items-center">
{resource.ssl ? "HTTPS" : "HTTP"}
{resource.mode == "http"
? resource.ssl
? "HTTPS"
: "HTTP"
: resource.mode?.toUpperCase()}
</span>
</InfoSectionContent>
</InfoSection>
+5 -13
View File
@@ -61,7 +61,7 @@ export function parseUnixGroups(value: string | undefined): string[] {
if (!value?.trim()) return [];
return value
.split(/[,\s\n]+/)
.split(/\r?\n/)
.map((group) => group.trim())
.filter(Boolean);
}
@@ -69,18 +69,10 @@ export function parseUnixGroups(value: string | undefined): string[] {
export function parseSudoCommands(value: string | undefined): string[] {
if (!value?.trim()) return [];
const commands: string[] = [];
for (const segment of value.split(/[,\n]+/)) {
const trimmed = segment.trim();
if (!trimmed) continue;
for (const part of trimmed.split(/ (?=\/)/)) {
const command = part.trim();
if (command) commands.push(command);
}
}
return commands;
return value
.split(/\r?\n/)
.map((command) => command.trim())
.filter(Boolean);
}
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
+164
View File
@@ -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
};
+6 -1
View File
@@ -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)}&nbsp;
<span>
{site.countryCode &&
countryCodeToFlagEmoji(site.countryCode)}
</span>
</InfoSectionContent>
</InfoSection>
) : null;
+267
View File
@@ -0,0 +1,267 @@
"use client";
import CertificateStatus from "@app/components/CertificateStatus";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
import { formatPortRestrictionDisplay } from "@app/lib/launcherResourceDetails";
import {
formatSiteResourceAccess,
formatSiteResourceDestinationDisplay,
isSafeUrlForLink,
type LauncherAccessFields
} from "@app/lib/launcherResourceAccess";
import type { PrivateResourceMode } from "@app/lib/privateResourceForm";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
type SiteResourceInfoInput = {
orgId: string;
mode: PrivateResourceMode;
destination: string | null;
destinationPort: number | null;
scheme: "http" | "https" | null;
ssl: boolean;
domainId?: string | null;
fullDomain?: string | null;
alias?: string | null;
aliasAddress?: string | null;
authDaemonMode?: "site" | "remote" | "native" | null;
tcpPortRangeString?: string | null;
udpPortRangeString?: string | null;
};
type SiteResourceInfoBoxVariant = "settings" | "panel";
type SiteResourceInfoSectionsProps = {
siteResource: SiteResourceInfoInput;
access: LauncherAccessFields;
variant: SiteResourceInfoBoxVariant;
accessClassName?: string;
};
function AccessMethodContent({
accessDisplay,
accessCopyValue,
accessUrl,
className
}: LauncherAccessFields & { className?: string }) {
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
className={className}
/>
);
}
return (
<CopyToClipboard
text={accessCopyValue || accessDisplay}
displayText={accessDisplay}
isLink={false}
className={className}
/>
);
}
export function SiteResourceInfoSections({
siteResource,
access,
variant,
accessClassName
}: SiteResourceInfoSectionsProps) {
const t = useTranslations();
const isPanel = variant === "panel";
const modeLabel: Record<PrivateResourceMode, string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
};
const destination = formatSiteResourceDestinationDisplay({
mode: siteResource.mode,
destination: siteResource.destination,
destinationPort: siteResource.destinationPort,
scheme: siteResource.scheme
});
const portRestrictions = formatPortRestrictionDisplay({
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
udpPortRangeString: siteResource.udpPortRangeString ?? "*"
});
const showAlias =
siteResource.mode !== "cidr" && siteResource.mode !== "http";
const showDestination = !(
siteResource.mode === "ssh" && siteResource.authDaemonMode === "native"
);
const showCertificate = !!(
siteResource.mode === "http" &&
siteResource.ssl &&
siteResource.domainId &&
siteResource.fullDomain &&
build != "oss"
);
const numSections =
2 +
(showDestination ? 1 : 0) +
(showAlias ? 1 : 0) +
(showCertificate ? 1 : 0) +
(isPanel ? 1 : 0);
const sections = (
<InfoSections cols={numSections} layout={isPanel ? "panel" : "default"}>
<InfoSection>
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
<InfoSectionContent>
{modeLabel[siteResource.mode]}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={access.accessDisplay}
accessCopyValue={access.accessCopyValue}
accessUrl={access.accessUrl}
className={accessClassName}
/>
</InfoSectionContent>
</InfoSection>
{showDestination ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogDestination")}
</InfoSectionTitle>
<InfoSectionContent>
{destination || "-"}
</InfoSectionContent>
</InfoSection>
) : null}
{showAlias ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogAlias")}
</InfoSectionTitle>
<InfoSectionContent>
{siteResource.alias?.trim() ? siteResource.alias : "-"}
</InfoSectionContent>
</InfoSection>
) : null}
{showCertificate ? (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
defaultValue: "Certificate"
})}
</InfoSectionTitle>
<InfoSectionContent>
<CertificateStatus
orgId={siteResource.orgId}
domainId={siteResource.domainId!}
fullDomain={siteResource.fullDomain!}
autoFetch={true}
showLabel={false}
polling={true}
/>
</InfoSectionContent>
</InfoSection>
) : null}
{isPanel ? (
<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>
) : null}
</InfoSections>
);
if (isPanel) {
return sections;
}
return (
<Alert>
<AlertDescription>{sections}</AlertDescription>
</Alert>
);
}
type SiteResourceInfoBoxProps = {
variant?: "settings";
};
export default function SiteResourceInfoBox({
variant = "settings"
}: SiteResourceInfoBoxProps) {
const { siteResource } = useSiteResourceContext();
const access = formatSiteResourceAccess({
mode: siteResource.mode,
destination: siteResource.destination,
destinationPort: siteResource.destinationPort,
scheme: siteResource.scheme,
ssl: siteResource.ssl,
fullDomain: siteResource.fullDomain ?? null,
alias: siteResource.alias ?? null,
aliasAddress: siteResource.aliasAddress ?? null
});
return (
<SiteResourceInfoSections
siteResource={siteResource}
access={access}
variant={variant}
/>
);
}
+8 -13
View File
@@ -7,6 +7,7 @@ import { SettingsContainer } from "@app/components/Settings";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { ListResourcesResponse } from "@server/routers/resource";
import type ResponseT from "@server/types/Response";
@@ -432,19 +433,13 @@ export default function SiteResourcesOverview({
editHref: `/${orgId}/settings/resources/public/${r.niceId}`
}));
const privateRows = privateList.map((row) => {
const qs = new URLSearchParams({
siteId: String(siteId),
query: row.niceId
});
return {
key: row.siteResourceId,
meta: <PrivateResourceMeta row={row} />,
name: row.name,
access: <PrivateAccessMethod row={row} />,
editHref: `/${orgId}/settings/resources/private?${qs.toString()}`
};
});
const privateRows = privateList.map((row) => ({
key: row.siteResourceId,
meta: <PrivateResourceMeta row={row} />,
name: row.name,
access: <PrivateAccessMethod row={row} />,
editHref: getPrivateResourceSettingsHref(orgId, row.niceId)
}));
if (showEmptyPlaceholder) {
return (
+139 -32
View File
@@ -19,6 +19,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
@@ -52,9 +53,11 @@ import {
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
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;
@@ -101,18 +104,25 @@ 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();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { data: latestVersions } = useQuery(
productUpdatesQueries.latestVersion(true)
);
const latestNewtVersion = latestVersions?.data?.newt?.latestVersion;
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
@@ -148,10 +158,33 @@ 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({
@@ -326,6 +359,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">
@@ -339,7 +377,7 @@ export default function SitesTable({
)}
</div>
</Badge>
{originalRow.newtUpdateAvailable && (
{updateAvailable && (
<InfoPopup
info={t("newtUpdateAvailableInfo")}
/>
@@ -460,6 +498,23 @@ export default function SitesTable({
);
}
},
{
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: SiteRow } }) => (
<SiteLabelCell site={row.original} orgId={orgId} />
)
},
{
id: "actions",
enableHiding: false,
@@ -507,16 +562,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("delete")}
{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
@@ -533,28 +619,8 @@ export default function SitesTable({
}
];
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
accessorKey: "labels",
header: () => (
<LabelColumnFilterButton
orgId={orgId}
selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
),
cell: ({ row }: { row: { original: SiteRow } }) => (
<SiteLabelCell site={row.original} orgId={orgId} />
)
});
}
return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
}, [orgId, t, searchParams, latestNewtVersion]);
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
@@ -619,25 +685,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")
}
/>
)}
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { SettingsFormCell } from "@app/components/Settings";
import {
StrategySelect,
type StrategyOption
} from "@app/components/StrategySelect";
import { Badge } from "@app/components/ui/badge";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
export type SshServerSettingsFormFields = {
pamMode: "passthrough" | "push";
standardDaemonLocation: "site" | "remote";
authDaemonPort: string;
};
type SshServerSettingsFieldsProps = {
pamMode: "passthrough" | "push";
standardDaemonLocation: "site" | "remote";
authDaemonPort: string;
onPamModeChange: (value: "passthrough" | "push") => void;
onStandardDaemonLocationChange: (value: "site" | "remote") => void;
onAuthDaemonPortChange: (value: string) => void;
authDaemonPortError?: string;
sshServerMode: "standard" | "native";
serverModeDisplay: "badge" | "select";
onServerModeChange?: (mode: "standard" | "native") => void;
sshServerModeOptions?: StrategyOption<"standard" | "native">[];
idPrefix?: string;
};
export function SshServerSettingsFields({
pamMode,
standardDaemonLocation,
authDaemonPort,
onPamModeChange,
onStandardDaemonLocationChange,
onAuthDaemonPortChange,
authDaemonPortError,
sshServerMode,
serverModeDisplay,
onServerModeChange,
sshServerModeOptions,
idPrefix = "ssh-server"
}: SshServerSettingsFieldsProps) {
const t = useTranslations();
const isNative = sshServerMode === "native";
const showDaemonLocation = !isNative && pamMode === "push";
const showDaemonPort =
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
const authMethodOptions = useMemo(
(): StrategyOption<"passthrough" | "push">[] => [
{
id: "passthrough",
title: t("sshAuthMethodManual"),
description: t("sshAuthMethodManualDescription")
},
{
id: "push",
title: t("sshAuthMethodAutomated"),
description: t("sshAuthMethodAutomatedDescription")
}
],
[t]
);
const daemonLocationOptions = useMemo(
(): StrategyOption<"site" | "remote">[] => [
{
id: "site",
title: t("internalResourceAuthDaemonSite"),
description: t("sshDaemonLocationSiteDescription")
},
{
id: "remote",
title: t("sshDaemonLocationRemote"),
description: t("sshDaemonLocationRemoteDescription")
}
],
[t]
);
const defaultSshServerModeOptions = useMemo(
(): StrategyOption<"standard" | "native">[] => [
{
id: "native",
title: t("sshServerModePangolin"),
description: t("sshServerModeNativeDescription")
},
{
id: "standard",
title: t("sshServerModeStandard"),
description: t("sshServerModeStandardDescription")
}
],
[t]
);
const modeOptions = sshServerModeOptions ?? defaultSshServerModeOptions;
return (
<>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshServerMode")}
</p>
{serverModeDisplay === "badge" ? (
<Badge variant="secondary">
{sshServerMode === "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
) : (
<StrategySelect<"standard" | "native">
idPrefix={`${idPrefix}-mode`}
value={sshServerMode}
options={modeOptions}
onChange={(value) => onServerModeChange?.(value)}
cols={2}
/>
)}
</div>
</SettingsFormCell>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthenticationMethod")}
</p>
<StrategySelect<"passthrough" | "push">
idPrefix={`${idPrefix}-auth`}
value={pamMode}
options={authMethodOptions}
onChange={onPamModeChange}
cols={2}
/>
</div>
</SettingsFormCell>
{showDaemonLocation && (
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthDaemonLocation")}
</p>
<StrategySelect<"site" | "remote">
idPrefix={`${idPrefix}-daemon`}
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={onStandardDaemonLocationChange}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/ssh"
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>
</SettingsFormCell>
)}
{showDaemonPort && (
<SettingsFormCell span="half">
<div className="grid gap-2">
<Label htmlFor={`${idPrefix}-daemon-port`}>
{t("sshDaemonPort")}
</Label>
<Input
id={`${idPrefix}-daemon-port`}
type="number"
min={1}
max={65535}
value={authDaemonPort}
onChange={(e) =>
onAuthDaemonPortChange(e.target.value)
}
/>
{authDaemonPortError ? (
<p className="text-destructive text-sm">
{authDaemonPortError}
</p>
) : null}
</div>
</SettingsFormCell>
)}
</>
);
}
+43 -35
View File
@@ -18,6 +18,7 @@ interface StrategySelectProps<TValue extends string> {
defaultValue?: TValue;
onChange?: (value: TValue) => void;
cols?: number;
idPrefix?: string;
}
export function StrategySelect<TValue extends string>({
@@ -25,7 +26,8 @@ export function StrategySelect<TValue extends string>({
value: controlledValue,
defaultValue,
onChange,
cols = 1
cols = 1,
idPrefix = "strategy"
}: StrategySelectProps<TValue>) {
const [uncontrolledSelected, setUncontrolledSelected] = useState<
TValue | undefined
@@ -49,43 +51,49 @@ export function StrategySelect<TValue extends string>({
}}
className="grid md:grid-cols-(--cols) gap-4"
>
{options.map((option: StrategyOption<TValue>) => (
<label
key={option.id}
htmlFor={option.id}
data-state={
selected === option.id ? "checked" : "unchecked"
}
className={cn(
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
option.disabled
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
: selected === option.id
? "border-primary bg-primary/10 text-primary"
: "border-input hover:bg-accent"
)}
>
<RadioGroupItem
value={option.id}
id={option.id}
disabled={option.disabled}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="flex gap-3 pl-7">
{option.icon && (
<div className="mt-1">{option.icon}</div>
{options.map((option: StrategyOption<TValue>) => {
const optionId = `${idPrefix}-${option.id}`;
return (
<label
key={option.id}
htmlFor={optionId}
data-state={
selected === option.id ? "checked" : "unchecked"
}
className={cn(
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
option.disabled
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
: selected === option.id
? "border-primary bg-primary/10 text-primary"
: "border-input hover:bg-accent"
)}
<div className="flex-1">
<div className="font-medium">{option.title}</div>
<div className="text-sm text-muted-foreground">
{typeof option.description === "string"
? option.description
: option.description}
>
<RadioGroupItem
value={option.id}
id={optionId}
disabled={option.disabled}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="flex gap-3 pl-7">
{option.icon && (
<div className="mt-1">{option.icon}</div>
)}
<div className="flex-1">
<div className="font-medium">
{option.title}
</div>
<div className="text-sm text-muted-foreground">
{typeof option.description === "string"
? option.description
: option.description}
</div>
</div>
</div>
</div>
</label>
))}
</label>
);
})}
</RadioGroup>
);
}
+28 -27
View File
@@ -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>
);
}
+110 -110
View File
@@ -3,7 +3,7 @@
// 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 { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useState, useTransition } from "react";
import {
Tooltip,
@@ -58,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);
@@ -469,7 +469,7 @@ export default function SupporterStatus({
{t("supportKeyBuy")}
</Button>
)
) : null}
) : null} */}
</>
);
}
+44 -4
View File
@@ -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,
+15
View File
@@ -38,6 +38,21 @@ export type LabelsSelectorProps = {
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => 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",
@@ -12,7 +12,12 @@ 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;
@@ -77,6 +82,14 @@ export function MultiSelectContent<T extends TagValue>({
aria-hidden
tabIndex={-1}
/>
{option.color && (
<span
className="size-2 rounded-full flex-none"
style={{
backgroundColor: option.color
}}
/>
)}
{`${option.text}`}
</CommandItem>
);
@@ -17,11 +17,13 @@ export interface MultiSelectInputProps<
> extends MultiSelectTagsProps<T> {
buttonText?: string;
lockedIds?: Set<string>;
onPopoverOpenChange?: (open: boolean) => void;
}
export function MultiSelectTagInput<T extends TagValue>({
buttonText,
lockedIds,
onPopoverOpenChange,
...props
}: MultiSelectInputProps<T>) {
const selectedValues = new Set(props.value.map((v) => v.id));
@@ -33,6 +35,7 @@ export function MultiSelectTagInput<T extends TagValue>({
// clear input when popover is closed
props.onSearch("");
}
onPopoverOpenChange?.(open);
}}
>
<PopoverTrigger asChild>
@@ -66,7 +69,17 @@ export function MultiSelectTagInput<T extends TagValue>({
)}
onClick={(e) => e.stopPropagation()}
>
<span>{option.text}</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" />
+33 -6
View File
@@ -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}
+33 -24
View File
@@ -20,6 +20,7 @@ import {
} 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 };
@@ -50,9 +51,12 @@ export function NewtSiteInstallCommands({
version = "latest"
}: NewtSiteInstallCommandsProps) {
const t = useTranslations();
const { env } = useEnvContext();
const [acceptClients, setAcceptClients] = useState(true);
const [allowPangolinSsh, setAllowPangolinSsh] = useState(true);
const [allowPangolinSsh, setAllowPangolinSsh] = useState(
!env.flags.disableEnterpriseFeatures
);
const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState(
() => getArchitectures(platform)[0]
@@ -71,7 +75,11 @@ export function NewtSiteInstallCommands({
: "";
const disableSshFlag =
supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : "";
supportsSshOption &&
!allowPangolinSsh &&
!env.flags.disableEnterpriseFeatures
? " --disable-ssh"
: "";
const runAsRootPrefix =
supportsSshOption && allowPangolinSsh ? "sudo " : "";
@@ -131,7 +139,6 @@ Restart=always
RestartSec=2
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
[Install]
@@ -306,27 +313,29 @@ WantedBy=default.target`
>
{t("siteAcceptClientConnectionsDescription")}
</p>
{supportsSshOption && (
<>
<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>
</>
)}
{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>
)}
@@ -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,470 @@
"use client";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { SiteResourceInfoSections } from "@app/components/SiteResourceInfoBox";
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,
formatPublicResourceType
} from "@app/lib/launcherResourceDetails";
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import { 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();
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>
<SiteResourceInfoSections
siteResource={resource}
access={{
accessDisplay: launcherResource.accessDisplay,
accessCopyValue: launcherResource.accessCopyValue,
accessUrl: launcherResource.accessUrl
}}
variant="panel"
accessClassName="text-base"
/>
</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,820 @@
"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)}>
<span className="pointer-events-none absolute inset-y-0 left-2 flex items-center">
<Search className="size-4 text-muted-foreground" />
</span>
<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>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useToast } from "@app/hooks/useToast";
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
import { useTranslations } from "next-intl";
import { useCallback, type KeyboardEvent, type MouseEvent } from "react";
type LauncherResourceActionInput = {
accessUrl?: string | null;
accessCopyValue: string;
};
export function useLauncherResourceAction({
accessUrl,
accessCopyValue
}: LauncherResourceActionInput) {
const { toast } = useToast();
const t = useTranslations();
const href = accessUrl ?? undefined;
const canLink = Boolean(href && isSafeUrlForLink(href));
const isClickable = canLink || Boolean(accessCopyValue);
const handleAction = useCallback(() => {
if (canLink && href) {
window.open(href, "_blank", "noopener,noreferrer");
return;
}
if (!accessCopyValue) {
return;
}
void navigator.clipboard.writeText(accessCopyValue).then(() => {
toast({
title: t("resourceLauncherCopiedToClipboard"),
description: t("resourceLauncherCopiedAccessDescription"),
duration: 2000
});
});
}, [accessCopyValue, canLink, href, t, toast]);
return { handleAction, isClickable };
}
export function isLauncherResourceInteractiveTarget(
target: EventTarget | null,
container?: EventTarget | null
): boolean {
if (!(target instanceof Element)) {
return false;
}
const interactive = target.closest(
"a, button, [role='button'], input, textarea, select"
);
if (!interactive) {
return false;
}
if (container instanceof Element && interactive === container) {
return false;
}
return true;
}
function handleLauncherResourceClick(
event: MouseEvent,
handleAction: () => void
) {
if (
isLauncherResourceInteractiveTarget(event.target, event.currentTarget)
) {
return;
}
handleAction();
}
export function getLauncherResourceSelectProps(onSelect: () => void) {
return {
onClick: (event: MouseEvent) => {
if (
isLauncherResourceInteractiveTarget(
event.target,
event.currentTarget
)
) {
return;
}
onSelect();
},
className: "cursor-pointer",
role: "button" as const,
tabIndex: 0,
onKeyDown: (event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect();
}
}
};
}
export function getLauncherResourceClickProps(
handleAction: () => void,
isClickable: boolean
) {
return {
onClick: (event: MouseEvent) =>
handleLauncherResourceClick(event, handleAction),
className: isClickable ? "cursor-pointer" : undefined,
role: isClickable ? ("button" as const) : undefined,
tabIndex: isClickable ? 0 : undefined,
onKeyDown: isClickable
? (event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleAction();
}
}
: undefined
};
}
@@ -340,7 +340,8 @@ function PolicyAccessRulesSectionEdit({
? rules.filter((rule) => !rule.fromPolicy)
: rules;
const rulesPayload = rulesToValidate.map(
({ action, match, value, priority, enabled }) => ({
({ ruleId, action, match, value, priority, enabled, new: isNew }) => ({
...(isNew ? {} : { ruleId }),
action,
match,
value,
@@ -74,6 +74,7 @@ import {
sortPolicyRulesForResourceOverlay,
type PolicyAccessRule
} from "./policy-access-rule-utils";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
export type PolicyAccessRulesTableProps = {
rules: PolicyAccessRule[];
@@ -490,8 +491,17 @@ export function PolicyAccessRulesTable({
{
accessorKey: "value",
header: () => <span className="p-3">{t("value")}</span>,
cell: ({ row }) =>
row.original.match === "COUNTRY" ? (
cell: ({ row }) => {
let selectedCountry: (typeof COUNTRIES)[number] | undefined;
if (
row.original.match === "COUNTRY" &&
row.original.value
) {
selectedCountry = COUNTRIES.find(
(c) => c.code === row.original.value
);
}
return row.original.match === "COUNTRY" ? (
<Popover>
<PopoverTrigger asChild>
<Button
@@ -502,15 +512,22 @@ export function PolicyAccessRulesTable({
}
className="w-full min-w-0 justify-between"
>
{row.original.value
? COUNTRIES.find(
(c) =>
c.code === row.original.value
)?.name +
" (" +
row.original.value +
")"
: t("selectCountry")}
{selectedCountry ? (
<>
<span>
{selectedCountry.code === "ALL"
? "🌍"
: countryCodeToFlagEmoji(
selectedCountry.code
)}
&nbsp;&nbsp;
{selectedCountry.name} (
{selectedCountry.code})
</span>
</>
) : (
t("selectCountry")
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -540,6 +557,13 @@ export function PolicyAccessRulesTable({
<Check
className={`mr-2 h-4 w-4 ${row.original.value === country.code ? "opacity-100" : "opacity-0"}`}
/>
<span>
{country.code === "ALL"
? "🌍"
: countryCodeToFlagEmoji(
country.code
)}
</span>
{country.name} (
{country.code})
</CommandItem>
@@ -767,7 +791,8 @@ export function PolicyAccessRulesTable({
});
}}
/>
)
);
}
},
{
accessorKey: "enabled",
@@ -83,9 +83,19 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
{ message: t("rulesErrorInvalidCountryDescription") }
);
case "ASN":
return required.refine((value) => /^AS\d+$/i.test(value.trim()), {
message: t("rulesErrorInvalidAsnDescription")
});
return required.refine(
(value) => {
const normalizedValue = value.trim().toUpperCase();
return (
/^AS\d+$/.test(normalizedValue) ||
normalizedValue === "ALL" ||
normalizedValue === "AS0"
);
},
{
message: t("rulesErrorInvalidAsnDescription")
}
);
default:
return required;
}
+3
View File
@@ -12,6 +12,7 @@ export type RolesSelectorProps = {
orgId: string;
selectedRoles?: SelectedRole[];
onSelectRoles: (roles: SelectedRole[]) => void;
onPopoverOpenChange?: (open: boolean) => void;
disabled?: boolean;
restrictAdminRole?: boolean;
mapRolesByName?: boolean;
@@ -23,6 +24,7 @@ export function RolesSelector({
orgId,
selectedRoles = [],
onSelectRoles,
onPopoverOpenChange,
disabled,
restrictAdminRole,
mapRolesByName,
@@ -77,6 +79,7 @@ export function RolesSelector({
options={rolesShown}
value={selectedRoles}
onChange={onSelectRoles}
onPopoverOpenChange={onPopoverOpenChange}
disabled={disabled}
lockedIds={lockedIds}
/>