mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-19 03:56:33 +02:00
Merge branch 'dev' into feat/remember-last-idp-on-smart-login-form
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher";
|
||||
import { commandBarNavSections } from "@app/app/navigation";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { fetchLauncherPageData } from "@app/lib/launcherServerData";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||
@@ -57,6 +59,8 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
} catch (e) {}
|
||||
|
||||
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
||||
const env = pullEnv();
|
||||
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
|
||||
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
const launcherData = overview
|
||||
@@ -73,6 +77,9 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={[]}
|
||||
commandNavItems={commandBarNavSections(env, {
|
||||
isPrimaryOrg: primaryOrg
|
||||
})}
|
||||
showSidebar={false}
|
||||
launcherMode
|
||||
showViewAsAdmin={isAdminOrOwner}
|
||||
@@ -82,9 +89,11 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
orgId={orgId}
|
||||
isAdmin={isAdminOrOwner}
|
||||
views={launcherData.views}
|
||||
defaultViewOverrides={launcherData.defaultViewOverrides}
|
||||
activeViewId={launcherData.activeViewId}
|
||||
config={launcherData.config}
|
||||
savedConfig={launcherData.savedConfig}
|
||||
scale={launcherData.scale}
|
||||
groups={launcherData.groups}
|
||||
groupsPagination={launcherData.groupsPagination}
|
||||
/>
|
||||
|
||||
@@ -290,7 +290,13 @@ export default function BillingPage() {
|
||||
setHasSubscription(
|
||||
tierSub.subscription.status === "active"
|
||||
);
|
||||
setIsTrial(tierSub.subscription.expiresAt != null);
|
||||
// expiresAt is only meaningful while the trial hasn't
|
||||
// actually run out yet; a stale row with a past
|
||||
// expiresAt should no longer be treated as a live trial
|
||||
const expiresAt = tierSub.subscription.expiresAt;
|
||||
setIsTrial(
|
||||
expiresAt != null && expiresAt * 1000 > Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
// Find license subscription
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function AccessControlsPage() {
|
||||
if (values.roles.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
title: t("accessRoleRequired"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return;
|
||||
@@ -173,15 +173,13 @@ export default function AccessControlsPage() {
|
||||
if (values.roles.length === 0) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleErrorAdd"),
|
||||
title: t("accessRoleRequired"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const willHaveAdminRole = values.roles.some(
|
||||
(r) => r.isAdmin === true
|
||||
);
|
||||
const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
|
||||
|
||||
const isRemovingOwnAdmin =
|
||||
sessionUser.userId === user.userId &&
|
||||
@@ -226,7 +224,9 @@ export default function AccessControlsPage() {
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => void handleAccessControlsSubmit(e)}
|
||||
onSubmit={(e) =>
|
||||
void handleAccessControlsSubmit(e)
|
||||
}
|
||||
className="space-y-4"
|
||||
id="access-controls-form"
|
||||
>
|
||||
|
||||
@@ -195,9 +195,9 @@ export default function GeneralPage() {
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
client.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
updateAvailable = Boolean(
|
||||
semver.valid(client.olmVersion) &&
|
||||
semver.lt(client.olmVersion, agentVersion.latestVersion)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-4
@@ -3,9 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
@@ -51,8 +49,6 @@ export default async function LabelsPage({ params, searchParams }: Props) {
|
||||
description={t("orgLabelsDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||
|
||||
<OrgLabelsTable
|
||||
labels={labels}
|
||||
orgId={orgId}
|
||||
@@ -12,7 +12,7 @@ import UserProvider from "@app/providers/UserProvider";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { orgNavSections } from "@app/app/navigation";
|
||||
import { commandBarNavSections, orgNavSections } from "@app/app/navigation";
|
||||
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -82,6 +82,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
navItems={orgNavSections(env, {
|
||||
isPrimaryOrg: primaryOrg
|
||||
})}
|
||||
commandNavItems={commandBarNavSections(env, {
|
||||
isPrimaryOrg: primaryOrg
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Layout>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { build } from "@server/build";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import axios from "axios";
|
||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
@@ -343,7 +344,10 @@ export default function GeneralPage() {
|
||||
<Link
|
||||
href={
|
||||
row.original.siteResourceId != null
|
||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
||||
? getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)
|
||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||
}
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
@@ -323,7 +324,10 @@ export default function ConnectionLogsPage() {
|
||||
if (row.original.resourceName && row.original.resourceNiceId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/private/?query=${row.original.resourceNiceId}`}
|
||||
href={getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import { logQueries } from "@app/lib/queries";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -395,7 +396,10 @@ export default function GeneralPage() {
|
||||
<Link
|
||||
href={
|
||||
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
||||
? getPrivateResourceSettingsHref(
|
||||
row.original.orgId,
|
||||
row.original.resourceNiceId
|
||||
)
|
||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { MachinesSelector } from "@app/components/machines-selector";
|
||||
import { RolesSelector } from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import type { Tag } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import type { PrivateResourceClient } from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
type AccessFormValues = {
|
||||
roles?: Tag[];
|
||||
users?: Tag[];
|
||||
clients?: PrivateResourceClient[];
|
||||
};
|
||||
|
||||
type PrivateResourceAccessFieldsProps = {
|
||||
control: Control<AccessFormValues>;
|
||||
orgId: string;
|
||||
loading?: boolean;
|
||||
hasMachineClients?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAccessFields({
|
||||
control,
|
||||
orgId,
|
||||
loading = false,
|
||||
hasMachineClients = false
|
||||
}: PrivateResourceAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">{t("loading")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<RolesSelector
|
||||
selectedRoles={field.value ?? []}
|
||||
orgId={orgId}
|
||||
restrictAdminRole
|
||||
onSelectRoles={(newRoles) => {
|
||||
field.onChange(newRoles);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<UsersSelector
|
||||
selectedUsers={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectUsers={(newUsers) => {
|
||||
field.onChange(newUsers);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{hasMachineClients && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="clients"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("machineClients")}</FormLabel>
|
||||
<MachinesSelector
|
||||
selectedMachines={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectMachines={(machines) => {
|
||||
field.onChange(machines);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceAliasFieldProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAliasField({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
disabled = false
|
||||
}: PrivateResourceAliasFieldProps) {
|
||||
const t = useTranslations();
|
||||
const aliasLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAlias"
|
||||
: "editInternalResourceDialogAlias";
|
||||
const aliasDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAliasDescription"
|
||||
: "editInternalResourceDialogAliasDescription";
|
||||
|
||||
const aliasValue = watch("alias");
|
||||
const aliasEndsWithLocal =
|
||||
typeof aliasValue === "string" &&
|
||||
aliasValue.trim().toLowerCase().endsWith(".local");
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="alias"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(aliasLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{aliasEndsWithLocal && (
|
||||
<p className="text-xs text-amber-700/80 mt-1">
|
||||
{t("internalResourceAliasLocalWarning")}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
<FormDescription>{t(aliasDescriptionKey)}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PrivateResourceHostDestinationFieldsProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideAlias?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceHostDestinationFields({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
hideAlias = false
|
||||
}: PrivateResourceHostDestinationFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
const destinationField = (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (hideAlias) {
|
||||
return destinationField;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">{destinationField}</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceCidrDestinationField({
|
||||
control,
|
||||
labelPrefix = "edit"
|
||||
}: {
|
||||
control: Control<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceHttpFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
orgId: string;
|
||||
watch: UseFormWatch<any>;
|
||||
disabled?: boolean;
|
||||
siteResourceId?: number;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideDomainPicker?: boolean;
|
||||
hidePaidFeaturesAlert?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceHttpFields({
|
||||
control,
|
||||
setValue,
|
||||
orgId,
|
||||
watch,
|
||||
disabled = false,
|
||||
siteResourceId,
|
||||
labelPrefix = "edit",
|
||||
hideDomainPicker = false,
|
||||
hidePaidFeaturesAlert = false
|
||||
}: PrivateResourceHttpFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const schemeLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogScheme"
|
||||
: "editInternalResourceDialogScheme";
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
const httpConfigurationTitleKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfiguration"
|
||||
: "editInternalResourceDialogHttpConfiguration";
|
||||
const httpConfigurationDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfigurationDescription"
|
||||
: "editInternalResourceDialogHttpConfigurationDescription";
|
||||
const enableSslLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSsl"
|
||||
: "editInternalResourceDialogEnableSsl";
|
||||
const enableSslDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSslDescription"
|
||||
: "editInternalResourceDialogEnableSslDescription";
|
||||
|
||||
const httpConfigSubdomain = watch("httpConfigSubdomain");
|
||||
const httpConfigDomainId = watch("httpConfigDomainId");
|
||||
const httpConfigFullDomain = watch("httpConfigFullDomain");
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
{!hidePaidFeaturesAlert && (
|
||||
<SettingsFormCell span="full">
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="scheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(schemeLabelKey)}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? "http"}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{!hideDomainPicker && (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t(httpConfigurationTitleKey)}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t(httpConfigurationDescriptionKey)}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<div
|
||||
className={
|
||||
disabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DomainPicker
|
||||
key={
|
||||
siteResourceId
|
||||
? `http-domain-${siteResourceId}`
|
||||
: "http-domain-create"
|
||||
}
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ?? undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (res === null) {
|
||||
setValue("httpConfigSubdomain", null);
|
||||
setValue("httpConfigDomainId", null);
|
||||
setValue("httpConfigFullDomain", null);
|
||||
return;
|
||||
}
|
||||
setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
);
|
||||
setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(
|
||||
enableSslDescriptionKey
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hideDomainPicker && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(enableSslDescriptionKey)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function PrivateResourceMultiSiteRoutingHelp() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t("internalResourceFormMultiSiteRoutingHelp")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/private/multi-site-routing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("internalResourceFormMultiSiteRoutingHelpLearnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import {
|
||||
getPortModeFromString,
|
||||
getPortStringFromMode,
|
||||
type PortMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue } from "react-hook-form";
|
||||
|
||||
type PrivateResourceNetworkAccessFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
showPortRanges?: boolean;
|
||||
initialTcp?: string | null;
|
||||
initialUdp?: string | null;
|
||||
disabled?: boolean;
|
||||
icmpId?: string;
|
||||
embedInParentGrid?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAllowIcmpField({
|
||||
control,
|
||||
id = "private-resource-allow-icmp",
|
||||
disabled = false
|
||||
}: {
|
||||
control: Control<any>;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id={id}
|
||||
label={t("privateResourceAllowIcmpPing")}
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(!checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateResourceNetworkAccessHeader() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("privateResourceNetworkAccess")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("privateResourceNetworkAccessDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceNetworkAccessFields({
|
||||
control,
|
||||
setValue,
|
||||
showPortRanges = true,
|
||||
initialTcp,
|
||||
initialUdp,
|
||||
disabled = false,
|
||||
icmpId = "private-resource-allow-icmp",
|
||||
embedInParentGrid = false
|
||||
}: PrivateResourceNetworkAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const resolvedInitialTcp = initialTcp !== undefined ? initialTcp : "*";
|
||||
const resolvedInitialUdp = initialUdp !== undefined ? initialUdp : "*";
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(resolvedInitialTcp)
|
||||
);
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(resolvedInitialUdp)
|
||||
);
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState(() =>
|
||||
resolvedInitialTcp && resolvedInitialTcp !== "*"
|
||||
? resolvedInitialTcp
|
||||
: ""
|
||||
);
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState(() =>
|
||||
resolvedInitialUdp && resolvedInitialUdp !== "*"
|
||||
? resolvedInitialUdp
|
||||
: ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"tcpPortRangeString",
|
||||
getPortStringFromMode(tcpPortMode, tcpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, tcpPortMode, tcpCustomPorts, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"udpPortRangeString",
|
||||
getPortStringFromMode(udpPortMode, udpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, udpPortMode, udpCustomPorts, setValue]);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
<PrivateResourceNetworkAccessHeader />
|
||||
|
||||
{showPortRanges ? (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="tcpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogTcp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setTcpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="udpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogUdp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setUdpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceAllowIcmpField
|
||||
control={control}
|
||||
id={icmpId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
|
||||
export function PrivateResourcePortRanges(
|
||||
props: Omit<
|
||||
PrivateResourceNetworkAccessFieldsProps,
|
||||
"showPortRanges" | "embedInParentGrid"
|
||||
>
|
||||
) {
|
||||
return <PrivateResourceNetworkAccessFields showPortRanges {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "@app/components/multi-site-selector";
|
||||
import { SitesSelector } from "@app/components/site-selector";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
|
||||
|
||||
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
orgId: string;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
siteIdsFieldName?: FieldPath<T>;
|
||||
singleSite?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSitesField<T extends FieldValues>({
|
||||
control,
|
||||
orgId,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
siteIdsFieldName = "siteIds" as FieldPath<T>,
|
||||
singleSite = false
|
||||
}: PrivateResourceSitesFieldProps<T>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={siteIdsFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
{singleSite ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedSites[0]?.name ??
|
||||
t("selectSite")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSites[0] ?? null}
|
||||
filterTypes={["newt"]}
|
||||
onSelectSite={(site) => {
|
||||
onSelectedSitesChange([site]);
|
||||
field.onChange([site.siteId]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<MultiSitesSelector
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
filterTypes={["newt"]}
|
||||
onSelectionChange={(sites) => {
|
||||
onSelectedSitesChange(sites);
|
||||
field.onChange(
|
||||
sites.map((s) => s.siteId)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<FormMessage />
|
||||
{!singleSite && selectedSites.length > 1 ? (
|
||||
<PrivateResourceMultiSiteRoutingHelp />
|
||||
) : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
|
||||
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
|
||||
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
|
||||
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
type PrivateResourceSshFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
orgId?: string;
|
||||
disabled?: boolean;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
labelPrefix?: "create" | "edit";
|
||||
showSshSettings?: boolean;
|
||||
layout?: "default" | "wizard";
|
||||
showPaidFeaturesAlert?: boolean;
|
||||
hideAlias?: boolean;
|
||||
embedInParentGrid?: boolean;
|
||||
isNativeSsh?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSshFields({
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
orgId,
|
||||
disabled = false,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
labelPrefix = "edit",
|
||||
showSshSettings = true,
|
||||
layout = "default",
|
||||
showPaidFeaturesAlert = true,
|
||||
hideAlias = false,
|
||||
embedInParentGrid = false,
|
||||
isNativeSsh: isNativeSshProp
|
||||
}: PrivateResourceSshFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
|
||||
const authDaemonMode = watch("authDaemonMode") ?? "site";
|
||||
const pamMode = inferSshPamMode(authDaemonMode, watch("pamMode"));
|
||||
const standardDaemonLocation =
|
||||
watch("standardDaemonLocation") ??
|
||||
(authDaemonMode === "remote" ? "remote" : "site");
|
||||
const formAuthDaemonPort = watch("authDaemonPort");
|
||||
const [authDaemonPortInput, setAuthDaemonPortInput] = useState(() =>
|
||||
formAuthDaemonPort != null ? String(formAuthDaemonPort) : "22123"
|
||||
);
|
||||
const isEditLayout = layout === "default";
|
||||
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
() => (authDaemonMode === "native" ? "native" : "standard")
|
||||
);
|
||||
|
||||
const isNative =
|
||||
isNativeSshProp ??
|
||||
(isEditLayout
|
||||
? authDaemonMode === "native"
|
||||
: sshServerMode === "native");
|
||||
const useMultiSiteTargetForm = getSshUseMultiSiteTargetForm(
|
||||
isNative,
|
||||
authDaemonMode,
|
||||
pamMode
|
||||
);
|
||||
|
||||
function trimSitesToFirst() {
|
||||
if (selectedSites.length <= 1) return;
|
||||
|
||||
const first = selectedSites.slice(0, 1);
|
||||
onSelectedSitesChange(first);
|
||||
setValue(
|
||||
"siteIds",
|
||||
first.map((s: Selectedsite) => s.siteId),
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
|
||||
function handlePamModeChange(value: "passthrough" | "push") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("pamMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "passthrough") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
return;
|
||||
}
|
||||
|
||||
if (standardDaemonLocation !== "remote" && selectedSites.length > 1) {
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("standardDaemonLocation", value, { shouldValidate: true });
|
||||
setValue("authDaemonMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "site") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthDaemonPortChange(value: string) {
|
||||
if (disabled) return;
|
||||
|
||||
setAuthDaemonPortInput(value);
|
||||
const trimmed = value.trim();
|
||||
setValue("authDaemonPort", trimmed ? Number(trimmed) : null, {
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
|
||||
function handleServerModeChange(mode: "standard" | "native") {
|
||||
if (disabled) return;
|
||||
|
||||
setSshServerMode(mode);
|
||||
if (mode === "native") {
|
||||
setValue("authDaemonMode", "native", { shouldValidate: true });
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setValue("destination", null, { shouldValidate: true });
|
||||
setValue("destinationPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue("authDaemonMode", standardDaemonLocation, {
|
||||
shouldValidate: true
|
||||
});
|
||||
setValue("destinationPort", 22, { shouldValidate: true });
|
||||
}
|
||||
|
||||
const aliasField = hideAlias ? null : (
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const standardSshTargetRow =
|
||||
orgId && !isNative ? (
|
||||
<div className="grid grid-cols-3 gap-4 items-start">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite={!useMultiSiteTargetForm}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const sshSettingsFields = showSshSettings ? (
|
||||
<SshServerSettingsFields
|
||||
idPrefix={
|
||||
layout === "wizard"
|
||||
? "private-ssh-create"
|
||||
: "private-ssh-fields"
|
||||
}
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={standardDaemonLocation}
|
||||
authDaemonPort={authDaemonPortInput}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={handleDaemonLocationChange}
|
||||
onAuthDaemonPortChange={handleAuthDaemonPortChange}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay={layout === "wizard" ? "select" : "badge"}
|
||||
onServerModeChange={handleServerModeChange}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const destinationSection = (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerDestination")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("sshServerDestinationDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
|
||||
{isNative && orgId ? (
|
||||
<>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isNative && orgId ? (
|
||||
<SettingsFormCell span="full">
|
||||
{standardSshTargetRow}
|
||||
</SettingsFormCell>
|
||||
) : null}
|
||||
|
||||
{!isNative && !hideAlias ? (
|
||||
<SettingsFormCell span="half">{aliasField}</SettingsFormCell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
{showPaidFeaturesAlert && layout === "default" && (
|
||||
<SettingsFormCell span="full">
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
{sshSettingsFields}
|
||||
{destinationSection}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
accessTagsToIds,
|
||||
buildUpdateSiteResourcePayload,
|
||||
createAccessFormSchema,
|
||||
mergeFormValuesWithResource
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { resourceQueries, orgQueries } from "@app/lib/queries";
|
||||
import { useAccessFormDefaults } from "@app/providers/SiteResourceProvider";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
|
||||
|
||||
export default function PrivateResourceAccessPage() {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const { siteResource, setAccess } = useSiteResourceContext();
|
||||
const { loading, roles, users, clients, hasMachineClients } =
|
||||
useAccessFormDefaults(siteResource.orgId, siteResource.id);
|
||||
|
||||
const machineClientsQuery = useQuery(
|
||||
orgQueries.machineClients({
|
||||
orgId: siteResource.orgId,
|
||||
perPage: 1
|
||||
})
|
||||
);
|
||||
const hasMachineClientsResolved =
|
||||
(machineClientsQuery.data ?? []).filter((c) => !c.userId).length > 0;
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(createAccessFormSchema()),
|
||||
defaultValues: {
|
||||
roles: [] as typeof roles,
|
||||
users: [] as typeof users,
|
||||
clients: [] as typeof clients
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
form.reset({ roles, users, clients });
|
||||
}
|
||||
}, [loading, roles, users, clients, form]);
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
const access = accessTagsToIds({
|
||||
roles: data.roles,
|
||||
users: data.users,
|
||||
clients: data.clients
|
||||
});
|
||||
|
||||
const merged = mergeFormValuesWithResource(siteResource, {});
|
||||
const payload = buildUpdateSiteResourcePayload(merged, access);
|
||||
|
||||
try {
|
||||
await api.post(`/site-resource/${siteResource.id}`, payload);
|
||||
setAccess(access);
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceRoles({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceUsers({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
),
|
||||
queryClient.invalidateQueries(
|
||||
resourceQueries.siteResourceClients({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
)
|
||||
]);
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
)
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("authentication")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogAccessControlDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-access-form"
|
||||
>
|
||||
<PrivateResourceAccessFields
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
loading={loading}
|
||||
hasMachineClients={
|
||||
hasMachineClients ||
|
||||
hasMachineClientsResolved
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-access-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { createCidrFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceCidrPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createCidrFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "cidr",
|
||||
destination: siteResource.destination ?? "",
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
|
||||
disableIcmp: siteResource.disableIcmp ?? false
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "cidr",
|
||||
destination: data.destination,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("cidrSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogDestinationCidrDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-cidr-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceCidrDestinationField
|
||||
control={asAnyControl(form.control)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
initialTcp={
|
||||
siteResource.tcpPortRangeString
|
||||
}
|
||||
initialUdp={
|
||||
siteResource.udpPortRangeString
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-cidr-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceGeneralPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
|
||||
const formSchema = useMemo(() => createGeneralFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: siteResource.name,
|
||||
niceId: siteResource.niceId
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
name: data.name,
|
||||
niceId: data.niceId
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("privateResourceGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-general-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"editInternalResourceDialogName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("identifier")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-general-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { createHostFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceHostPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createHostFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "host",
|
||||
destination: siteResource.destination ?? "",
|
||||
alias: siteResource.alias ?? null,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
|
||||
disableIcmp: siteResource.disableIcmp ?? false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? "site",
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "host",
|
||||
destination: data.destination,
|
||||
alias: data.alias,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp,
|
||||
authDaemonMode: data.authDaemonMode,
|
||||
authDaemonPort: data.authDaemonPort
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("hostSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("editInternalResourceDialogDestinationDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-host-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHostDestinationFields
|
||||
control={asAnyControl(form.control)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
initialTcp={
|
||||
siteResource.tcpPortRangeString
|
||||
}
|
||||
initialUdp={
|
||||
siteResource.udpPortRangeString
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-host-form"
|
||||
loading={saveLoading}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { createHttpFormSchema } from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceHttpPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const httpSectionDisabled = !isPaidUser(
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(() => createHttpFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "http",
|
||||
destination: siteResource.destination ?? "",
|
||||
destinationPort: siteResource.destinationPort ?? null,
|
||||
scheme: siteResource.scheme ?? "http",
|
||||
ssl: siteResource.ssl ?? false,
|
||||
httpConfigSubdomain: siteResource.subdomain ?? null,
|
||||
httpConfigDomainId: siteResource.domainId ?? null,
|
||||
httpConfigFullDomain: siteResource.fullDomain ?? null
|
||||
}
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "http",
|
||||
destination: data.destination,
|
||||
destinationPort: data.destinationPort,
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl,
|
||||
httpConfigSubdomain: data.httpConfigSubdomain,
|
||||
httpConfigDomainId: data.httpConfigDomainId,
|
||||
httpConfigFullDomain: data.httpConfigFullDomain
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("httpSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogHttpConfigurationDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="private-resource-http-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHttpFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
orgId={siteResource.orgId}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
disabled={httpSectionDisabled}
|
||||
siteResourceId={siteResource.id}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="private-resource-http-form"
|
||||
loading={saveLoading}
|
||||
disabled={httpSectionDisabled}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { fetchSiteResourceByNiceId } from "@app/lib/fetchSiteResourceByNiceId";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import SiteResourceProvider from "@app/providers/SiteResourceProvider";
|
||||
import SiteResourceInfoBox from "@app/components/SiteResourceInfoBox";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Private Resource"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PrivateResourceLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function PrivateResourceLayout(
|
||||
props: PrivateResourceLayoutProps
|
||||
) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const { children } = props;
|
||||
|
||||
const siteResource = await fetchSiteResourceByNiceId(
|
||||
params.orgId,
|
||||
params.niceId
|
||||
);
|
||||
|
||||
if (!siteResource) {
|
||||
redirect(`/${params.orgId}/settings/resources/private`);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const res = await getCachedOrg(params.orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources/private`);
|
||||
}
|
||||
|
||||
if (!org) {
|
||||
redirect(`/${params.orgId}/settings/resources/private`);
|
||||
}
|
||||
|
||||
const modeSettingsKey = `${siteResource.mode}Settings` as
|
||||
| "hostSettings"
|
||||
| "cidrSettings"
|
||||
| "httpSettings"
|
||||
| "sshSettings";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t("general"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(modeSettingsKey),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
|
||||
},
|
||||
{
|
||||
title: t("authentication"),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("resourceSetting", {
|
||||
resourceName: siteResource.name
|
||||
})}
|
||||
description={t("resourceSettingDescription")}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<SiteResourceProvider siteResource={siteResource}>
|
||||
<div className="space-y-6">
|
||||
<SiteResourceInfoBox />
|
||||
<HorizontalTabs items={navItems}>
|
||||
{children}
|
||||
</HorizontalTabs>
|
||||
</div>
|
||||
</SiteResourceProvider>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Private Resource"
|
||||
};
|
||||
|
||||
export default async function PrivateResourcePage(props: {
|
||||
params: Promise<{ niceId: string; orgId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
redirect(
|
||||
`/${params.orgId}/settings/resources/private/${params.niceId}/general`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsFormGrid
|
||||
} from "@app/components/Settings";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import {
|
||||
createSshFormSchema,
|
||||
inferSshPamMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
export default function PrivateResourceSshPage() {
|
||||
const t = useTranslations();
|
||||
const { save, siteResource } = useSaveSiteResource();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const isNative = siteResource.authDaemonMode === "native";
|
||||
const [sshServerMode] = useState<"standard" | "native">(
|
||||
isNative ? "native" : "standard"
|
||||
);
|
||||
const [selectedSites, setSelectedSites] = useState(() =>
|
||||
buildSelectedSitesForResource(siteResource)
|
||||
);
|
||||
|
||||
const formSchema = useMemo(
|
||||
() => createSshFormSchema(t, { isNative }),
|
||||
[t, isNative]
|
||||
);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
siteIds: siteResource.siteIds,
|
||||
mode: "ssh",
|
||||
destination: siteResource.destination ?? "",
|
||||
alias: siteResource.alias ?? null,
|
||||
destinationPort: siteResource.destinationPort ?? null,
|
||||
pamMode: inferSshPamMode(
|
||||
siteResource.authDaemonMode,
|
||||
siteResource.pamMode
|
||||
),
|
||||
standardDaemonLocation: isNative
|
||||
? "site"
|
||||
: siteResource.authDaemonMode === "remote"
|
||||
? "remote"
|
||||
: "site",
|
||||
authDaemonPort: siteResource.authDaemonPort
|
||||
? String(siteResource.authDaemonPort)
|
||||
: "22123"
|
||||
}
|
||||
});
|
||||
|
||||
const pamMode = form.watch("pamMode");
|
||||
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
||||
const authDaemonPort = form.watch("authDaemonPort");
|
||||
|
||||
function trimSitesToFirst() {
|
||||
if (selectedSites.length <= 1) return;
|
||||
|
||||
const first = selectedSites.slice(0, 1);
|
||||
setSelectedSites(first);
|
||||
form.setValue(
|
||||
"siteIds",
|
||||
first.map((s: Selectedsite) => s.siteId),
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
|
||||
function handlePamModeChange(value: "passthrough" | "push") {
|
||||
form.setValue("pamMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "push") {
|
||||
if (
|
||||
standardDaemonLocation !== "remote" &&
|
||||
selectedSites.length > 1
|
||||
) {
|
||||
trimSitesToFirst();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
|
||||
}
|
||||
|
||||
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||
form.setValue("standardDaemonLocation", value, {
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
if (value === "site") {
|
||||
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
const effectiveAuthDaemonMode = isNative
|
||||
? "native"
|
||||
: data.standardDaemonLocation;
|
||||
const effectiveAuthDaemonPort =
|
||||
!isNative &&
|
||||
data.pamMode === "push" &&
|
||||
data.standardDaemonLocation === "remote"
|
||||
? Number(data.authDaemonPort)
|
||||
: null;
|
||||
|
||||
await save({
|
||||
siteIds: data.siteIds,
|
||||
mode: "ssh",
|
||||
destination: isNative ? null : data.destination,
|
||||
alias: data.alias,
|
||||
destinationPort: isNative ? null : data.destinationPort,
|
||||
authDaemonMode: effectiveAuthDaemonMode,
|
||||
authDaemonPort: effectiveAuthDaemonPort,
|
||||
pamMode: data.pamMode
|
||||
});
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("sshSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("editInternalResourceDialogDestinationDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<fieldset
|
||||
disabled={sshSectionDisabled}
|
||||
className={
|
||||
sshSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SshServerSettingsFields
|
||||
idPrefix="private-ssh-edit"
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={
|
||||
standardDaemonLocation
|
||||
}
|
||||
authDaemonPort={authDaemonPort}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={
|
||||
handleDaemonLocationChange
|
||||
}
|
||||
onAuthDaemonPortChange={(value) =>
|
||||
form.setValue(
|
||||
"authDaemonPort",
|
||||
value,
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
}
|
||||
authDaemonPortError={
|
||||
form.formState.errors.authDaemonPort
|
||||
?.message
|
||||
}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay="badge"
|
||||
/>
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(form.setValue)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={siteResource.orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={setSelectedSites}
|
||||
showSshSettings={false}
|
||||
embedInParentGrid
|
||||
showPaidFeaturesAlert={false}
|
||||
isNativeSsh={isNative}
|
||||
/>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<form action={formAction}>
|
||||
<Button type="submit" loading={saveLoading}>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsSectionFooter>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import {
|
||||
OptionSelect,
|
||||
type OptionSelectOption
|
||||
} from "@app/components/OptionSelect";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
buildCreateSiteResourcePayload,
|
||||
createCreateFormSchema,
|
||||
type PrivateResourceMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { SiteResource } from "@server/db";
|
||||
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
|
||||
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
|
||||
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
|
||||
import {
|
||||
PrivateResourceAliasField,
|
||||
PrivateResourceCidrDestinationField,
|
||||
PrivateResourceHostDestinationFields
|
||||
} from "../PrivateResourceDestinationFields";
|
||||
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
|
||||
|
||||
export default function CreatePrivateResourcePage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const orgId = params.orgId as string;
|
||||
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const httpSectionDisabled = !isPaidUser(
|
||||
tierMatrix.advancedPrivateResources
|
||||
);
|
||||
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
const siteIdParam = searchParams.get("siteId");
|
||||
const siteIdNumber =
|
||||
siteIdParam && Number.isInteger(Number(siteIdParam))
|
||||
? Number(siteIdParam)
|
||||
: null;
|
||||
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
|
||||
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
siteIds: [],
|
||||
mode: "host",
|
||||
destination: "",
|
||||
alias: null,
|
||||
destinationPort: null,
|
||||
scheme: "http",
|
||||
ssl: true,
|
||||
httpConfigSubdomain: null,
|
||||
httpConfigDomainId: null,
|
||||
httpConfigFullDomain: null,
|
||||
authDaemonMode: "native",
|
||||
standardDaemonLocation: "site",
|
||||
authDaemonPort: null,
|
||||
pamMode: "passthrough",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
disableIcmp: false
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteIdNumber) return;
|
||||
|
||||
void api
|
||||
.get<ResponseT<GetSiteResponse>>(`/site/${siteIdNumber}`)
|
||||
.then((res) => {
|
||||
const site = res.data.data;
|
||||
if (!site || site.orgId !== orgId) return;
|
||||
const selected: Selectedsite = {
|
||||
siteId: site.siteId,
|
||||
name: site.name,
|
||||
type: site.type as Selectedsite["type"]
|
||||
};
|
||||
setSelectedSites([selected]);
|
||||
form.setValue("siteIds", [site.siteId]);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [api, form, orgId, siteIdNumber]);
|
||||
|
||||
const mode = form.watch("mode");
|
||||
const authDaemonMode = form.watch("authDaemonMode");
|
||||
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
|
||||
|
||||
const modeOptions: OptionSelectOption<PrivateResourceMode>[] = [
|
||||
{ value: "host", label: t("createInternalResourceDialogModeHost") },
|
||||
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") },
|
||||
...(!disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
value: "http" as const,
|
||||
label: t("createInternalResourceDialogModeHttp")
|
||||
},
|
||||
{
|
||||
value: "ssh" as const,
|
||||
label: t("createInternalResourceDialogModeSsh")
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
const submitDisabled =
|
||||
isSubmitting ||
|
||||
(mode === "http" && httpSectionDisabled) ||
|
||||
(mode === "ssh" && sshSectionDisabled);
|
||||
|
||||
function onSubmit(values: FormValues) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await api.put<
|
||||
AxiosResponse<ResponseT<SiteResource>>
|
||||
>(
|
||||
`/org/${orgId}/site-resource`,
|
||||
buildCreateSiteResourcePayload({
|
||||
...values,
|
||||
destination:
|
||||
values.destination?.trim() &&
|
||||
values.destination.trim().length > 0
|
||||
? values.destination.trim()
|
||||
: null
|
||||
})
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
)
|
||||
});
|
||||
|
||||
const created = (res.data as unknown as ResponseT<SiteResource>)
|
||||
.data;
|
||||
if (!created) {
|
||||
throw new Error("Failed to create private resource");
|
||||
}
|
||||
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<HeaderTitle
|
||||
title={t(
|
||||
"createInternalResourceDialogCreateClientResource"
|
||||
)}
|
||||
description={t(
|
||||
"createInternalResourceDialogCreateClientResourceDescription"
|
||||
)}
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/${orgId}/settings/resources/private`}>
|
||||
{t("privateResourceCreatePageSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="create-private-resource-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* General */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceCreateGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceCreateGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceNameDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("type")}
|
||||
</FormLabel>
|
||||
<OptionSelect<PrivateResourceMode>
|
||||
options={modeOptions}
|
||||
value={field.value}
|
||||
onChange={(newMode) => {
|
||||
field.onChange(
|
||||
newMode
|
||||
);
|
||||
if (
|
||||
newMode ===
|
||||
"ssh"
|
||||
) {
|
||||
form.setValue(
|
||||
"authDaemonMode",
|
||||
"native"
|
||||
);
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
"site"
|
||||
);
|
||||
form.setValue(
|
||||
"destination",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
} else if (
|
||||
newMode ===
|
||||
"http"
|
||||
) {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
443
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
"destinationPort",
|
||||
null
|
||||
);
|
||||
}
|
||||
}}
|
||||
cols={4}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{mode === "http" && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
onDomainChange={(res) => {
|
||||
if (!res) {
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ??
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceDomainDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{(mode === "host" ||
|
||||
(mode === "ssh" && !isNativeSsh)) && (
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="create"
|
||||
disabled={
|
||||
mode === "ssh" &&
|
||||
sshSectionDisabled
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Host destination */}
|
||||
{mode === "host" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("hostSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogDestinationDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceHostDestinationFields
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
labelPrefix="create"
|
||||
hideAlias
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* CIDR destination */}
|
||||
{mode === "cidr" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("cidrSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogDestinationCidrDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceCidrDestinationField
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
labelPrefix="create"
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourcePortRanges
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* HTTP configuration */}
|
||||
{mode === "http" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("httpSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
"editInternalResourceDialogHttpConfigurationDescription"
|
||||
)}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={httpSectionDisabled}
|
||||
className={
|
||||
httpSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
selectedSites={
|
||||
selectedSites
|
||||
}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceHttpFields
|
||||
control={asAnyControl(
|
||||
form.control
|
||||
)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
orgId={orgId}
|
||||
watch={asAnyWatch(
|
||||
form.watch
|
||||
)}
|
||||
disabled={
|
||||
httpSectionDisabled
|
||||
}
|
||||
labelPrefix="create"
|
||||
hideDomainPicker
|
||||
hidePaidFeaturesAlert
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* SSH server */}
|
||||
{mode === "ssh" && (
|
||||
<SettingsSection>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("sshServer")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("sshServerDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<fieldset
|
||||
disabled={sshSectionDisabled}
|
||||
className={
|
||||
sshSectionDisabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<PrivateResourceSshFields
|
||||
control={asAnyControl(form.control)}
|
||||
setValue={asAnySetValue(
|
||||
form.setValue
|
||||
)}
|
||||
watch={asAnyWatch(form.watch)}
|
||||
orgId={orgId}
|
||||
disabled={sshSectionDisabled}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={
|
||||
setSelectedSites
|
||||
}
|
||||
labelPrefix="create"
|
||||
showSshSettings={true}
|
||||
layout="wizard"
|
||||
showPaidFeaturesAlert={false}
|
||||
hideAlias
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/resources/private`
|
||||
)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-private-resource-form"
|
||||
disabled={submitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
Control,
|
||||
FieldValues,
|
||||
UseFormSetValue,
|
||||
UseFormWatch
|
||||
} from "react-hook-form";
|
||||
|
||||
export function asAnyControl<T extends FieldValues>(
|
||||
control: Control<T>
|
||||
): Control<any> {
|
||||
return control as Control<any>;
|
||||
}
|
||||
|
||||
export function asAnySetValue<T extends FieldValues>(
|
||||
setValue: UseFormSetValue<T>
|
||||
): UseFormSetValue<any> {
|
||||
return setValue as UseFormSetValue<any>;
|
||||
}
|
||||
|
||||
export function asAnyWatch<T extends FieldValues>(
|
||||
watch: UseFormWatch<T>
|
||||
): UseFormWatch<any> {
|
||||
return watch as UseFormWatch<any>;
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
|
||||
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
||||
import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -24,13 +21,6 @@ export interface ClientResourcesPageProps {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
function parsePositiveInt(s: string | undefined): number | undefined {
|
||||
if (!s) return undefined;
|
||||
const n = Number(s);
|
||||
if (!Number.isInteger(n) || n <= 0) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
export default async function ClientResourcesPage(
|
||||
props: ClientResourcesPageProps
|
||||
) {
|
||||
@@ -39,7 +29,7 @@ export default async function ClientResourcesPage(
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
let pagination: ListResourcesResponse["pagination"] = {
|
||||
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
@@ -56,34 +46,6 @@ export default async function ClientResourcesPage(
|
||||
pagination = responseData.pagination;
|
||||
} catch (e) {}
|
||||
|
||||
const siteIdParam = parsePositiveInt(
|
||||
searchParams.get("siteId") ?? undefined
|
||||
);
|
||||
|
||||
let initialFilterSite: {
|
||||
siteId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
} | null = null;
|
||||
if (siteIdParam) {
|
||||
try {
|
||||
const siteRes = await internal.get(
|
||||
`/site/${siteIdParam}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
|
||||
if (s && s.orgId === params.orgId) {
|
||||
initialFilterSite = {
|
||||
siteId: s.siteId,
|
||||
name: s.name,
|
||||
type: s.type
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// leave null
|
||||
}
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const res = await getCachedOrg(params.orgId);
|
||||
@@ -122,6 +84,7 @@ export default async function ClientResourcesPage(
|
||||
aliasAddress: siteResource.aliasAddress || null,
|
||||
siteNiceIds: siteResource.siteNiceIds,
|
||||
niceId: siteResource.niceId,
|
||||
enabled: siteResource.enabled,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
@@ -153,7 +116,6 @@ export default async function ClientResourcesPage(
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
initialFilterSite={initialFilterSite}
|
||||
/>
|
||||
</OrgProvider>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import type { SiteResourceData } from "@app/lib/privateResourceForm";
|
||||
|
||||
export function buildSelectedSitesForResource(
|
||||
resource: Pick<SiteResourceData, "siteIds" | "siteNames">
|
||||
): Selectedsite[] {
|
||||
return resource.siteIds.map((siteId, idx) => ({
|
||||
name: resource.siteNames[idx] ?? "",
|
||||
siteId,
|
||||
type: "newt" as const
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSshSingleSiteMode(
|
||||
authDaemonMode?: string | null,
|
||||
pamMode?: string | null
|
||||
): boolean {
|
||||
return (
|
||||
authDaemonMode === "native" ||
|
||||
(pamMode === "push" && authDaemonMode === "site")
|
||||
);
|
||||
}
|
||||
|
||||
export function getSshUseMultiSiteTargetForm(
|
||||
isNative: boolean,
|
||||
authDaemonMode?: string | null,
|
||||
pamMode?: string | null
|
||||
): boolean {
|
||||
if (isNative) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authDaemonMode !== "site" || pamMode === "passthrough";
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||
import {
|
||||
buildUpdateSiteResourcePayload,
|
||||
mergeFormValuesWithResource,
|
||||
type PrivateResourceFormValues
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function useSaveSiteResource() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { siteResource, updateSiteResource, access } =
|
||||
useSiteResourceContext();
|
||||
|
||||
async function save(
|
||||
partial: Partial<PrivateResourceFormValues>,
|
||||
options?: { successMessage?: string }
|
||||
) {
|
||||
const merged = mergeFormValuesWithResource(siteResource, partial);
|
||||
const isNativeSsh =
|
||||
merged.mode === "ssh" && merged.authDaemonMode === "native";
|
||||
const trimmedDestination = merged.destination?.trim();
|
||||
|
||||
const payload = buildUpdateSiteResourcePayload(
|
||||
{
|
||||
...merged,
|
||||
destination: isNativeSsh
|
||||
? null
|
||||
: trimmedDestination && trimmedDestination.length > 0
|
||||
? trimmedDestination
|
||||
: null
|
||||
},
|
||||
access
|
||||
);
|
||||
|
||||
try {
|
||||
await api.post(`/site-resource/${siteResource.id}`, payload);
|
||||
|
||||
updateSiteResource({
|
||||
name: merged.name,
|
||||
niceId: merged.niceId ?? siteResource.niceId,
|
||||
enabled: merged.enabled ?? siteResource.enabled,
|
||||
siteIds: merged.siteIds,
|
||||
mode: merged.mode,
|
||||
destination: merged.destination ?? null,
|
||||
alias: merged.alias ?? null,
|
||||
destinationPort: merged.destinationPort ?? null,
|
||||
scheme: merged.scheme ?? siteResource.scheme,
|
||||
ssl: merged.ssl ?? siteResource.ssl,
|
||||
subdomain: merged.httpConfigSubdomain ?? null,
|
||||
domainId: merged.httpConfigDomainId ?? null,
|
||||
fullDomain: merged.httpConfigFullDomain ?? null,
|
||||
tcpPortRangeString: merged.tcpPortRangeString ?? null,
|
||||
udpPortRangeString: merged.udpPortRangeString ?? null,
|
||||
disableIcmp: merged.disableIcmp ?? false,
|
||||
authDaemonMode: merged.authDaemonMode ?? null,
|
||||
authDaemonPort: merged.authDaemonPort ?? null,
|
||||
pamMode: merged.pamMode ?? null
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description:
|
||||
options?.successMessage ??
|
||||
t(
|
||||
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||
)
|
||||
});
|
||||
|
||||
if (merged.niceId && merged.niceId !== siteResource.niceId) {
|
||||
router.replace(
|
||||
getPrivateResourceSettingsHref(
|
||||
siteResource.orgId,
|
||||
merged.niceId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t(
|
||||
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return { save, siteResource, access };
|
||||
}
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useActionState } from "react";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -137,11 +137,21 @@ function ProxyResourceHttpForm({
|
||||
});
|
||||
|
||||
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||
const [headersValid, setHeadersValid] = useState(true);
|
||||
|
||||
async function onSubmit() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
if (!headersValid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: t("headersValidationError")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
const res = await api
|
||||
@@ -318,6 +328,9 @@ function ProxyResourceHttpForm({
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
onValidityChange={
|
||||
setHeadersValid
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -341,7 +354,7 @@ function ProxyResourceHttpForm({
|
||||
<Button
|
||||
type="submit"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
disabled={saveLoading || !headersValid}
|
||||
form="http-settings-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
|
||||
@@ -14,14 +14,13 @@ import {
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SitesSelector } from "@app/components/site-selector";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -35,8 +34,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
@@ -223,6 +221,7 @@ function SshServerForm({
|
||||
|
||||
const pamMode = form.watch("pamMode");
|
||||
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
||||
const authDaemonPort = form.watch("authDaemonPort");
|
||||
const selectedNativeSite = form.watch("selectedNativeSite");
|
||||
|
||||
async function save() {
|
||||
@@ -364,35 +363,6 @@ function SshServerForm({
|
||||
}
|
||||
}
|
||||
|
||||
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
|
||||
{
|
||||
id: "passthrough",
|
||||
title: t("sshAuthMethodManual"),
|
||||
description: t("sshAuthMethodManualDescription")
|
||||
},
|
||||
{
|
||||
id: "push",
|
||||
title: t("sshAuthMethodAutomated"),
|
||||
description: t("sshAuthMethodAutomatedDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
|
||||
{
|
||||
id: "site",
|
||||
title: t("internalResourceAuthDaemonSite"),
|
||||
description: t("sshDaemonLocationSiteDescription")
|
||||
},
|
||||
{
|
||||
id: "remote",
|
||||
title: t("sshDaemonLocationRemote"),
|
||||
description: t("sshDaemonLocationRemoteDescription")
|
||||
}
|
||||
];
|
||||
|
||||
const showDaemonLocation = !isNative && pamMode === "push";
|
||||
const showDaemonPort =
|
||||
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
|
||||
const useMultiSiteTargetForm =
|
||||
!isNative &&
|
||||
(standardDaemonLocation !== "site" || pamMode === "passthrough");
|
||||
@@ -413,97 +383,37 @@ function SshServerForm({
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshServerMode")}
|
||||
</p>
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode == "standard"
|
||||
? t("sshServerModeStandard")
|
||||
: t("sshServerModePangolin")}
|
||||
</Badge>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthenticationMethod")}
|
||||
</p>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
onChange={(value) =>
|
||||
form.setValue("pamMode", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
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">
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
value,
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
)
|
||||
}
|
||||
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">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshDaemonPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
<SshServerSettingsFields
|
||||
idPrefix="public-ssh-edit"
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={
|
||||
standardDaemonLocation
|
||||
}
|
||||
authDaemonPort={authDaemonPort}
|
||||
onPamModeChange={(value) =>
|
||||
form.setValue("pamMode", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
onStandardDaemonLocationChange={(value) =>
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
value,
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
}
|
||||
onAuthDaemonPortChange={(value) =>
|
||||
form.setValue("authDaemonPort", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
authDaemonPortError={
|
||||
form.formState.errors.authDaemonPort
|
||||
?.message
|
||||
}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay="badge"
|
||||
/>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
|
||||
@@ -777,8 +777,8 @@ export default function Page() {
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title={t("resourceCreate")}
|
||||
description={t("resourceCreateDescription")}
|
||||
title={t("resourcePublicCreate")}
|
||||
description={t("resourcePublicCreateDescription")}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -166,11 +166,13 @@ export default async function ResourceAuthPage(props: {
|
||||
|
||||
// If user is not compliant with org policies, show policy requirements
|
||||
if (orgPolicyCheck && !orgPolicyCheck.allowed && orgPolicyCheck.policies) {
|
||||
const resourceAuthPageUrl = `/auth/resource/${authInfo.resourceGuid}${redirectUrl !== authInfo.url ? `?redirect=${encodeURIComponent(redirectUrl)}` : ""}`;
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<OrgPolicyRequired
|
||||
orgId={authInfo.orgId}
|
||||
policies={orgPolicyCheck.policies}
|
||||
redirectAfterAuth={resourceAuthPageUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+244
-1
@@ -15,6 +15,7 @@ import {
|
||||
GlobeLock,
|
||||
KeyRound,
|
||||
Laptop,
|
||||
LayoutGrid,
|
||||
Link as LinkIcon,
|
||||
Logs,
|
||||
MonitorUp,
|
||||
@@ -49,7 +50,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "sidebarAccount",
|
||||
href: "/{orgId}",
|
||||
icon: <User className="size-4 flex-none" />
|
||||
icon: <LayoutGrid className="size-4 flex-none" />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -341,3 +342,245 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export type CommandBarNavSection = {
|
||||
// Added from 'dev' branch
|
||||
heading: string;
|
||||
items: CommandBarNavItem[];
|
||||
};
|
||||
|
||||
export type CommandBarNavItem = {
|
||||
href?: string;
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
showEE?: boolean;
|
||||
isBeta?: boolean;
|
||||
};
|
||||
|
||||
export const commandBarNavSections = (
|
||||
env?: Env,
|
||||
options?: OrgNavSectionsOptions
|
||||
): CommandBarNavSection[] => [
|
||||
{
|
||||
heading: "commandLauncher",
|
||||
items: [
|
||||
{
|
||||
title: "commandResourceLauncher",
|
||||
href: "/{orgId}",
|
||||
icon: <LayoutGrid className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "network",
|
||||
items: [
|
||||
{
|
||||
title: "commandSites",
|
||||
href: "/{orgId}/settings/sites",
|
||||
icon: <Plug className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandProxyResources",
|
||||
href: "/{orgId}/settings/resources/public",
|
||||
icon: <Globe className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandClientResources",
|
||||
href: "/{orgId}/settings/resources/private",
|
||||
icon: <GlobeLock className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
href: "/{orgId}/settings/clients/user",
|
||||
title: "commandUserDevices",
|
||||
icon: <Laptop className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
href: "/{orgId}/settings/clients/machine",
|
||||
title: "commandMachineClients",
|
||||
icon: <Server className="size-4 flex-none" />
|
||||
},
|
||||
...(build === "saas"
|
||||
? [
|
||||
{
|
||||
title: "commandRemoteExitNodes",
|
||||
href: "/{orgId}/settings/remote-exit-nodes",
|
||||
icon: <Server className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "commandTeam",
|
||||
items: [
|
||||
{
|
||||
title: "commandUsers",
|
||||
href: "/{orgId}/settings/access/users",
|
||||
icon: <User className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandRoles",
|
||||
href: "/{orgId}/settings/access/roles",
|
||||
icon: <Users className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandInvitations",
|
||||
href: "/{orgId}/settings/access/invitations",
|
||||
icon: <TicketCheck className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "accessControl",
|
||||
items: [
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "commandResourcePolicies",
|
||||
href: "/{orgId}/settings/policies/resources/public",
|
||||
icon: <ShieldIcon className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
// PaidFeaturesAlert
|
||||
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||
build === "saas" ||
|
||||
env?.app.identityProviderMode === "org" ||
|
||||
(env?.app.identityProviderMode === undefined && build !== "oss")
|
||||
? [
|
||||
{
|
||||
title: "commandIdentityProviders",
|
||||
href: "/{orgId}/settings/idp",
|
||||
icon: <Fingerprint className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "commandApprovals",
|
||||
href: "/{orgId}/settings/access/approvals",
|
||||
icon: <UserCog className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "commandShareableLinks",
|
||||
href: "/{orgId}/settings/share-links",
|
||||
icon: <LinkIcon className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "commandLogsAndAnalytics",
|
||||
items: [
|
||||
{
|
||||
title: "commandLogsAnalytics",
|
||||
href: "/{orgId}/settings/logs/analytics",
|
||||
icon: <ChartLine className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandLogsRequest",
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "commandLogsAccess",
|
||||
href: "/{orgId}/settings/logs/access",
|
||||
icon: <ScanEye className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandLogsAction",
|
||||
href: "/{orgId}/settings/logs/action",
|
||||
icon: <Logs className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandLogsConnection",
|
||||
href: "/{orgId}/settings/logs/connection",
|
||||
icon: <Cable className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandLogsStreaming",
|
||||
href: "/{orgId}/settings/logs/streaming",
|
||||
icon: <Unplug className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "commandManagement",
|
||||
items: [
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "commandAlerting",
|
||||
href: "/{orgId}/settings/alerting",
|
||||
icon: <BellRing className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandProvisioning",
|
||||
href: "/{orgId}/settings/provisioning",
|
||||
icon: <Boxes className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "commandBluePrints",
|
||||
href: "/{orgId}/settings/blueprints",
|
||||
icon: <ReceiptText className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "labels",
|
||||
href: "/{orgId}/settings/labels",
|
||||
icon: <TagIcon className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
heading: "commandOrganization",
|
||||
items: [
|
||||
{
|
||||
title: "commandSettings",
|
||||
href: "/{orgId}/settings/general",
|
||||
icon: <Settings className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandDomains",
|
||||
href: "/{orgId}/settings/domains",
|
||||
icon: <Globe className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
...(build === "saas" && options?.isPrimaryOrg
|
||||
? [
|
||||
{
|
||||
heading: "commandBillingAndLicenses",
|
||||
items: [
|
||||
{
|
||||
title: "commandBilling",
|
||||
href: "/{orgId}/settings/billing",
|
||||
icon: <CreditCard className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandEnterpriseLicenses",
|
||||
href: "/{orgId}/settings/license",
|
||||
icon: <TicketCheck className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
+3
-1
@@ -19,9 +19,11 @@ export default async function Page(props: {
|
||||
searchParams: Promise<{
|
||||
redirect: string | undefined;
|
||||
t: string | undefined;
|
||||
orgs?: string | undefined;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.searchParams; // this is needed to prevent static optimization
|
||||
const showOrgPicker = params.orgs === "1";
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
@@ -103,7 +105,7 @@ export default async function Page(props: {
|
||||
}
|
||||
}
|
||||
|
||||
if (targetOrgId) {
|
||||
if (targetOrgId && !showOrgPicker) {
|
||||
const targetOrg = orgs.find((org) => org.orgId === targetOrgId);
|
||||
return (
|
||||
<RedirectToOrg
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export default function CreateShareLinkForm({
|
||||
const t = useTranslations();
|
||||
|
||||
const { data: allResources = [] } = useQuery(
|
||||
orgQueries.resources({ orgId: org?.org.orgId ?? "" })
|
||||
orgQueries.proxyResources({ orgId: org?.org.orgId ?? "" })
|
||||
);
|
||||
|
||||
const [selectedResource, setSelectedResource] =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
|
||||
@@ -25,6 +25,7 @@ type LabelsFilterSelectorProps = {
|
||||
orgId: string;
|
||||
isSelected: (label: LabelFilterOption) => boolean;
|
||||
onToggle: (label: LabelFilterOption) => void;
|
||||
selectedLabels?: LabelFilterOption[];
|
||||
onClear?: () => void;
|
||||
showClear?: boolean;
|
||||
scope?: "org" | "launcher";
|
||||
@@ -34,6 +35,7 @@ export function LabelsFilterSelector({
|
||||
orgId,
|
||||
isSelected,
|
||||
onToggle,
|
||||
selectedLabels = [],
|
||||
onClear,
|
||||
showClear = false,
|
||||
scope = "org"
|
||||
@@ -54,7 +56,7 @@ export function LabelsFilterSelector({
|
||||
...launcherQueries.labels({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 500
|
||||
perPage: 20
|
||||
}),
|
||||
enabled: scope === "launcher"
|
||||
});
|
||||
@@ -63,6 +65,18 @@ export function LabelsFilterSelector({
|
||||
? (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
|
||||
@@ -81,7 +95,7 @@ export function LabelsFilterSelector({
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{labels.map((label) => (
|
||||
{labelsShown.map((label) => (
|
||||
<CommandItem
|
||||
key={label.labelId}
|
||||
value={label.name}
|
||||
|
||||
+61
-49
@@ -1,17 +1,22 @@
|
||||
import React from "react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import type { SidebarNavSection } from "@app/app/navigation";
|
||||
import type {
|
||||
CommandBarNavSection,
|
||||
SidebarNavSection
|
||||
} from "@app/app/navigation";
|
||||
import { LayoutSidebar } from "@app/components/LayoutSidebar";
|
||||
import { LayoutHeader } from "@app/components/LayoutHeader";
|
||||
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
|
||||
import { cookies } from "next/headers";
|
||||
import { CommandPaletteProvider } from "./command-palette/CommandPalette";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
navItems?: SidebarNavSection[];
|
||||
commandNavItems?: CommandBarNavSection[];
|
||||
showSidebar?: boolean;
|
||||
showHeader?: boolean;
|
||||
showTopBar?: boolean;
|
||||
@@ -25,6 +30,7 @@ export async function Layout({
|
||||
orgId,
|
||||
orgs,
|
||||
navItems = [],
|
||||
commandNavItems = [],
|
||||
showSidebar = true,
|
||||
showHeader = true,
|
||||
showTopBar = true,
|
||||
@@ -41,61 +47,67 @@ export async function Layout({
|
||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen-safe overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
{showSidebar && (
|
||||
<LayoutSidebar
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={navItems}
|
||||
defaultSidebarCollapsed={initialSidebarCollapsed}
|
||||
hasCookiePreference={hasCookiePreference}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col h-full min-w-0 relative",
|
||||
!showSidebar && "w-full"
|
||||
)}
|
||||
>
|
||||
{/* Mobile header */}
|
||||
{showHeader && (
|
||||
<LayoutMobileMenu
|
||||
<CommandPaletteProvider
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={commandNavItems}
|
||||
>
|
||||
<div className="flex h-screen-safe overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
{showSidebar && (
|
||||
<LayoutSidebar
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={navItems}
|
||||
showSidebar={showSidebar}
|
||||
showTopBar={showTopBar}
|
||||
launcherMode={launcherMode}
|
||||
showViewAsAdmin={showViewAsAdmin}
|
||||
defaultSidebarCollapsed={initialSidebarCollapsed}
|
||||
hasCookiePreference={hasCookiePreference}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Desktop header */}
|
||||
{showHeader && (
|
||||
<LayoutHeader
|
||||
showTopBar={showTopBar}
|
||||
launcherMode={launcherMode}
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
showViewAsAdmin={showViewAsAdmin}
|
||||
/>
|
||||
)}
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col h-full min-w-0 relative",
|
||||
!showSidebar && "w-full"
|
||||
)}
|
||||
>
|
||||
{/* Mobile header */}
|
||||
{showHeader && (
|
||||
<LayoutMobileMenu
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={navItems}
|
||||
showSidebar={showSidebar}
|
||||
showTopBar={showTopBar}
|
||||
launcherMode={launcherMode}
|
||||
showViewAsAdmin={showViewAsAdmin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"container mx-auto max-w-12xl mb-12",
|
||||
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
{/* Desktop header */}
|
||||
{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">
|
||||
<div
|
||||
className={cn(
|
||||
"container mx-auto max-w-12xl mb-12",
|
||||
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandPaletteProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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";
|
||||
|
||||
type LayoutHeaderProps = {
|
||||
showTopBar: boolean;
|
||||
@@ -105,6 +106,10 @@ export function LayoutHeader({
|
||||
{showTopBar && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ThemeSwitcher />
|
||||
<CommandPaletteTrigger
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
<ProfileIcon />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { 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 ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
import type { SidebarNavSection } from "@app/app/navigation";
|
||||
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
SheetDescription
|
||||
SheetTrigger
|
||||
} from "@app/components/ui/sheet";
|
||||
import { Abel } from "next/font/google";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Menu, Server, Settings, LayoutGrid } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface LayoutMobileMenuProps {
|
||||
orgId?: string;
|
||||
@@ -151,11 +151,11 @@ export function LayoutMobileMenu({
|
||||
}
|
||||
>
|
||||
<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" />
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"resourceLauncherTitle"
|
||||
"resourceSidebarLauncherTitle"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
@@ -207,6 +207,11 @@ export function LayoutMobileMenu({
|
||||
{showTopBar && (
|
||||
<div className="ml-auto flex items-center justify-end">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CommandPaletteTrigger
|
||||
variant="mobile"
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
<ThemeSwitcher />
|
||||
<ProfileIcon />
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,9 @@ import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
LayoutGrid,
|
||||
PanelRightOpen,
|
||||
Server,
|
||||
SquareMousePointer
|
||||
Server
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import dynamic from "next/dynamic";
|
||||
@@ -175,34 +175,48 @@ export function LayoutSidebar({
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
|
||||
isSidebarCollapsed
|
||||
? "px-2 py-2 justify-center"
|
||||
: "px-3 py-1.5"
|
||||
)}
|
||||
title={
|
||||
isSidebarCollapsed
|
||||
? t("resourceLauncherTitle")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
{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">
|
||||
<LayoutGrid 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-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"
|
||||
)}
|
||||
>
|
||||
<SquareMousePointer className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="flex-1">
|
||||
{t("resourceLauncherTitle")}
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<span className="flex-1">
|
||||
{t("resourceSidebarLauncherTitle")}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
@@ -212,36 +226,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
|
||||
|
||||
@@ -103,7 +103,6 @@ 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;
|
||||
@@ -405,9 +404,12 @@ export default function MachineClientsTable({
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
updateAvailable = Boolean(
|
||||
semver.valid(originalRow.olmVersion) &&
|
||||
semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -434,11 +436,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: () => (
|
||||
@@ -458,8 +457,8 @@ export default function MachineClientsTable({
|
||||
orgId={orgId}
|
||||
/>
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Only include actions column if there are rows without userIds
|
||||
if (hasRowsWithoutUserId) {
|
||||
@@ -541,7 +540,7 @@ export default function MachineClientsTable({
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||
}, [hasRowsWithoutUserId, orgId, t, searchParams]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -11,24 +11,79 @@ import {
|
||||
import { Shield, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type OrgPolicyRequiredProps = {
|
||||
orgId: string;
|
||||
policies: {
|
||||
requiredTwoFactor?: boolean;
|
||||
maxSessionLength?: {
|
||||
compliant: boolean;
|
||||
maxSessionLengthHours: number;
|
||||
sessionAgeHours: number;
|
||||
};
|
||||
passwordAge?: {
|
||||
compliant: boolean;
|
||||
maxPasswordAgeDays: number;
|
||||
passwordAgeDays: number;
|
||||
};
|
||||
};
|
||||
redirectAfterAuth?: string;
|
||||
};
|
||||
|
||||
export default function OrgPolicyRequired({
|
||||
orgId,
|
||||
policies
|
||||
policies,
|
||||
redirectAfterAuth
|
||||
}: OrgPolicyRequiredProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const policySteps = [];
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
if (policies?.requiredTwoFactor === false) {
|
||||
policySteps.push(t("enableTwoFactorAuthentication"));
|
||||
const sessionExpired =
|
||||
policies?.maxSessionLength &&
|
||||
policies.maxSessionLength.compliant === false;
|
||||
|
||||
function reauthenticate() {
|
||||
api.post("/auth/logout")
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
const destination = redirectAfterAuth ?? `/${orgId}`;
|
||||
router.push(destination);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (sessionExpired) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-orange-100">
|
||||
<Shield className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
{t("sessionExpired")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("sessionExpiredReauthRequired")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={reauthenticate}
|
||||
>
|
||||
{t("reauthenticate")}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionDeleteSiteResource")]: "deleteSiteResource",
|
||||
[t("actionGetSiteResource")]: "getSiteResource",
|
||||
[t("actionListSiteResources")]: "listSiteResources",
|
||||
[t("actionUpdateSiteResource")]: "updateSiteResource"
|
||||
[t("actionUpdateSiteResource")]: "updateSiteResource",
|
||||
[t("actionCreateResourceSessionToken")]: "createResourceSessionToken"
|
||||
},
|
||||
|
||||
Target: {
|
||||
@@ -111,6 +112,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
@@ -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;
|
||||
@@ -117,15 +119,13 @@ type ClientResourcesTableProps = {
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
initialFilterSite?: Selectedsite | null;
|
||||
};
|
||||
|
||||
export default function PrivateResourcesTable({
|
||||
internalResources,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount,
|
||||
initialFilterSite = null
|
||||
rowCount
|
||||
}: ClientResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
@@ -140,9 +140,10 @@ export default function PrivateResourcesTable({
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const [selectedInternalResource, setSelectedInternalResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
useState<InternalResourceRow | null>(null);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
@@ -151,7 +152,6 @@ export default function PrivateResourcesTable({
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||
|
||||
// useEffect(() => {
|
||||
// const interval = setInterval(() => {
|
||||
@@ -429,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,
|
||||
@@ -450,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(
|
||||
@@ -464,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,
|
||||
@@ -580,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}
|
||||
@@ -597,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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -53,7 +53,6 @@ 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";
|
||||
@@ -114,7 +113,6 @@ export default function SitesTable({
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
@@ -171,7 +169,10 @@ export default function SitesTable({
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorRestart"),
|
||||
description: formatAxiosError(e, t("siteErrorRestartDescription"))
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("siteErrorRestartDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setRestartingSite(null);
|
||||
@@ -358,10 +359,15 @@ export default function SitesTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
let updateAvailable =
|
||||
let updateAvailable = Boolean(
|
||||
latestNewtVersion &&
|
||||
originalRow.newtVersion &&
|
||||
semver.lt(originalRow.newtVersion, latestNewtVersion);
|
||||
originalRow.newtVersion &&
|
||||
semver.valid(originalRow.newtVersion) &&
|
||||
semver.lt(
|
||||
originalRow.newtVersion,
|
||||
latestNewtVersion
|
||||
)
|
||||
);
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
return (
|
||||
@@ -497,6 +503,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,
|
||||
@@ -552,9 +575,7 @@ export default function SitesTable({
|
||||
setRestartingSite(siteRow)
|
||||
}
|
||||
>
|
||||
<span className="text-orange-500">
|
||||
{t("siteRestartButton")}
|
||||
</span>
|
||||
{t("siteRestartButton")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
@@ -601,28 +622,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, latestNewtVersion]);
|
||||
}, [orgId, t, searchParams, latestNewtVersion]);
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -588,9 +588,12 @@ export default function UserDevicesTable({
|
||||
if (agent in latestPlatformVersions) {
|
||||
const agentVersion = latestPlatformVersions[agent];
|
||||
|
||||
updateAvailable = semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
updateAvailable = Boolean(
|
||||
semver.valid(originalRow.olmVersion) &&
|
||||
semver.lt(
|
||||
originalRow.olmVersion,
|
||||
agentVersion.latestVersion
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ function ResourceMultiSelect({
|
||||
const [debounced] = useDebounce(q, 150);
|
||||
|
||||
const { data: resources = [] } = useQuery(
|
||||
orgQueries.resources({ orgId, query: debounced, perPage: 10 })
|
||||
orgQueries.proxyResources({ orgId, query: debounced, perPage: 10 })
|
||||
);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
CommandBarNavSection,
|
||||
SidebarNavSection
|
||||
} from "@app/app/navigation";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
} from "@app/components/ui/command";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
GlobeIcon,
|
||||
GlobeLockIcon,
|
||||
LaptopIcon,
|
||||
PlugIcon,
|
||||
ServerIcon,
|
||||
UserIcon,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState
|
||||
} from "react";
|
||||
import { useCanUseCommandPalette } from "./useCanUseCommandPalette";
|
||||
import { useCommandPaletteActions } from "./useCommandPaletteActions";
|
||||
import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation";
|
||||
import { useCommandPaletteSearch } from "./useCommandPaletteSearch";
|
||||
import { resources } from "@server/db";
|
||||
|
||||
type CommandPaletteProps = {
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
navItems: CommandBarNavSection[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Plan for command bar:
|
||||
* - the nav items should be custom items instead of all of the ones in the sidebar
|
||||
* - actions should be triggered by using `>` (like in Github)
|
||||
* -> if search starts with `>`, the filter should exclude that char in the filter string
|
||||
*/
|
||||
|
||||
export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { open, setOpen } = useCommandPalette();
|
||||
const [search, setSearch] = useState("");
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const isActionMode = search.startsWith(">");
|
||||
|
||||
const navigationGroups = useCommandPaletteNavigation(navItems);
|
||||
// const organizations = useCommandPaletteOrganizations(orgs);
|
||||
const actions = useCommandPaletteActions(orgId, orgs);
|
||||
const {
|
||||
shouldSearch,
|
||||
sites,
|
||||
publicResources,
|
||||
privateResources,
|
||||
users,
|
||||
machineClients,
|
||||
userDevices,
|
||||
isLoading,
|
||||
hasResults: hasEntityResults
|
||||
} = useCommandPaletteSearch({
|
||||
orgId,
|
||||
query: search,
|
||||
enabled: !isActionMode && open
|
||||
});
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setSearch("");
|
||||
}
|
||||
},
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
const runCommand = useCallback(
|
||||
(command: () => void) => {
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
command();
|
||||
},
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title={t("commandPaletteTitle")}
|
||||
description={t("commandPaletteDescription")}
|
||||
className="max-w-2xl **:data-[slot=command-input-wrapper]:h-15"
|
||||
commandProps={{
|
||||
loop: true,
|
||||
filter(value, query) {
|
||||
let search = query;
|
||||
if (query.startsWith(">")) {
|
||||
search = query.substring(1);
|
||||
|
||||
console.log({
|
||||
search,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
value
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase())
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={t("commandPaletteSearchPlaceholder")}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
isLoading={!isActionMode && shouldSearch && isLoading}
|
||||
trailing={
|
||||
!isDesktop ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
aria-label={t("close")}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out">
|
||||
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
|
||||
|
||||
<CommandGroup
|
||||
heading={t("commandActionModeInfo")}
|
||||
className="[&_[cmdk-group-heading]]:text-sm"
|
||||
/>
|
||||
|
||||
{!isActionMode &&
|
||||
navigationGroups.map((group, groupIndex) => (
|
||||
<React.Fragment key={group.heading}>
|
||||
{groupIndex > 0 && <CommandSeparator />}
|
||||
<CommandGroup
|
||||
heading={group.heading}
|
||||
className={cn(
|
||||
"[&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-sm pb-2.5",
|
||||
groupIndex > 0 &&
|
||||
"[&_[cmdk-group-heading]]:pt-3"
|
||||
)}
|
||||
>
|
||||
{group.items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={`${item.title} ${group.heading}`}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
router.push(item.href)
|
||||
)
|
||||
}
|
||||
className="h-9"
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* {organizations.length > 1 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup
|
||||
heading={t("commandPaletteOrganizations")}
|
||||
>
|
||||
{organizations.map((org) => (
|
||||
<CommandItem
|
||||
key={org.id}
|
||||
value={`${org.name} ${org.orgId}`}
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push(org.href))
|
||||
}
|
||||
>
|
||||
<span className="truncate">{org.name}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{org.orgId}
|
||||
</span>
|
||||
{org.isPrimaryOrg && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-auto shrink-0 text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{t("primary")}
|
||||
</Badge>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)} */}
|
||||
|
||||
{!isActionMode && shouldSearch && orgId && hasEntityResults && (
|
||||
<CommandGroup
|
||||
heading={t("commandSearchResults")}
|
||||
className={cn(
|
||||
"[&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-sm pb-2.5"
|
||||
)}
|
||||
>
|
||||
{sites.map((site) => (
|
||||
<CommandItem
|
||||
key={site.id}
|
||||
value={`${site.name} site`}
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push(site.href))
|
||||
}
|
||||
className="h-9"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<PlugIcon className="size-4 flex-none" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("commandSites")}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||
<span className="truncate">
|
||||
{site.name}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{publicResources.map((resource) => (
|
||||
<CommandItem
|
||||
key={resource.id}
|
||||
value={`${resource.name} public resource`}
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push(resource.href))
|
||||
}
|
||||
className="h-9"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<GlobeIcon className="size-4 flex-none" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("commandProxyResources")}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||
<span className="truncate">
|
||||
{resource.name}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{privateResources.map((resource) => (
|
||||
<CommandItem
|
||||
key={resource.id}
|
||||
value={`${resource.name} private resource`}
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push(resource.href))
|
||||
}
|
||||
className="h-9"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<GlobeLockIcon className="size-4 flex-none" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("commandClientResources")}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||
<span className="truncate">
|
||||
{resource.name}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={`${user.name} ${user.email}`}
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push(user.href))
|
||||
}
|
||||
className="h-9"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<UserIcon className="size-4 flex-none" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("commandUsers")}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||
<div className="inline-flex min-w-0 items-center gap-1">
|
||||
<span className="truncate">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{machineClients.map((client) => (
|
||||
<CommandItem
|
||||
key={client.id}
|
||||
value={`${client.name} client`}
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push(client.href))
|
||||
}
|
||||
className="h-9"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<ServerIcon className="size-4 flex-none" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("commandMachineClients")}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||
<span className="truncate">
|
||||
{client.name}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
{userDevices.map((device) => (
|
||||
<CommandItem
|
||||
key={device.id}
|
||||
value={`${device.name} user device`}
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push(device.href))
|
||||
}
|
||||
className="h-9"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<LaptopIcon className="size-4 flex-none" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("commandUserDevices")}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5! flex-none relative p-0! top-px" />
|
||||
<span className="truncate">
|
||||
{device.name}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{isActionMode && actions.length > 0 && (
|
||||
<>
|
||||
<CommandGroup
|
||||
heading={t("commandPaletteActions")}
|
||||
className={cn(
|
||||
"[&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-sm pb-2.5"
|
||||
)}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<CommandItem
|
||||
key={action.id}
|
||||
value={action.label}
|
||||
onSelect={() =>
|
||||
runCommand(() => {
|
||||
if (action.onSelect) {
|
||||
action.onSelect();
|
||||
} else if (action.href) {
|
||||
router.push(action.href);
|
||||
}
|
||||
})
|
||||
}
|
||||
className="h-9"
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/*******************************/
|
||||
/* COMMAND PALETTE CONTEXT */
|
||||
/*******************************/
|
||||
export type CommandPaletteContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export function useCommandPalette() {
|
||||
const context = useContext(CommandPaletteContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useCommandPalette must be used within CommandPaletteProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
//*******************************/
|
||||
/* COMMAND PALETTE PROVIDER */
|
||||
/*******************************/
|
||||
type CommandPaletteProviderProps = {
|
||||
children: React.ReactNode;
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
navItems: SidebarNavSection[];
|
||||
};
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
const tagName = target.tagName;
|
||||
return (
|
||||
tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"
|
||||
);
|
||||
}
|
||||
|
||||
function CommandPaletteProviderInner({
|
||||
children,
|
||||
orgId,
|
||||
orgs,
|
||||
navItems
|
||||
}: CommandPaletteProviderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const canUseCommandPalette = useCanUseCommandPalette(orgId, orgs);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpen((current) => !current);
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo<CommandPaletteContextValue>(
|
||||
() => ({
|
||||
open,
|
||||
setOpen,
|
||||
toggle
|
||||
}),
|
||||
[open, toggle]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canUseCommandPalette) {
|
||||
return;
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key.toLowerCase() !== "k" ||
|
||||
!(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open && isEditableTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [canUseCommandPalette, open, toggle]);
|
||||
|
||||
return (
|
||||
<CommandPaletteContext value={contextValue}>
|
||||
{children}
|
||||
{canUseCommandPalette ? (
|
||||
<CommandPalette orgId={orgId} orgs={orgs} navItems={navItems} />
|
||||
) : null}
|
||||
</CommandPaletteContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandPaletteProvider(props: CommandPaletteProviderProps) {
|
||||
return <CommandPaletteProviderInner {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { CommandShortcut } from "@app/components/ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { useCommandPalette } from "./CommandPalette";
|
||||
import { useCanUseCommandPalette } from "./useCanUseCommandPalette";
|
||||
|
||||
type CommandPaletteTriggerProps = {
|
||||
variant?: "header" | "mobile";
|
||||
className?: string;
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
};
|
||||
|
||||
function useIsMac() {
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(/Mac|iPhone|iPod|iPad/.test(navigator.platform));
|
||||
}, []);
|
||||
|
||||
return isMac;
|
||||
}
|
||||
|
||||
export function CommandPaletteTrigger({
|
||||
variant = "header",
|
||||
className,
|
||||
orgId,
|
||||
orgs
|
||||
}: CommandPaletteTriggerProps) {
|
||||
const t = useTranslations();
|
||||
const { setOpen } = useCommandPalette();
|
||||
const isMac = useIsMac();
|
||||
const canUseCommandPalette = useCanUseCommandPalette(orgId, orgs);
|
||||
|
||||
if (!canUseCommandPalette) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (variant === "mobile") {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className}
|
||||
aria-label={t("commandPaletteTitle")}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Search className="size-5" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"relative hidden h-9 w-56 justify-start pl-8 pr-3 text-muted-foreground md:flex lg:w-64",
|
||||
className
|
||||
)}
|
||||
aria-label={t("commandPaletteTitle")}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-left text-sm font-normal">
|
||||
{t("commandPaletteSearchPlaceholder")}
|
||||
</span>
|
||||
<CommandShortcut>
|
||||
{isMac
|
||||
? t("commandPaletteShortcutMac")
|
||||
: t("commandPaletteShortcutWindows")}
|
||||
</CommandShortcut>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function useCanUseCommandPalette(
|
||||
orgId?: string,
|
||||
orgs?: ListUserOrgsResponse["orgs"]
|
||||
) {
|
||||
const pathname = usePathname();
|
||||
const { user } = useUserContext();
|
||||
|
||||
if (pathname?.startsWith("/admin")) {
|
||||
return user.serverAdmin;
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
return Boolean(currentOrg?.isAdmin || currentOrg?.isOwner);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
BellRing,
|
||||
Building2,
|
||||
Globe,
|
||||
GlobeLock,
|
||||
KeyRound,
|
||||
MonitorUp,
|
||||
Plus,
|
||||
SunMoon,
|
||||
UserPlus
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
|
||||
export type CommandPaletteAction = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
href?: string;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
export function useCommandPaletteActions(
|
||||
orgId?: string,
|
||||
orgs?: ListUserOrgsResponse["orgs"]
|
||||
): CommandPaletteAction[] {
|
||||
const t = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const { env } = useEnvContext();
|
||||
const { user } = useUserContext();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
|
||||
return useMemo(() => {
|
||||
const actions: CommandPaletteAction[] = [];
|
||||
|
||||
function cycleTheme() {
|
||||
const currentTheme = theme || "system";
|
||||
if (currentTheme === "light") {
|
||||
setTheme("dark");
|
||||
} else if (currentTheme === "dark") {
|
||||
setTheme("system");
|
||||
} else {
|
||||
setTheme("light");
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdminPage) {
|
||||
actions.push({
|
||||
id: "create-admin-api-key",
|
||||
label: t("commandPaletteCreateApiKey"),
|
||||
icon: <KeyRound className="size-4" />,
|
||||
href: "/admin/api-keys/create"
|
||||
});
|
||||
|
||||
if (
|
||||
build === "oss" ||
|
||||
env?.app.identityProviderMode === "global" ||
|
||||
env?.app.identityProviderMode === undefined
|
||||
) {
|
||||
actions.push({
|
||||
id: "create-admin-idp",
|
||||
label: t("commandPaletteCreateIdentityProvider"),
|
||||
icon: <Plus className="size-4" />,
|
||||
href: "/admin/idp/create"
|
||||
});
|
||||
}
|
||||
} else if (orgId) {
|
||||
actions.push({
|
||||
id: "create-site",
|
||||
label: t("commandPaletteCreateSite"),
|
||||
icon: <Plus className="size-4" />,
|
||||
href: `/${orgId}/settings/sites/create`
|
||||
});
|
||||
actions.push({
|
||||
id: "create-proxy-resource",
|
||||
label: t("commandPaletteCreateProxyResource"),
|
||||
icon: <Globe className="size-4" />,
|
||||
href: `/${orgId}/settings/resources/proxy/create`
|
||||
});
|
||||
actions.push({
|
||||
id: "create-private-resource",
|
||||
label: t("commandPaletteCreatePrivateResource"),
|
||||
icon: <GlobeLock className="size-4" />,
|
||||
href: `/${orgId}/settings/resources/private/create`
|
||||
});
|
||||
actions.push({
|
||||
id: "create-machine-client",
|
||||
label: t("commandPaletteCreateMachineClient"),
|
||||
icon: <MonitorUp className="size-4" />,
|
||||
href: `/${orgId}/settings/clients/machine/create`
|
||||
});
|
||||
actions.push({
|
||||
id: "create-user",
|
||||
label: t("commandPaletteCreateUser"),
|
||||
icon: <UserPlus className="size-4" />,
|
||||
href: `/${orgId}/settings/access/users/create`
|
||||
});
|
||||
actions.push({
|
||||
id: "create-api-key",
|
||||
label: t("commandPaletteCreateApiKey"),
|
||||
icon: <KeyRound className="size-4" />,
|
||||
href: `/${orgId}/settings/api-keys/create`
|
||||
});
|
||||
|
||||
if (!env?.flags.disableEnterpriseFeatures) {
|
||||
actions.push({
|
||||
id: "create-alert-rule",
|
||||
label: t("commandPaletteCreateAlertRule"),
|
||||
icon: <BellRing className="size-4" />,
|
||||
href: `/${orgId}/settings/alerting/create`
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||
build === "saas" ||
|
||||
env?.app.identityProviderMode === "org" ||
|
||||
(env?.app.identityProviderMode === undefined && build !== "oss")
|
||||
) {
|
||||
actions.push({
|
||||
id: "create-idp",
|
||||
label: t("commandPaletteCreateIdentityProvider"),
|
||||
icon: <Plus className="size-4" />,
|
||||
href: `/${orgId}/settings/idp/create`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: "toggle-theme",
|
||||
label: t("commandPaletteToggleTheme"),
|
||||
icon: <SunMoon className="size-4" />,
|
||||
onSelect: cycleTheme
|
||||
});
|
||||
|
||||
if (user.serverAdmin && !isAdminPage) {
|
||||
actions.push({
|
||||
id: "go-admin",
|
||||
label: t("serverAdmin"),
|
||||
icon: <Building2 className="size-4" />,
|
||||
href: "/admin/users"
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import type { CommandBarNavSection } from "@app/app/navigation";
|
||||
import { flattenNavSections } from "@app/lib/flattenNavItems";
|
||||
import {
|
||||
hydrateNavHref,
|
||||
navHrefParamsFromRoute
|
||||
} from "@app/lib/hydrateNavHref";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type NavigationCommand = {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
icon?: React.ReactNode;
|
||||
sectionHeading: string;
|
||||
};
|
||||
|
||||
export type NavigationCommandGroup = {
|
||||
heading: string;
|
||||
items: NavigationCommand[];
|
||||
};
|
||||
|
||||
export function useCommandPaletteNavigation(
|
||||
navItems: CommandBarNavSection[]
|
||||
): NavigationCommandGroup[] {
|
||||
const params = useParams();
|
||||
const t = useTranslations();
|
||||
|
||||
return useMemo(() => {
|
||||
const hrefParams = navHrefParamsFromRoute(params);
|
||||
const flat = flattenNavSections(navItems);
|
||||
const groups = new Map<string, NavigationCommand[]>();
|
||||
|
||||
for (const item of flat) {
|
||||
const href = hydrateNavHref(item.href, hrefParams);
|
||||
if (!href) continue;
|
||||
|
||||
const groupItems = groups.get(item.sectionHeading) ?? [];
|
||||
groupItems.push({
|
||||
id: `nav-${item.sectionHeading}-${item.title}-${href}`,
|
||||
title: t(item.title),
|
||||
href,
|
||||
icon: item.icon,
|
||||
sectionHeading: item.sectionHeading
|
||||
});
|
||||
groups.set(item.sectionHeading, groupItems);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([heading, items]) => ({
|
||||
heading: t(heading),
|
||||
items
|
||||
}));
|
||||
}, [navItems, params, t]);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type OrganizationCommand = {
|
||||
id: string;
|
||||
orgId: string;
|
||||
name: string;
|
||||
isPrimaryOrg?: boolean;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export function useCommandPaletteOrganizations(
|
||||
orgs: ListUserOrgsResponse["orgs"] | undefined
|
||||
): OrganizationCommand[] {
|
||||
const pathname = usePathname();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!orgs?.length) return [];
|
||||
|
||||
const sortedOrgs = [...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;
|
||||
});
|
||||
|
||||
return sortedOrgs.map((org) => {
|
||||
const newPath = pathname.includes("/settings/")
|
||||
? pathname.replace(/^\/[^/]+/, `/${org.orgId}`)
|
||||
: `/${org.orgId}`;
|
||||
|
||||
return {
|
||||
id: `org-${org.orgId}`,
|
||||
orgId: org.orgId,
|
||||
name: org.name,
|
||||
isPrimaryOrg: org.isPrimaryOrg,
|
||||
href: newPath
|
||||
};
|
||||
});
|
||||
}, [orgs, pathname]);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
const SEARCH_PER_PAGE = 5;
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
export type SiteSearchResult = {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type ResourceSearchResult = {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type UserSearchResult = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type ClientSearchResult = {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export function useCommandPaletteSearch({
|
||||
orgId,
|
||||
query,
|
||||
enabled
|
||||
}: {
|
||||
orgId?: string;
|
||||
query: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const [debouncedQuery] = useDebounce(query, 150);
|
||||
const trimmedQuery = debouncedQuery.trim();
|
||||
const shouldSearch =
|
||||
enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH;
|
||||
|
||||
const [
|
||||
sitesQuery,
|
||||
proxyResourcesQuery,
|
||||
privateResourcesQuery,
|
||||
usersQuery,
|
||||
clientsQuery,
|
||||
userDevicesQuery
|
||||
] = useQueries({
|
||||
queries: [
|
||||
{
|
||||
...orgQueries.sites({
|
||||
orgId: orgId ?? "",
|
||||
query: trimmedQuery,
|
||||
perPage: SEARCH_PER_PAGE
|
||||
}),
|
||||
enabled: shouldSearch
|
||||
},
|
||||
{
|
||||
...orgQueries.proxyResources({
|
||||
orgId: orgId ?? "",
|
||||
query: trimmedQuery,
|
||||
perPage: SEARCH_PER_PAGE
|
||||
}),
|
||||
enabled: shouldSearch
|
||||
},
|
||||
{
|
||||
...orgQueries.privateResources({
|
||||
orgId: orgId ?? "",
|
||||
query: trimmedQuery,
|
||||
perPage: SEARCH_PER_PAGE
|
||||
}),
|
||||
enabled: shouldSearch
|
||||
},
|
||||
{
|
||||
...orgQueries.users({
|
||||
orgId: orgId ?? "",
|
||||
query: trimmedQuery,
|
||||
perPage: SEARCH_PER_PAGE
|
||||
}),
|
||||
enabled: shouldSearch
|
||||
},
|
||||
{
|
||||
...orgQueries.machineClients({
|
||||
orgId: orgId ?? "",
|
||||
query: trimmedQuery,
|
||||
perPage: SEARCH_PER_PAGE
|
||||
}),
|
||||
enabled: shouldSearch
|
||||
},
|
||||
{
|
||||
...orgQueries.userDevices({
|
||||
orgId: orgId ?? "",
|
||||
query: trimmedQuery,
|
||||
perPage: SEARCH_PER_PAGE
|
||||
}),
|
||||
enabled: shouldSearch
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const sites = useMemo((): SiteSearchResult[] => {
|
||||
if (!orgId || !sitesQuery.data) return [];
|
||||
return sitesQuery.data.map((site) => ({
|
||||
id: `site-${site.siteId}`,
|
||||
name: site.name,
|
||||
href: `/${orgId}/settings/sites/${site.niceId}`
|
||||
}));
|
||||
}, [orgId, sitesQuery.data]);
|
||||
|
||||
const publicResources = useMemo((): ResourceSearchResult[] => {
|
||||
if (!orgId || !proxyResourcesQuery.data) return [];
|
||||
return proxyResourcesQuery.data.map((resource) => ({
|
||||
id: `resource-${resource.resourceId}`,
|
||||
name: resource.name,
|
||||
href: `/${orgId}/settings/resources/proxy/${resource.niceId}`
|
||||
}));
|
||||
}, [orgId, proxyResourcesQuery.data]);
|
||||
|
||||
const privateResources = useMemo((): ResourceSearchResult[] => {
|
||||
if (!orgId || !privateResourcesQuery.data) return [];
|
||||
return privateResourcesQuery.data.map((resource) => ({
|
||||
id: `site-resource-${resource.siteResourceId}`,
|
||||
name: resource.name,
|
||||
href: `/${orgId}/settings/resources/private/${resource.niceId}`
|
||||
}));
|
||||
}, [orgId, privateResourcesQuery.data]);
|
||||
|
||||
const users = useMemo((): UserSearchResult[] => {
|
||||
if (!orgId || !usersQuery.data) return [];
|
||||
return usersQuery.data.map((user) => ({
|
||||
id: `user-${user.id}`,
|
||||
name: user.name ?? user.email ?? user.username ?? "",
|
||||
email: user.email ?? user.username ?? "",
|
||||
href: `/${orgId}/settings/access/users/${user.id}`
|
||||
}));
|
||||
}, [orgId, usersQuery.data]);
|
||||
|
||||
const machineClients = useMemo((): ClientSearchResult[] => {
|
||||
if (!orgId || !clientsQuery.data) return [];
|
||||
return clientsQuery.data
|
||||
.filter((client) => !client.userId)
|
||||
.map((client) => ({
|
||||
id: `client-${client.clientId}`,
|
||||
name: client.name,
|
||||
href: `/${orgId}/settings/clients/machine/${client.niceId}`
|
||||
}));
|
||||
}, [orgId, clientsQuery.data]);
|
||||
|
||||
const userDevices = useMemo((): ClientSearchResult[] => {
|
||||
if (!orgId || !userDevicesQuery.data) return [];
|
||||
return userDevicesQuery.data
|
||||
.filter((client) => !client.userId)
|
||||
.map((client) => ({
|
||||
id: `client-${client.clientId}`,
|
||||
name: client.name,
|
||||
href: `/${orgId}/settings/clients/user/${client.niceId}`
|
||||
}));
|
||||
}, [orgId, userDevicesQuery.data]);
|
||||
|
||||
const isLoading =
|
||||
shouldSearch &&
|
||||
(sitesQuery.isFetching ||
|
||||
proxyResourcesQuery.isFetching ||
|
||||
privateResourcesQuery.isFetching ||
|
||||
usersQuery.isFetching ||
|
||||
userDevicesQuery.isFetching ||
|
||||
clientsQuery.isFetching);
|
||||
|
||||
const hasResults =
|
||||
sites.length > 0 ||
|
||||
publicResources.length > 0 ||
|
||||
users.length > 0 ||
|
||||
privateResources.length > 0 ||
|
||||
userDevices.length > 0 ||
|
||||
machineClients.length > 0;
|
||||
|
||||
return {
|
||||
debouncedQuery: trimmedQuery,
|
||||
shouldSearch,
|
||||
sites,
|
||||
publicResources,
|
||||
privateResources,
|
||||
users,
|
||||
machineClients,
|
||||
userDevices,
|
||||
isLoading,
|
||||
hasResults
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -20,6 +20,8 @@ export type MultiSitesSelectorProps = {
|
||||
onSelectionChange: (sites: Selectedsite[]) => void;
|
||||
filterTypes?: string[];
|
||||
scope?: "org" | "launcher";
|
||||
onClear?: () => void;
|
||||
showClear?: boolean;
|
||||
};
|
||||
|
||||
export function formatMultiSitesSelectorLabel(
|
||||
@@ -42,7 +44,9 @@ export function MultiSitesSelector({
|
||||
selectedSites,
|
||||
onSelectionChange,
|
||||
filterTypes,
|
||||
scope = "org"
|
||||
scope = "org",
|
||||
onClear,
|
||||
showClear = false
|
||||
}: MultiSitesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||
@@ -60,7 +64,7 @@ export function MultiSitesSelector({
|
||||
...launcherQueries.sites({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 500
|
||||
perPage: 20
|
||||
}),
|
||||
enabled: scope === "launcher"
|
||||
});
|
||||
@@ -107,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}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
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,
|
||||
@@ -46,14 +47,14 @@ export function LauncherFilterPopover({
|
||||
const { data: labels = [] } = useQuery(
|
||||
launcherQueries.labels({
|
||||
orgId,
|
||||
perPage: 500
|
||||
perPage: 20
|
||||
})
|
||||
);
|
||||
|
||||
const { data: sites = [] } = useQuery(
|
||||
launcherQueries.sites({
|
||||
orgId,
|
||||
perPage: 500
|
||||
perPage: 20
|
||||
})
|
||||
);
|
||||
|
||||
@@ -96,14 +97,32 @@ export function LauncherFilterPopover({
|
||||
[labels, selectedLabels]
|
||||
);
|
||||
|
||||
const activeFilterCount = selectedSites.length + selectedLabels.length;
|
||||
|
||||
return (
|
||||
<Popover modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="relative shrink-0"
|
||||
>
|
||||
<Funnel className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("resourceLauncherFilter")}
|
||||
{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">
|
||||
@@ -139,6 +158,10 @@ export function LauncherFilterPopover({
|
||||
selectedSites={resolvedSelectedSites}
|
||||
onSelectionChange={onSitesChange}
|
||||
scope="launcher"
|
||||
showClear={selectedSites.length > 0}
|
||||
onClear={() => {
|
||||
onSitesChange([]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -172,6 +195,7 @@ export function LauncherFilterPopover({
|
||||
<LabelsFilterSelector
|
||||
orgId={orgId}
|
||||
scope="launcher"
|
||||
selectedLabels={resolvedSelectedLabels}
|
||||
isSelected={(label) =>
|
||||
selectedLabelIds.has(label.labelId)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -69,16 +69,20 @@ export function LauncherGroupList({
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||
useInfiniteQuery({
|
||||
...launcherQueries.groups(orgId, groupFilters),
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
groups: initialGroups,
|
||||
pagination: groupsPagination
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
},
|
||||
refetchOnMount: false
|
||||
...(initialGroups.length > 0
|
||||
? {
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
groups: initialGroups,
|
||||
pagination: groupsPagination
|
||||
}
|
||||
],
|
||||
pageParams: [1]
|
||||
},
|
||||
refetchOnMount: false as const
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
||||
|
||||
@@ -23,9 +23,8 @@ export function LauncherRefreshButton({
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
className={`size-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">{t("refresh")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
"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,
|
||||
SidePanelDescription,
|
||||
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";
|
||||
|
||||
@@ -23,6 +58,368 @@ type LauncherResourcePanelProps = {
|
||||
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,
|
||||
@@ -37,11 +434,16 @@ export function LauncherResourcePanel({
|
||||
<SidePanelContent>
|
||||
<SidePanelHeader>
|
||||
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
|
||||
<SidePanelDescription>
|
||||
{t("resourceLauncherResourceDetailsDescription")}
|
||||
</SidePanelDescription>
|
||||
</SidePanelHeader>
|
||||
<SidePanelBody />
|
||||
<SidePanelBody>
|
||||
{resource ? (
|
||||
<LauncherResourcePanelBody
|
||||
orgId={orgId}
|
||||
resource={resource}
|
||||
open={open}
|
||||
/>
|
||||
) : null}
|
||||
</SidePanelBody>
|
||||
<SidePanelFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,22 +15,27 @@ import {
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import type { LauncherViewConfig } from "@server/routers/launcher/types";
|
||||
import type {
|
||||
LauncherScaleCapabilities,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
type LauncherSettingsMenuProps = {
|
||||
config: LauncherViewConfig;
|
||||
isDefaultView: boolean;
|
||||
capabilities: LauncherScaleCapabilities;
|
||||
isCompactMode: boolean;
|
||||
selectedGroupBy: LauncherViewConfig["groupBy"];
|
||||
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
|
||||
onDeleteView: () => void;
|
||||
};
|
||||
|
||||
export function LauncherSettingsMenu({
|
||||
config,
|
||||
isDefaultView,
|
||||
onConfigChange,
|
||||
onDeleteView
|
||||
capabilities,
|
||||
isCompactMode,
|
||||
selectedGroupBy,
|
||||
onConfigChange
|
||||
}: LauncherSettingsMenuProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -51,7 +56,7 @@ export function LauncherSettingsMenu({
|
||||
{t("resourceLauncherGroupBy")}
|
||||
</p>
|
||||
<Select
|
||||
value={config.groupBy}
|
||||
value={selectedGroupBy}
|
||||
onValueChange={(value) =>
|
||||
onConfigChange({
|
||||
groupBy:
|
||||
@@ -63,14 +68,46 @@ export function LauncherSettingsMenu({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="site">
|
||||
<SelectItem value="none">
|
||||
{t("resourceLauncherGroupByNone")}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="site"
|
||||
disabled={
|
||||
!capabilities.allowSiteGrouping ||
|
||||
(isCompactMode &&
|
||||
config.siteIds.length === 0)
|
||||
}
|
||||
>
|
||||
{t("resourceLauncherGroupBySite")}
|
||||
</SelectItem>
|
||||
<SelectItem value="label">
|
||||
<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">
|
||||
@@ -116,16 +153,6 @@ export function LauncherSettingsMenu({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDefaultView ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full rounded-xl"
|
||||
onClick={onDeleteView}
|
||||
>
|
||||
{t("resourceLauncherDeleteView")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -68,11 +68,14 @@ type LauncherSaveViewMenuProps = {
|
||||
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({
|
||||
@@ -80,13 +83,17 @@ export function LauncherSaveViewMenu({
|
||||
isAdmin,
|
||||
isOrgWideView,
|
||||
hasUnsavedChanges,
|
||||
canResetSystemDefault,
|
||||
onSaveToCurrent,
|
||||
onSaveAsNew,
|
||||
onSaveForEveryone,
|
||||
onMakePersonal,
|
||||
onResetView
|
||||
onResetView,
|
||||
onResetSystemDefault,
|
||||
onDeleteView
|
||||
}: LauncherSaveViewMenuProps) {
|
||||
const t = useTranslations();
|
||||
const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -108,7 +115,26 @@ export function LauncherSaveViewMenu({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{!isDefaultView && (isAdmin || !isOrgWideView) ? (
|
||||
{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>
|
||||
@@ -126,6 +152,17 @@ export function LauncherSaveViewMenu({
|
||||
{t("resourceLauncherMakePersonal")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canDeleteView ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={onDeleteView}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{t("resourceLauncherDeleteView")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -29,12 +29,23 @@ import {
|
||||
} from "@app/lib/launcherUrlState";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherViewConfig,
|
||||
LauncherViewRecord
|
||||
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 } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -52,20 +63,26 @@ 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;
|
||||
@@ -78,9 +95,11 @@ export default function ResourceLauncher({
|
||||
orgId,
|
||||
isAdmin,
|
||||
views,
|
||||
defaultViewOverrides,
|
||||
activeViewId,
|
||||
config,
|
||||
savedConfig,
|
||||
scale: initialScale,
|
||||
groups,
|
||||
groupsPagination
|
||||
}: ResourceLauncherProps) {
|
||||
@@ -88,6 +107,7 @@ export default function ResourceLauncher({
|
||||
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();
|
||||
@@ -95,11 +115,47 @@ export default function ResourceLauncher({
|
||||
|
||||
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);
|
||||
@@ -129,13 +185,24 @@ export default function ResourceLauncher({
|
||||
return;
|
||||
}
|
||||
|
||||
const baseConfig = getLauncherUrlBaseConfig(lastView, views);
|
||||
const baseConfig = getLauncherUrlBaseConfig(
|
||||
lastView,
|
||||
views,
|
||||
defaultViewOverrides
|
||||
);
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: lastView,
|
||||
config: baseConfig
|
||||
});
|
||||
navigate({ searchParams: params, replace: true });
|
||||
}, [activeViewId, navigate, orgId, searchParams, views]);
|
||||
}, [
|
||||
activeViewId,
|
||||
defaultViewOverrides,
|
||||
navigate,
|
||||
orgId,
|
||||
searchParams,
|
||||
views
|
||||
]);
|
||||
|
||||
const navigateToConfig = useCallback(
|
||||
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
|
||||
@@ -158,10 +225,14 @@ export default function ResourceLauncher({
|
||||
const selectView = useCallback(
|
||||
(viewId: LauncherActiveViewId) => {
|
||||
writeLauncherLastView(orgId, viewId);
|
||||
const baseConfig = getLauncherUrlBaseConfig(viewId, views);
|
||||
const baseConfig = getLauncherUrlBaseConfig(
|
||||
viewId,
|
||||
views,
|
||||
defaultViewOverrides
|
||||
);
|
||||
navigateToConfig(viewId, baseConfig);
|
||||
},
|
||||
[navigateToConfig, orgId, views]
|
||||
[defaultViewOverrides, navigateToConfig, orgId, views]
|
||||
);
|
||||
|
||||
const activeSavedView = useMemo(
|
||||
@@ -175,6 +246,10 @@ export default function ResourceLauncher({
|
||||
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(
|
||||
() =>
|
||||
@@ -270,6 +345,87 @@ export default function ResourceLauncher({
|
||||
}
|
||||
});
|
||||
|
||||
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}`);
|
||||
@@ -278,7 +434,11 @@ export default function ResourceLauncher({
|
||||
writeLauncherLastView(orgId, "default");
|
||||
const params = serializeLauncherUrlState({
|
||||
viewId: "default",
|
||||
config: getLauncherUrlBaseConfig("default", views)
|
||||
config: getLauncherUrlBaseConfig(
|
||||
"default",
|
||||
views,
|
||||
defaultViewOverrides
|
||||
)
|
||||
});
|
||||
navigate({ searchParams: params, replace: true });
|
||||
router.refresh();
|
||||
@@ -328,9 +488,17 @@ export default function ResourceLauncher({
|
||||
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({
|
||||
@@ -343,7 +511,14 @@ export default function ResourceLauncher({
|
||||
};
|
||||
|
||||
const handleSaveToCurrent = () => {
|
||||
if (isDefaultView || (isOrgWideView && !isAdmin)) {
|
||||
if (isDefaultView) {
|
||||
saveDefaultViewMutation.mutate({
|
||||
config,
|
||||
orgWide: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isOrgWideView && !isAdmin) {
|
||||
return;
|
||||
}
|
||||
updateViewMutation.mutate({
|
||||
@@ -360,6 +535,10 @@ export default function ResourceLauncher({
|
||||
|
||||
const handleSaveForEveryone = () => {
|
||||
if (isDefaultView) {
|
||||
saveDefaultViewMutation.mutate({
|
||||
config,
|
||||
orgWide: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateViewMutation.mutate({
|
||||
@@ -389,6 +568,18 @@ export default function ResourceLauncher({
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
@@ -396,7 +587,9 @@ export default function ResourceLauncher({
|
||||
|
||||
const renderToolbarSearch = (searchClassName: string) => (
|
||||
<div className={cn("relative shrink-0", searchClassName)}>
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<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}
|
||||
@@ -412,19 +605,8 @@ export default function ResourceLauncher({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderToolbarActions = () => (
|
||||
const renderToolbarFilterSort = () => (
|
||||
<>
|
||||
<LauncherSaveViewMenu
|
||||
isDefaultView={isDefaultView}
|
||||
isAdmin={isAdmin}
|
||||
isOrgWideView={isOrgWideView}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
onSaveToCurrent={handleSaveToCurrent}
|
||||
onSaveAsNew={handleSaveAsNew}
|
||||
onSaveForEveryone={handleSaveForEveryone}
|
||||
onMakePersonal={handleMakePersonal}
|
||||
onResetView={handleResetView}
|
||||
/>
|
||||
<LauncherFilterPopover
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
@@ -448,15 +630,31 @@ export default function ResourceLauncher({
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
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}
|
||||
isDefaultView={isDefaultView}
|
||||
capabilities={scale.capabilities}
|
||||
isCompactMode={scale.mode === "compact"}
|
||||
selectedGroupBy={effectiveConfig.groupBy}
|
||||
onConfigChange={applyConfigPatch}
|
||||
onDeleteView={() => {
|
||||
if (!isDefaultView) {
|
||||
deleteViewMutation.mutate(activeViewId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<LauncherRefreshButton
|
||||
onRefresh={refreshData}
|
||||
@@ -483,6 +681,9 @@ export default function ResourceLauncher({
|
||||
{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>
|
||||
@@ -495,22 +696,64 @@ export default function ResourceLauncher({
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{renderToolbarActions()}
|
||||
</div>
|
||||
{renderToolbarSearch("w-full")}
|
||||
<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>
|
||||
)}
|
||||
|
||||
<LauncherGroupList
|
||||
{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}
|
||||
activeViewId={activeViewId}
|
||||
config={config}
|
||||
initialGroups={groups}
|
||||
groupsPagination={groupsPagination}
|
||||
onClearFilters={handleClearFilters}
|
||||
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>
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
type PolicyAccessRule
|
||||
} from "./policy-access-rule-utils";
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
import type { readonly } from "zod";
|
||||
|
||||
export type PolicyAccessRulesTableProps = {
|
||||
rules: PolicyAccessRule[];
|
||||
@@ -106,7 +107,7 @@ function getColumnClassName(columnId: string) {
|
||||
return "w-42 max-w-42";
|
||||
}
|
||||
if (columnId === "match") {
|
||||
return "w-36 max-w-36";
|
||||
return "w-42 max-w-42";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -230,6 +231,7 @@ export function PolicyAccessRulesTable({
|
||||
IP: "IP",
|
||||
CIDR: t("ipAddressRange"),
|
||||
COUNTRY: t("country"),
|
||||
COUNTRY_IS_NOT: t("countryIsNot"),
|
||||
ASN: "ASN",
|
||||
REGION: t("region")
|
||||
}),
|
||||
@@ -442,13 +444,15 @@ export function PolicyAccessRulesTable({
|
||||
| "IP"
|
||||
| "PATH"
|
||||
| "COUNTRY"
|
||||
| "COUNTRY_IS_NOT"
|
||||
| "ASN"
|
||||
| "REGION"
|
||||
) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
match: value,
|
||||
value:
|
||||
value === "COUNTRY"
|
||||
value === "COUNTRY" ||
|
||||
value === "COUNTRY_IS_NOT"
|
||||
? "US"
|
||||
: value === "ASN"
|
||||
? "AS15169"
|
||||
@@ -458,7 +462,7 @@ export function PolicyAccessRulesTable({
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full min-w-0">
|
||||
<SelectTrigger className="h-8 w-36 min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -470,9 +474,14 @@ export function PolicyAccessRulesTable({
|
||||
{RuleMatch.CIDR}
|
||||
</SelectItem>
|
||||
{isMaxmindAvailable && (
|
||||
<SelectItem value="COUNTRY">
|
||||
{RuleMatch.COUNTRY}
|
||||
</SelectItem>
|
||||
<>
|
||||
<SelectItem value="COUNTRY">
|
||||
{RuleMatch.COUNTRY}
|
||||
</SelectItem>
|
||||
<SelectItem value="COUNTRY_IS_NOT">
|
||||
{RuleMatch.COUNTRY_IS_NOT}
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{includeRegionMatch && isMaxmindAvailable && (
|
||||
<SelectItem value="REGION">
|
||||
@@ -494,14 +503,16 @@ export function PolicyAccessRulesTable({
|
||||
cell: ({ row }) => {
|
||||
let selectedCountry: (typeof COUNTRIES)[number] | undefined;
|
||||
if (
|
||||
row.original.match === "COUNTRY" &&
|
||||
(row.original.match === "COUNTRY" ||
|
||||
row.original.match === "COUNTRY_IS_NOT") &&
|
||||
row.original.value
|
||||
) {
|
||||
selectedCountry = COUNTRIES.find(
|
||||
(c) => c.code === row.original.value
|
||||
);
|
||||
}
|
||||
return row.original.match === "COUNTRY" ? (
|
||||
return row.original.match === "COUNTRY" ||
|
||||
row.original.match === "COUNTRY_IS_NOT" ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -17,6 +17,7 @@ export const POLICY_RULE_MATCH_TYPES = [
|
||||
"IP",
|
||||
"PATH",
|
||||
"COUNTRY",
|
||||
"COUNTRY_IS_NOT",
|
||||
"ASN",
|
||||
"REGION"
|
||||
] as const;
|
||||
@@ -78,6 +79,7 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
|
||||
message: t("rulesErrorInvalidRegionDescription")
|
||||
});
|
||||
case "COUNTRY":
|
||||
case "COUNTRY_IS_NOT":
|
||||
return required.refine(
|
||||
(value) => COUNTRIES.some((country) => country.code === value),
|
||||
{ message: t("rulesErrorInvalidCountryDescription") }
|
||||
|
||||
@@ -39,7 +39,7 @@ export function ResourceSelector({
|
||||
const [debouncedSearchQuery] = useDebounce(resourceSearchQuery, 150);
|
||||
|
||||
const { data: resources = [] } = useQuery(
|
||||
orgQueries.resources({
|
||||
orgQueries.proxyResources({
|
||||
orgId: orgId,
|
||||
query: debouncedSearchQuery,
|
||||
perPage: 10
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { LoaderIcon, SearchIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,8 +11,21 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from "@/components/ui/sheet";
|
||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
const desktop = "(min-width: 768px)";
|
||||
|
||||
const commandSurfaceClassName =
|
||||
"transition duration-150 mt-0 [&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
@@ -35,23 +48,57 @@ function CommandDialog({
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
commandProps,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
commandProps?: React.ComponentProps<typeof Command>;
|
||||
}) {
|
||||
const isDesktop = useMediaQuery(desktop);
|
||||
|
||||
const command = (
|
||||
<Command {...commandProps} className={commandSurfaceClassName}>
|
||||
{children}
|
||||
</Command>
|
||||
);
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<SheetContent
|
||||
side="top"
|
||||
className={cn(
|
||||
"flex max-h-[85dvh] w-full flex-col gap-0 overflow-hidden rounded-none border-x-0 p-0 pt-[env(safe-area-inset-top,0px)]",
|
||||
className
|
||||
)}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>{description}</SheetDescription>
|
||||
</SheetHeader>
|
||||
{command}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className={cn("overflow-hidden p-0", className)}>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"overflow-hidden p-0 place-items-start md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100dvh-clamp(1.5rem,12vh,200px)-1.5rem)] md:translate-y-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{command}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -59,22 +106,32 @@ function CommandDialog({
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
isLoading,
|
||||
trailing,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input> & {
|
||||
isLoading?: boolean;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
{isLoading ? (
|
||||
<LoaderIcon className="animate-spin size-4 shrink-0 opacity-50" />
|
||||
) : (
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
)}
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"placeholder:text-muted-foreground flex h-10 min-w-0 flex-1 rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{trailing ? <div className="shrink-0">{trailing}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,10 +215,11 @@ function CommandShortcut({
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
<kbd
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
"px-1 py-0.5 rounded-sm border-border bg-muted border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SiteResourceData } from "@app/lib/privateResourceForm";
|
||||
import { createContext } from "react";
|
||||
|
||||
export type SiteResourceAccessState = {
|
||||
roleIds: number[];
|
||||
userIds: string[];
|
||||
clientIds: number[];
|
||||
};
|
||||
|
||||
export type SiteResourceContextValue = {
|
||||
siteResource: SiteResourceData;
|
||||
updateSiteResource: (updated: Partial<SiteResourceData>) => void;
|
||||
access: SiteResourceAccessState;
|
||||
setAccess: (access: SiteResourceAccessState) => void;
|
||||
};
|
||||
|
||||
const SiteResourceContext = createContext<SiteResourceContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export default SiteResourceContext;
|
||||
@@ -0,0 +1,12 @@
|
||||
import SiteResourceContext from "@app/contexts/siteResourceContext";
|
||||
import { useContext } from "react";
|
||||
|
||||
export function useSiteResourceContext() {
|
||||
const context = useContext(SiteResourceContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useSiteResourceContext must be used within SiteResourceProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SiteResourceData } from "@app/lib/privateResourceForm";
|
||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
|
||||
export async function fetchSiteResourceByNiceId(
|
||||
orgId: string,
|
||||
niceId: string
|
||||
): Promise<SiteResourceData | null> {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||
>(
|
||||
`/org/${orgId}/site-resources?query=${encodeURIComponent(niceId)}&pageSize=50`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
|
||||
const match = res.data.data.siteResources.find((r) => r.niceId === niceId);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: match.siteResourceId,
|
||||
name: match.name,
|
||||
orgId,
|
||||
sites: match.siteIds.map((siteId, idx) => ({
|
||||
siteId,
|
||||
siteName: match.siteNames[idx],
|
||||
siteNiceId: match.siteNiceIds[idx],
|
||||
online: match.siteOnlines[idx]
|
||||
})),
|
||||
mode: match.mode,
|
||||
scheme: match.scheme,
|
||||
ssl: match.ssl,
|
||||
siteNames: match.siteNames,
|
||||
siteAddresses: match.siteAddresses || null,
|
||||
siteIds: match.siteIds,
|
||||
destination: match.destination,
|
||||
destinationPort: match.destinationPort ?? null,
|
||||
alias: match.alias || null,
|
||||
aliasAddress: match.aliasAddress || null,
|
||||
siteNiceIds: match.siteNiceIds,
|
||||
niceId: match.niceId,
|
||||
tcpPortRangeString: match.tcpPortRangeString || null,
|
||||
udpPortRangeString: match.udpPortRangeString || null,
|
||||
disableIcmp: match.disableIcmp || false,
|
||||
authDaemonMode: match.authDaemonMode ?? null,
|
||||
authDaemonPort: match.authDaemonPort ?? null,
|
||||
pamMode: match.pamMode ?? null,
|
||||
subdomain: match.subdomain ?? null,
|
||||
domainId: match.domainId ?? null,
|
||||
fullDomain: match.fullDomain ?? null,
|
||||
labels: match.labels ?? [],
|
||||
enabled: match.enabled
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
SidebarNavItem,
|
||||
SidebarNavSection
|
||||
} from "@app/components/SidebarNav";
|
||||
|
||||
export type FlatNavItem = {
|
||||
title: string;
|
||||
href: string;
|
||||
icon?: ReactNode;
|
||||
sectionHeading: string;
|
||||
};
|
||||
|
||||
function flattenItems(
|
||||
items: SidebarNavItem[],
|
||||
sectionHeading: string,
|
||||
result: FlatNavItem[]
|
||||
) {
|
||||
for (const item of items) {
|
||||
if (item.href) {
|
||||
result.push({
|
||||
title: item.title,
|
||||
href: item.href,
|
||||
icon: item.icon,
|
||||
sectionHeading
|
||||
});
|
||||
}
|
||||
if (item.items?.length) {
|
||||
flattenItems(item.items, sectionHeading, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenNavSections(
|
||||
sections: SidebarNavSection[]
|
||||
): FlatNavItem[] {
|
||||
const result: FlatNavItem[] = [];
|
||||
for (const section of sections) {
|
||||
flattenItems(section.items, section.heading, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export type NavHrefParams = {
|
||||
orgId?: string;
|
||||
niceId?: string;
|
||||
resourceId?: string;
|
||||
userId?: string;
|
||||
apiKeyId?: string;
|
||||
clientId?: string;
|
||||
};
|
||||
|
||||
export function hydrateNavHref(
|
||||
val: string | undefined,
|
||||
params: NavHrefParams
|
||||
): string | undefined {
|
||||
if (!val) return undefined;
|
||||
return val
|
||||
.replace("{orgId}", params.orgId ?? "")
|
||||
.replace("{niceId}", params.niceId ?? "")
|
||||
.replace("{resourceId}", params.resourceId ?? "")
|
||||
.replace("{userId}", params.userId ?? "")
|
||||
.replace("{apiKeyId}", params.apiKeyId ?? "")
|
||||
.replace("{clientId}", params.clientId ?? "");
|
||||
}
|
||||
|
||||
export function navHrefParamsFromRoute(
|
||||
params: Record<string, string | string[] | undefined>
|
||||
): NavHrefParams {
|
||||
return {
|
||||
orgId: params.orgId as string | undefined,
|
||||
niceId: params.niceId as string | undefined,
|
||||
resourceId: params.resourceId as string | undefined,
|
||||
userId: params.userId as string | undefined,
|
||||
apiKeyId: params.apiKeyId as string | undefined,
|
||||
clientId: params.clientId as string | undefined
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
|
||||
function groupOpenKey(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label"
|
||||
groupBy: "site" | "label" | "none"
|
||||
) {
|
||||
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
|
||||
export function readLauncherGroupOpenState(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label"
|
||||
groupBy: "site" | "label" | "none"
|
||||
): Record<string, boolean> {
|
||||
return readJson<Record<string, boolean>>(
|
||||
groupOpenKey(orgId, viewId, groupBy),
|
||||
@@ -75,7 +75,7 @@ export function readLauncherGroupOpenState(
|
||||
export function readLauncherGroupOpen(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label",
|
||||
groupBy: "site" | "label" | "none",
|
||||
groupKey: string,
|
||||
defaultOpen: boolean
|
||||
): boolean {
|
||||
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
|
||||
export function writeLauncherGroupOpen(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label",
|
||||
groupBy: "site" | "label" | "none",
|
||||
groupKey: string,
|
||||
isOpen: boolean
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||
|
||||
export function getPrivateResourceSettingsHref(
|
||||
orgId: string,
|
||||
niceId: string
|
||||
): string {
|
||||
return `/${orgId}/settings/resources/private/${niceId}/general`;
|
||||
}
|
||||
|
||||
export function getLauncherResourceAdminHref(
|
||||
orgId: string,
|
||||
resource: LauncherResource
|
||||
@@ -8,10 +15,5 @@ export function getLauncherResourceAdminHref(
|
||||
return `/${orgId}/settings/resources/public/${resource.niceId}/general`;
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams({ query: resource.niceId });
|
||||
if (resource.site?.siteId != null) {
|
||||
qs.set("siteId", String(resource.site.siteId));
|
||||
}
|
||||
|
||||
return `/${orgId}/settings/resources/private?${qs.toString()}`;
|
||||
return getPrivateResourceSettingsHref(orgId, resource.niceId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||
|
||||
export type PublicAuthState = "protected" | "not_protected" | "none";
|
||||
|
||||
const BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
|
||||
|
||||
export function derivePublicAuthState(
|
||||
mode: string | null,
|
||||
authInfo: Pick<
|
||||
GetResourceAuthInfoResponse,
|
||||
"password" | "pincode" | "sso" | "whitelist" | "headerAuth"
|
||||
>
|
||||
): PublicAuthState {
|
||||
if (!BROWSER_MODES.includes(mode || "")) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (
|
||||
authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ||
|
||||
authInfo.headerAuth
|
||||
) {
|
||||
return "protected";
|
||||
}
|
||||
|
||||
return "not_protected";
|
||||
}
|
||||
|
||||
export function formatPublicResourceType(
|
||||
resource: Pick<GetResourceResponse, "mode" | "ssl">
|
||||
): string {
|
||||
if (resource.mode === "http") {
|
||||
return resource.ssl ? "HTTPS" : "HTTP";
|
||||
}
|
||||
|
||||
const mode = (resource.mode || "").toLowerCase();
|
||||
if (mode === "tcp") {
|
||||
return "TCP";
|
||||
}
|
||||
if (mode === "udp") {
|
||||
return "UDP";
|
||||
}
|
||||
|
||||
return (resource.mode || "—").toUpperCase();
|
||||
}
|
||||
|
||||
export type PortProtocolState = "all" | "blocked" | "custom";
|
||||
|
||||
function getPortProtocolState(
|
||||
value: string | null | undefined
|
||||
): PortProtocolState {
|
||||
if (value === "*") {
|
||||
return "all";
|
||||
}
|
||||
|
||||
if (!value || value.trim() === "") {
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export type PortRestrictionDisplay = {
|
||||
hasNonDefaultPorts: boolean;
|
||||
tcp: { state: PortProtocolState; ports: string | null };
|
||||
udp: { state: PortProtocolState; ports: string | null };
|
||||
};
|
||||
|
||||
export function formatPortRestrictionDisplay(
|
||||
resource: Pick<
|
||||
GetSiteResourceResponse,
|
||||
"tcpPortRangeString" | "udpPortRangeString"
|
||||
>
|
||||
): PortRestrictionDisplay {
|
||||
const tcpState = getPortProtocolState(resource.tcpPortRangeString);
|
||||
const udpState = getPortProtocolState(resource.udpPortRangeString);
|
||||
|
||||
return {
|
||||
hasNonDefaultPorts: tcpState !== "all" || udpState !== "all",
|
||||
tcp: {
|
||||
state: tcpState,
|
||||
ports: tcpState === "custom" ? resource.tcpPortRangeString : null
|
||||
},
|
||||
udp: {
|
||||
state: udpState,
|
||||
ports: udpState === "custom" ? resource.udpPortRangeString : null
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
LauncherScaleInfo,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
|
||||
export function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
|
||||
return (
|
||||
config.query.trim().length > 0 ||
|
||||
config.siteIds.length > 0 ||
|
||||
config.labelIds.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function getEffectiveGroupBy(
|
||||
scale: LauncherScaleInfo,
|
||||
config: LauncherViewConfig
|
||||
): LauncherViewConfig["groupBy"] {
|
||||
if (scale.mode !== "compact") {
|
||||
return config.groupBy;
|
||||
}
|
||||
|
||||
if (
|
||||
config.groupBy === "site" &&
|
||||
config.siteIds.length > 0 &&
|
||||
scale.capabilities.allowSiteGrouping
|
||||
) {
|
||||
return "site";
|
||||
}
|
||||
|
||||
if (
|
||||
config.groupBy === "label" &&
|
||||
config.labelIds.length > 0 &&
|
||||
scale.capabilities.allowLabelGrouping
|
||||
) {
|
||||
return "label";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function getEffectiveLauncherConfig(
|
||||
scale: LauncherScaleInfo,
|
||||
config: LauncherViewConfig
|
||||
): LauncherViewConfig {
|
||||
const groupBy = getEffectiveGroupBy(scale, config);
|
||||
if (groupBy === config.groupBy) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return { ...config, groupBy };
|
||||
}
|
||||
|
||||
export function shouldFetchLauncherGroups(
|
||||
scale: LauncherScaleInfo,
|
||||
config: LauncherViewConfig
|
||||
): boolean {
|
||||
const groupBy = getEffectiveGroupBy(scale, config);
|
||||
|
||||
if (groupBy === "none") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scale.mode === "full") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(scale.capabilities.allowSiteGrouping &&
|
||||
groupBy === "site" &&
|
||||
config.siteIds.length > 0) ||
|
||||
(scale.capabilities.allowLabelGrouping &&
|
||||
groupBy === "label" &&
|
||||
config.labelIds.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowLauncherGroupList(
|
||||
scale: LauncherScaleInfo,
|
||||
config: LauncherViewConfig
|
||||
): boolean {
|
||||
return shouldFetchLauncherGroups(scale, config);
|
||||
}
|
||||
|
||||
export function shouldShowSearchFirstGate(
|
||||
scale: LauncherScaleInfo,
|
||||
config: LauncherViewConfig
|
||||
): boolean {
|
||||
return (
|
||||
scale.mode === "compact" &&
|
||||
scale.capabilities.requireSearchOrFilter &&
|
||||
!hasActiveLauncherFilters(config)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowFlatResourceList(
|
||||
scale: LauncherScaleInfo,
|
||||
config: LauncherViewConfig
|
||||
): boolean {
|
||||
if (shouldShowSearchFirstGate(scale, config)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const groupBy = getEffectiveGroupBy(scale, config);
|
||||
|
||||
if (groupBy === "none") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (scale.mode === "full") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !shouldShowLauncherGroupList(scale, config);
|
||||
}
|
||||
@@ -1,21 +1,27 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import { shouldFetchLauncherGroups } from "@app/lib/launcherScale";
|
||||
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
|
||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||
import type {
|
||||
LauncherGroup,
|
||||
LauncherScaleInfo,
|
||||
LauncherDefaultViewOverrides,
|
||||
LauncherViewConfig,
|
||||
LauncherViewRecord,
|
||||
ListLauncherGroupsResponse,
|
||||
ListLauncherScaleResponse,
|
||||
ListLauncherViewsResponse
|
||||
} from "@server/routers/launcher/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export type LauncherPageData = {
|
||||
views: LauncherViewRecord[];
|
||||
defaultViewOverrides: LauncherDefaultViewOverrides;
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
savedConfig: LauncherViewConfig;
|
||||
scale: LauncherScaleInfo;
|
||||
groups: LauncherGroup[];
|
||||
groupsPagination: {
|
||||
total: number;
|
||||
@@ -32,19 +38,56 @@ export async function fetchLauncherPageData(
|
||||
>
|
||||
): Promise<LauncherPageData> {
|
||||
let views: LauncherViewRecord[] = [];
|
||||
let defaultViewOverrides: LauncherDefaultViewOverrides = {
|
||||
personal: null,
|
||||
orgWide: null
|
||||
};
|
||||
try {
|
||||
const viewsRes = await internal.get<
|
||||
AxiosResponse<ListLauncherViewsResponse>
|
||||
>(`/org/${orgId}/launcher/views`, cookieHeader);
|
||||
views = viewsRes.data.data.views;
|
||||
defaultViewOverrides = viewsRes.data.data.defaultViewOverrides;
|
||||
} catch (e) {}
|
||||
|
||||
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
|
||||
searchParams,
|
||||
views,
|
||||
null
|
||||
null,
|
||||
defaultViewOverrides
|
||||
);
|
||||
|
||||
const scaleFilters = {
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order
|
||||
};
|
||||
|
||||
let scale: LauncherScaleInfo = {
|
||||
mode: "full",
|
||||
resourceCount: 0,
|
||||
siteGroupCount: 0,
|
||||
labelGroupCount: 0,
|
||||
capabilities: {
|
||||
allowSiteGrouping: true,
|
||||
allowLabelGrouping: true,
|
||||
requireSearchOrFilter: false
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const sp = buildLauncherSearchParams(scaleFilters, 1);
|
||||
sp.delete("page");
|
||||
sp.delete("pageSize");
|
||||
const scaleRes = await internal.get<
|
||||
AxiosResponse<ListLauncherScaleResponse>
|
||||
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader);
|
||||
scale = scaleRes.data.data.scale;
|
||||
} catch (e) {}
|
||||
|
||||
const groupFilters = {
|
||||
query: config.query,
|
||||
groupBy: config.groupBy,
|
||||
@@ -62,20 +105,24 @@ export async function fetchLauncherPageData(
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const sp = buildLauncherSearchParams(groupFilters, 1);
|
||||
const groupsRes = await internal.get<
|
||||
AxiosResponse<ListLauncherGroupsResponse>
|
||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
|
||||
groups = groupsRes.data.data.groups;
|
||||
groupsPagination = groupsRes.data.data.pagination;
|
||||
} catch (e) {}
|
||||
if (shouldFetchLauncherGroups(scale, config)) {
|
||||
try {
|
||||
const sp = buildLauncherSearchParams(groupFilters, 1);
|
||||
const groupsRes = await internal.get<
|
||||
AxiosResponse<ListLauncherGroupsResponse>
|
||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
|
||||
groups = groupsRes.data.data.groups;
|
||||
groupsPagination = groupsRes.data.data.pagination;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return {
|
||||
views,
|
||||
defaultViewOverrides,
|
||||
activeViewId,
|
||||
config,
|
||||
savedConfig,
|
||||
scale,
|
||||
groups,
|
||||
groupsPagination
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import {
|
||||
defaultLauncherViewConfig,
|
||||
getEffectiveDefaultLauncherConfig,
|
||||
parseIdListParam,
|
||||
type LauncherDefaultViewOverrides,
|
||||
type LauncherViewConfig,
|
||||
type LauncherViewRecord
|
||||
} from "@server/routers/launcher/types";
|
||||
@@ -64,10 +66,13 @@ export function isLauncherConfigEqual(
|
||||
|
||||
export function getLauncherUrlBaseConfig(
|
||||
viewId: LauncherActiveViewId,
|
||||
views: LauncherViewRecord[]
|
||||
views: LauncherViewRecord[],
|
||||
defaultViewOverrides?: LauncherDefaultViewOverrides
|
||||
): LauncherViewConfig {
|
||||
if (viewId === "default") {
|
||||
return defaultLauncherViewConfig;
|
||||
return defaultViewOverrides
|
||||
? getEffectiveDefaultLauncherConfig(defaultViewOverrides)
|
||||
: defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
const savedView = views.find((view) => view.viewId === viewId);
|
||||
@@ -113,7 +118,7 @@ function parseConfigOverrides(
|
||||
}
|
||||
|
||||
const groupBy = searchParams.get("groupBy");
|
||||
if (groupBy === "site" || groupBy === "label") {
|
||||
if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
|
||||
overrides.groupBy = groupBy;
|
||||
}
|
||||
|
||||
@@ -172,7 +177,8 @@ function isValidActiveViewId(
|
||||
export function resolveLauncherStateFromUrl(
|
||||
searchParams: URLSearchParams,
|
||||
views: LauncherViewRecord[],
|
||||
fallbackViewId: LauncherActiveViewId | null
|
||||
fallbackViewId: LauncherActiveViewId | null,
|
||||
defaultViewOverrides?: LauncherDefaultViewOverrides
|
||||
): ResolvedLauncherState {
|
||||
const parsed = parseLauncherUrlState(searchParams);
|
||||
|
||||
@@ -188,18 +194,26 @@ export function resolveLauncherStateFromUrl(
|
||||
: "default";
|
||||
}
|
||||
|
||||
const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
|
||||
const savedConfig = getLauncherUrlBaseConfig(
|
||||
activeViewId,
|
||||
views,
|
||||
defaultViewOverrides
|
||||
);
|
||||
|
||||
let config: LauncherViewConfig;
|
||||
if (hasLauncherConfigParams(searchParams)) {
|
||||
config = resolveLauncherConfig(
|
||||
defaultLauncherViewConfig,
|
||||
getEffectiveDefaultLauncherConfig(
|
||||
defaultViewOverrides ?? { personal: null, orgWide: null }
|
||||
),
|
||||
parsed.configOverrides
|
||||
);
|
||||
} else if (activeViewId !== "default") {
|
||||
config = savedConfig;
|
||||
} else {
|
||||
config = defaultLauncherViewConfig;
|
||||
config = getEffectiveDefaultLauncherConfig(
|
||||
defaultViewOverrides ?? { personal: null, orgWide: null }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
import { z } from "zod";
|
||||
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
||||
|
||||
export type PrivateResourceMode = "host" | "cidr" | "http" | "ssh";
|
||||
|
||||
export type SiteResourceData = InternalResourceRow & {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PortMode = "all" | "blocked" | "custom";
|
||||
|
||||
const tagSchema = z.object({ id: z.string(), text: z.string() });
|
||||
|
||||
export type PrivateResourceAccessTag = z.infer<typeof tagSchema>;
|
||||
|
||||
export type PrivateResourceClient = {
|
||||
clientId: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PrivateResourceFormValues = {
|
||||
name: string;
|
||||
siteIds: number[];
|
||||
mode: PrivateResourceMode;
|
||||
destination: string | null;
|
||||
alias?: string | null;
|
||||
niceId?: string;
|
||||
enabled?: boolean;
|
||||
tcpPortRangeString?: string | null;
|
||||
udpPortRangeString?: string | null;
|
||||
disableIcmp?: boolean;
|
||||
authDaemonMode?: "site" | "remote" | "native" | null;
|
||||
authDaemonPort?: number | null;
|
||||
pamMode?: "passthrough" | "push" | null;
|
||||
destinationPort?: number | null;
|
||||
scheme?: "http" | "https";
|
||||
ssl?: boolean;
|
||||
httpConfigSubdomain?: string | null;
|
||||
httpConfigDomainId?: string | null;
|
||||
httpConfigFullDomain?: string | null;
|
||||
roles?: PrivateResourceAccessTag[];
|
||||
users?: PrivateResourceAccessTag[];
|
||||
clients?: PrivateResourceClient[];
|
||||
};
|
||||
|
||||
export type SiteResourceAccess = {
|
||||
roleIds: number[];
|
||||
userIds: string[];
|
||||
clientIds: number[];
|
||||
};
|
||||
|
||||
type TranslateFn = (key: string) => string;
|
||||
|
||||
export const isValidPortRangeString = (
|
||||
val: string | undefined | null
|
||||
): boolean => {
|
||||
if (!val || val.trim() === "" || val.trim() === "*") return true;
|
||||
const parts = val.split(",").map((p) => p.trim());
|
||||
for (const part of parts) {
|
||||
if (part === "") return false;
|
||||
if (part.includes("-")) {
|
||||
const [start, end] = part.split("-").map((p) => p.trim());
|
||||
if (!start || !end) return false;
|
||||
const startPort = parseInt(start, 10);
|
||||
const endPort = parseInt(end, 10);
|
||||
if (isNaN(startPort) || isNaN(endPort)) return false;
|
||||
if (
|
||||
startPort < 1 ||
|
||||
startPort > 65535 ||
|
||||
endPort < 1 ||
|
||||
endPort > 65535
|
||||
)
|
||||
return false;
|
||||
if (startPort > endPort) return false;
|
||||
} else {
|
||||
const port = parseInt(part, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const getPortRangeValidationMessage = (t: TranslateFn) =>
|
||||
t("editInternalResourceDialogPortRangeValidationError");
|
||||
|
||||
export const createPortRangeStringSchema = (t: TranslateFn) =>
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine((val) => isValidPortRangeString(val), {
|
||||
message: getPortRangeValidationMessage(t)
|
||||
});
|
||||
|
||||
export const getPortModeFromString = (
|
||||
val: string | undefined | null
|
||||
): PortMode => {
|
||||
if (val === "*") return "all";
|
||||
if (!val || val.trim() === "") return "blocked";
|
||||
return "custom";
|
||||
};
|
||||
|
||||
export const getPortStringFromMode = (
|
||||
mode: PortMode,
|
||||
customValue: string
|
||||
): string | undefined => {
|
||||
if (mode === "all") return "*";
|
||||
if (mode === "blocked") return "";
|
||||
return customValue;
|
||||
};
|
||||
|
||||
export const isHostname = (destination: string | null): boolean =>
|
||||
!!destination && /[a-zA-Z]/.test(destination);
|
||||
|
||||
export const cleanForFQDN = (name: string): string =>
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.-]/g, "-")
|
||||
.replace(/[-]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.replace(/^\.|\.$/g, "");
|
||||
|
||||
export function applyAliasAutoGeneration(
|
||||
values: PrivateResourceFormValues
|
||||
): PrivateResourceFormValues {
|
||||
const data = { ...values };
|
||||
if (
|
||||
(data.mode === "host" ||
|
||||
data.mode === "http" ||
|
||||
(data.mode === "ssh" && data.authDaemonMode !== "native")) &&
|
||||
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.alias = aliasValue;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function accessTagsToIds(access: {
|
||||
roles?: PrivateResourceAccessTag[];
|
||||
users?: PrivateResourceAccessTag[];
|
||||
clients?: PrivateResourceClient[];
|
||||
}): SiteResourceAccess {
|
||||
return {
|
||||
roleIds: (access.roles ?? []).map((r) => parseInt(r.id)),
|
||||
userIds: (access.users ?? []).map((u) => u.id),
|
||||
clientIds: (access.clients ?? []).map((c) => c.clientId)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateSiteResourcePayload(
|
||||
values: PrivateResourceFormValues
|
||||
) {
|
||||
const data = applyAliasAutoGeneration(values);
|
||||
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
|
||||
|
||||
return {
|
||||
name: data.name,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
destination: isNativeSsh ? undefined : (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,
|
||||
...(!isNativeSsh && {
|
||||
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
|
||||
}),
|
||||
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) => c.clientId) : []
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpdateSiteResourcePayload(
|
||||
values: PrivateResourceFormValues,
|
||||
access: SiteResourceAccess
|
||||
) {
|
||||
const data = applyAliasAutoGeneration(values);
|
||||
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
|
||||
|
||||
return {
|
||||
name: data.name,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
niceId: data.niceId,
|
||||
enabled: data.enabled,
|
||||
...(isNativeSsh
|
||||
? { destination: null, destinationPort: null }
|
||||
: { 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,
|
||||
...(!isNativeSsh && {
|
||||
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
|
||||
}),
|
||||
roleIds: access.roleIds,
|
||||
userIds: access.userIds,
|
||||
clientIds: access.clientIds
|
||||
};
|
||||
}
|
||||
|
||||
export function inferSshPamMode(
|
||||
authDaemonMode?: string | null,
|
||||
pamMode?: "passthrough" | "push" | null
|
||||
): "passthrough" | "push" {
|
||||
if (pamMode === "passthrough" || pamMode === "push") {
|
||||
return pamMode;
|
||||
}
|
||||
|
||||
return authDaemonMode === "remote" ? "push" : "passthrough";
|
||||
}
|
||||
|
||||
export function siteResourceToFormValues(
|
||||
resource: SiteResourceData
|
||||
): PrivateResourceFormValues {
|
||||
return {
|
||||
name: resource.name,
|
||||
siteIds: resource.siteIds,
|
||||
mode: resource.mode ?? "host",
|
||||
destination: resource.destination ?? "",
|
||||
alias: resource.alias ?? null,
|
||||
destinationPort: resource.destinationPort ?? null,
|
||||
scheme: resource.scheme ?? "http",
|
||||
ssl: resource.ssl ?? false,
|
||||
httpConfigSubdomain: resource.subdomain ?? null,
|
||||
httpConfigDomainId: resource.domainId ?? null,
|
||||
httpConfigFullDomain: resource.fullDomain ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
authDaemonMode:
|
||||
resource.authDaemonMode === "native"
|
||||
? "native"
|
||||
: (resource.authDaemonMode ?? "site"),
|
||||
authDaemonPort: resource.authDaemonPort ?? null,
|
||||
pamMode: inferSshPamMode(resource.authDaemonMode, resource.pamMode),
|
||||
niceId: resource.niceId,
|
||||
enabled: resource.enabled
|
||||
};
|
||||
}
|
||||
|
||||
export function createGeneralFormSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("editInternalResourceDialogNameRequired"))
|
||||
.max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
niceId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9-]+$/)
|
||||
});
|
||||
}
|
||||
|
||||
export function createAccessFormSchema() {
|
||||
return z.object({
|
||||
roles: z.array(tagSchema).optional(),
|
||||
users: z.array(tagSchema).optional(),
|
||||
clients: z
|
||||
.array(
|
||||
z.object({
|
||||
clientId: z.number(),
|
||||
name: z.string()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
}
|
||||
|
||||
export function createCreateFormSchema(t: TranslateFn) {
|
||||
const destinationRequired = t(
|
||||
"createInternalResourceDialogDestinationRequired"
|
||||
);
|
||||
|
||||
return z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("createInternalResourceDialogNameRequired"))
|
||||
.max(255, t("createInternalResourceDialogNameMaxLength")),
|
||||
siteIds: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, t("createInternalResourceDialogPleaseSelectSite")),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||
destination: z.string().nullish(),
|
||||
alias: z.string().nullish(),
|
||||
destinationPort: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.optional()
|
||||
.nullable(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
httpConfigSubdomain: z.string().nullish(),
|
||||
httpConfigDomainId: z.string().nullish(),
|
||||
httpConfigFullDomain: z.string().nullish(),
|
||||
authDaemonMode: z
|
||||
.enum(["site", "remote", "native"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
standardDaemonLocation: z
|
||||
.enum(["site", "remote"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
authDaemonPort: z.number().int().positive().optional().nullable(),
|
||||
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
|
||||
tcpPortRangeString: createPortRangeStringSchema(t),
|
||||
udpPortRangeString: createPortRangeStringSchema(t),
|
||||
disableIcmp: z.boolean().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const isNativeSsh =
|
||||
data.mode === "ssh" && data.authDaemonMode === "native";
|
||||
const trimmedDestination = data.destination?.trim();
|
||||
if (
|
||||
data.mode !== "ssh" &&
|
||||
(!trimmedDestination || trimmedDestination.length < 1)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: destinationRequired,
|
||||
path: ["destination"]
|
||||
});
|
||||
}
|
||||
if (data.mode === "ssh" && !isNativeSsh) {
|
||||
if (!trimmedDestination || trimmedDestination.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: destinationRequired,
|
||||
path: ["destination"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.destinationPort == null ||
|
||||
!Number.isFinite(data.destinationPort) ||
|
||||
data.destinationPort < 1
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("internalResourceHttpPortRequired"),
|
||||
path: ["destinationPort"]
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.mode === "http") {
|
||||
if (!data.scheme) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("internalResourceDownstreamSchemeRequired"),
|
||||
path: ["scheme"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.destinationPort == null ||
|
||||
!Number.isFinite(data.destinationPort) ||
|
||||
data.destinationPort < 1
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("internalResourceHttpPortRequired"),
|
||||
path: ["destinationPort"]
|
||||
});
|
||||
}
|
||||
if (!data.httpConfigDomainId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("domainRequired"),
|
||||
path: ["httpConfigDomainId"]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function destinationRefine(
|
||||
data: {
|
||||
mode: PrivateResourceMode;
|
||||
destination?: string | null;
|
||||
authDaemonMode?: string | null;
|
||||
destinationPort?: number | null;
|
||||
scheme?: string;
|
||||
httpConfigDomainId?: string | null;
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
t: TranslateFn,
|
||||
destinationRequired?: string
|
||||
) {
|
||||
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
|
||||
const trimmedDestination = data.destination?.trim();
|
||||
if (
|
||||
!isNativeSsh &&
|
||||
(!trimmedDestination || trimmedDestination.length < 1)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: destinationRequired ?? "Destination is required",
|
||||
path: ["destination"]
|
||||
});
|
||||
}
|
||||
if (data.mode === "ssh" && !isNativeSsh) {
|
||||
if (
|
||||
data.destinationPort == null ||
|
||||
!Number.isFinite(data.destinationPort) ||
|
||||
data.destinationPort < 1
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("internalResourceHttpPortRequired"),
|
||||
path: ["destinationPort"]
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.mode === "http") {
|
||||
if (!data.scheme) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("internalResourceDownstreamSchemeRequired"),
|
||||
path: ["scheme"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.destinationPort == null ||
|
||||
!Number.isFinite(data.destinationPort) ||
|
||||
data.destinationPort < 1
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("internalResourceHttpPortRequired"),
|
||||
path: ["destinationPort"]
|
||||
});
|
||||
}
|
||||
if (!data.httpConfigDomainId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("domainRequired"),
|
||||
path: ["httpConfigDomainId"]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostFormSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
siteIds: z.array(z.number().int().positive()).min(1),
|
||||
mode: z.literal("host"),
|
||||
destination: z.string().nullish(),
|
||||
alias: z.string().nullish(),
|
||||
tcpPortRangeString: createPortRangeStringSchema(t),
|
||||
udpPortRangeString: createPortRangeStringSchema(t),
|
||||
disableIcmp: z.boolean().optional(),
|
||||
authDaemonMode: z
|
||||
.enum(["site", "remote", "native"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
authDaemonPort: z.number().int().positive().optional().nullable()
|
||||
})
|
||||
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
||||
}
|
||||
|
||||
export function createCidrFormSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
siteIds: z.array(z.number().int().positive()).min(1),
|
||||
mode: z.literal("cidr"),
|
||||
destination: z.string().nullish(),
|
||||
tcpPortRangeString: createPortRangeStringSchema(t),
|
||||
udpPortRangeString: createPortRangeStringSchema(t),
|
||||
disableIcmp: z.boolean().optional()
|
||||
})
|
||||
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
||||
}
|
||||
|
||||
export function createHttpFormSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
siteIds: z.array(z.number().int().positive()).min(1),
|
||||
mode: z.literal("http"),
|
||||
destination: z.string().nullish(),
|
||||
destinationPort: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.optional()
|
||||
.nullable(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
httpConfigSubdomain: z.string().nullish(),
|
||||
httpConfigDomainId: z.string().nullish(),
|
||||
httpConfigFullDomain: z.string().nullish()
|
||||
})
|
||||
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
||||
}
|
||||
|
||||
export function createSshFormSchema(
|
||||
t: TranslateFn,
|
||||
options?: { isNative?: boolean }
|
||||
) {
|
||||
const isNative = options?.isNative ?? false;
|
||||
|
||||
return z
|
||||
.object({
|
||||
siteIds: z.array(z.number().int().positive()).min(1),
|
||||
mode: z.literal("ssh"),
|
||||
destination: z.string().nullish(),
|
||||
alias: z.string().nullish(),
|
||||
destinationPort: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.optional()
|
||||
.nullable(),
|
||||
pamMode: z.enum(["passthrough", "push"]),
|
||||
standardDaemonLocation: z.enum(["site", "remote"]),
|
||||
authDaemonPort: z.string()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
destinationRefine(
|
||||
{
|
||||
...data,
|
||||
authDaemonMode: isNative
|
||||
? "native"
|
||||
: data.standardDaemonLocation
|
||||
},
|
||||
ctx,
|
||||
t
|
||||
);
|
||||
|
||||
const showDaemonPort =
|
||||
!isNative &&
|
||||
data.pamMode === "push" &&
|
||||
data.standardDaemonLocation === "remote";
|
||||
|
||||
if (showDaemonPort) {
|
||||
const port = Number(data.authDaemonPort);
|
||||
if (
|
||||
!data.authDaemonPort.trim() ||
|
||||
!Number.isInteger(port) ||
|
||||
port < 1 ||
|
||||
port > 65535
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("healthCheckPortInvalid"),
|
||||
path: ["authDaemonPort"]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeFormValuesWithResource(
|
||||
resource: SiteResourceData,
|
||||
partial: Partial<PrivateResourceFormValues>
|
||||
): PrivateResourceFormValues {
|
||||
return {
|
||||
...siteResourceToFormValues(resource),
|
||||
...partial
|
||||
};
|
||||
}
|
||||
+143
-7
@@ -7,7 +7,10 @@ import type {
|
||||
QueryConnectionAuditLogResponse,
|
||||
QueryRequestAuditLogResponse
|
||||
} from "@server/routers/auditLogs/types";
|
||||
import type { ListClientsResponse } from "@server/routers/client";
|
||||
import type {
|
||||
ListClientsResponse,
|
||||
ListUserDevicesResponse
|
||||
} from "@server/routers/client";
|
||||
import type {
|
||||
GetDNSRecordsResponse,
|
||||
ListDomainsResponse
|
||||
@@ -25,6 +28,7 @@ import type {
|
||||
import type { ListRolesResponse } from "@server/routers/role";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import type {
|
||||
ListAllSiteResourcesByOrgResponse,
|
||||
ListSiteResourceClientsResponse,
|
||||
ListSiteResourceRolesResponse,
|
||||
ListSiteResourceUsersResponse
|
||||
@@ -38,7 +42,7 @@ import {
|
||||
queryOptions
|
||||
} from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import z from "zod";
|
||||
import z, { meta } from "zod";
|
||||
import { remote } from "./api";
|
||||
import { durationToMs } from "./durationToMs";
|
||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||
@@ -50,11 +54,16 @@ import type {
|
||||
ListLauncherGroupsResponse,
|
||||
ListLauncherLabelsResponse,
|
||||
ListLauncherResourcesResponse,
|
||||
ListLauncherScaleResponse,
|
||||
ListLauncherSitesResponse,
|
||||
ListLauncherViewsResponse,
|
||||
LauncherListQuery,
|
||||
LauncherResource,
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||
|
||||
@@ -180,6 +189,38 @@ export const orgQueries = {
|
||||
return res.data.data.clients;
|
||||
}
|
||||
}),
|
||||
userDevices: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"ORG",
|
||||
orgId,
|
||||
"USER_DEVICES",
|
||||
{ query, perPage }
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: perPage.toString()
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
sp.set("query", query);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListUserDevicesResponse>
|
||||
>(`/org/${orgId}/user-devices?${sp.toString()}`, { signal });
|
||||
|
||||
return res.data.data.devices;
|
||||
}
|
||||
}),
|
||||
users: ({
|
||||
orgId,
|
||||
query,
|
||||
@@ -324,7 +365,7 @@ export const orgQueries = {
|
||||
}
|
||||
}),
|
||||
|
||||
resources: ({
|
||||
proxyResources: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
@@ -334,7 +375,12 @@ export const orgQueries = {
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "RESOURCES", { query, perPage }] as const,
|
||||
queryKey: [
|
||||
"ORG",
|
||||
orgId,
|
||||
"PROXY_RESOURCES",
|
||||
{ query, perPage }
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: perPage.toString()
|
||||
@@ -352,6 +398,39 @@ export const orgQueries = {
|
||||
}
|
||||
}),
|
||||
|
||||
privateResources: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"ORG",
|
||||
orgId,
|
||||
"PRIVATE_RESOURCES",
|
||||
{ query, perPage }
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = new URLSearchParams({
|
||||
pageSize: perPage.toString()
|
||||
});
|
||||
|
||||
if (query?.trim()) {
|
||||
sp.set("query", query);
|
||||
}
|
||||
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||
>(`/org/${orgId}/site-resources?${sp.toString()}`, { signal });
|
||||
|
||||
return res.data.data.siteResources;
|
||||
}
|
||||
}),
|
||||
|
||||
healthChecks: ({
|
||||
orgId,
|
||||
perPage = 10_000
|
||||
@@ -1189,13 +1268,13 @@ export const launcherQueries = {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherViewsResponse>
|
||||
>(`/org/${orgId}/launcher/views`, { signal });
|
||||
return res.data.data.views;
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
sites: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 500
|
||||
perPage = 20
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
@@ -1227,7 +1306,7 @@ export const launcherQueries = {
|
||||
labels: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 500
|
||||
perPage = 20
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
@@ -1298,5 +1377,62 @@ export const launcherQueries = {
|
||||
const nextPage = page + 1;
|
||||
return page * pageSize < total ? nextPage : undefined;
|
||||
}
|
||||
}),
|
||||
scale: (orgId: string, filters: LauncherQueryFilters) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "SCALE", filters] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const sp = buildLauncherSearchParams(filters, 1);
|
||||
sp.delete("page");
|
||||
sp.delete("pageSize");
|
||||
sp.delete("groupKey");
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherScaleResponse>
|
||||
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
|
||||
return res.data.data.scale;
|
||||
}
|
||||
}),
|
||||
resourceDetail: (orgId: string, resource: LauncherResource | null) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"ORG",
|
||||
orgId,
|
||||
"LAUNCHER",
|
||||
"RESOURCE_DETAIL",
|
||||
resource?.launcherResourceKey ?? null
|
||||
] as const,
|
||||
enabled: resource != null,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
if (!resource) {
|
||||
throw new Error("Resource is required");
|
||||
}
|
||||
|
||||
if (resource.resourceType === "public") {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<GetResourceResponse>
|
||||
>(`/org/${orgId}/resource/${resource.niceId}`, { signal });
|
||||
const resourceData = res.data.data;
|
||||
const authRes = await meta!.api.get<
|
||||
AxiosResponse<GetResourceAuthInfoResponse>
|
||||
>(`/resource/${resourceData.resourceGuid}/auth`, {
|
||||
signal
|
||||
});
|
||||
return {
|
||||
resourceType: "public" as const,
|
||||
data: resourceData,
|
||||
authInfo: authRes.data.data
|
||||
};
|
||||
}
|
||||
|
||||
const siteResourceId =
|
||||
resource.siteResourceId ?? resource.resourceId;
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<GetSiteResourceResponse>
|
||||
>(`/org/${orgId}/site-resource/${siteResourceId}`, { signal });
|
||||
return {
|
||||
resourceType: "site" as const,
|
||||
data: res.data.data
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import SiteResourceContext, {
|
||||
type SiteResourceAccessState
|
||||
} from "@app/contexts/siteResourceContext";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import {
|
||||
accessTagsToIds,
|
||||
type PrivateResourceAccessTag,
|
||||
type PrivateResourceClient,
|
||||
type SiteResourceData
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type SiteResourceProviderProps = {
|
||||
children: React.ReactNode;
|
||||
siteResource: SiteResourceData;
|
||||
};
|
||||
|
||||
export function SiteResourceProvider({
|
||||
children,
|
||||
siteResource: serverSiteResource
|
||||
}: SiteResourceProviderProps) {
|
||||
const t = useTranslations();
|
||||
const [siteResource, setSiteResource] =
|
||||
useState<SiteResourceData>(serverSiteResource);
|
||||
|
||||
useEffect(() => {
|
||||
setSiteResource(serverSiteResource);
|
||||
}, [serverSiteResource]);
|
||||
|
||||
const resourceRolesQuery = useQuery(
|
||||
resourceQueries.siteResourceRoles({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
);
|
||||
const resourceUsersQuery = useQuery(
|
||||
resourceQueries.siteResourceUsers({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
);
|
||||
const resourceClientsQuery = useQuery(
|
||||
resourceQueries.siteResourceClients({
|
||||
siteResourceId: siteResource.id
|
||||
})
|
||||
);
|
||||
|
||||
const access = useMemo<SiteResourceAccessState>(() => {
|
||||
const roles = (resourceRolesQuery.data ?? []) as {
|
||||
roleId: number;
|
||||
name: string;
|
||||
}[];
|
||||
const users = (resourceUsersQuery.data ?? []) as {
|
||||
userId: string;
|
||||
}[];
|
||||
const clients = (resourceClientsQuery.data ?? []) as {
|
||||
clientId: number;
|
||||
}[];
|
||||
|
||||
return {
|
||||
roleIds: roles
|
||||
.filter((r) => r.name !== "Admin")
|
||||
.map((r) => r.roleId),
|
||||
userIds: users.map((u) => u.userId),
|
||||
clientIds: clients.map((c) => c.clientId)
|
||||
};
|
||||
}, [
|
||||
resourceRolesQuery.data,
|
||||
resourceUsersQuery.data,
|
||||
resourceClientsQuery.data
|
||||
]);
|
||||
|
||||
const [accessOverride, setAccessOverride] =
|
||||
useState<SiteResourceAccessState | null>(null);
|
||||
|
||||
const resolvedAccess = accessOverride ?? access;
|
||||
|
||||
const updateSiteResource = (updated: Partial<SiteResourceData>) => {
|
||||
if (!siteResource) {
|
||||
throw new Error(t("resourceErrorNoUpdate"));
|
||||
}
|
||||
setSiteResource((prev) => ({ ...prev, ...updated }));
|
||||
};
|
||||
|
||||
return (
|
||||
<SiteResourceContext.Provider
|
||||
value={{
|
||||
siteResource,
|
||||
updateSiteResource,
|
||||
access: resolvedAccess,
|
||||
setAccess: setAccessOverride
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SiteResourceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAccessFormDefaults(orgId: string, siteResourceId: number) {
|
||||
const resourceRolesQuery = useQuery(
|
||||
resourceQueries.siteResourceRoles({ siteResourceId })
|
||||
);
|
||||
const resourceUsersQuery = useQuery(
|
||||
resourceQueries.siteResourceUsers({ siteResourceId })
|
||||
);
|
||||
const resourceClientsQuery = useQuery(
|
||||
resourceQueries.siteResourceClients({ siteResourceId })
|
||||
);
|
||||
const clientsQuery = useQuery(
|
||||
orgQueries.machineClients({
|
||||
orgId,
|
||||
perPage: 1
|
||||
})
|
||||
);
|
||||
|
||||
const loading =
|
||||
resourceRolesQuery.isLoading ||
|
||||
resourceUsersQuery.isLoading ||
|
||||
resourceClientsQuery.isLoading;
|
||||
|
||||
const roles: PrivateResourceAccessTag[] = (
|
||||
(resourceRolesQuery.data ?? []) as { roleId: number; name: string }[]
|
||||
)
|
||||
.map((i) => ({ id: i.roleId.toString(), text: i.name }))
|
||||
.filter((r) => r.text !== "Admin");
|
||||
|
||||
const users: PrivateResourceAccessTag[] = (
|
||||
(resourceUsersQuery.data ?? []) as {
|
||||
userId: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
type?: string;
|
||||
idpName?: string;
|
||||
}[]
|
||||
).map((i) => ({
|
||||
id: i.userId.toString(),
|
||||
text: `${getUserDisplayName({ email: i.email, username: i.username })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
||||
}));
|
||||
|
||||
const clients: PrivateResourceClient[] = [
|
||||
...((resourceClientsQuery.data ?? []) as {
|
||||
clientId: number;
|
||||
name: string;
|
||||
}[])
|
||||
];
|
||||
|
||||
const allClients = (clientsQuery.data ?? []).filter((c) => !c.userId);
|
||||
|
||||
return {
|
||||
loading,
|
||||
roles,
|
||||
users,
|
||||
clients,
|
||||
hasMachineClients: allClients.length > 0,
|
||||
accessIds: accessTagsToIds({ roles, users, clients })
|
||||
};
|
||||
}
|
||||
|
||||
export default SiteResourceProvider;
|
||||
Reference in New Issue
Block a user