Merge branch 'dev' into feat/ip-filtering

This commit is contained in:
Fred KISSIE
2026-08-21 23:24:09 +02:00
622 changed files with 64427 additions and 8040 deletions
+114
View File
@@ -0,0 +1,114 @@
import { Layout } from "@app/components/Layout";
import UserVirtualApiKeys from "@app/components/UserVirtualApiKeys";
import { commandBarNavSections } from "@app/app/navigation";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
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";
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { cache } from "react";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: t("myVirtualApiKeysTitle")
};
}
type KeysPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function KeysPage(props: KeysPageProps) {
const params = await props.params;
const orgId = params.orgId;
if (!orgId) {
redirect(`/`);
}
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect("/");
}
const cookieHeader = await authCookieHeader();
let overview: GetOrgOverviewResponse | undefined;
try {
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
`/org/${orgId}/overview`,
cookieHeader
);
overview = res.data.data;
} catch {
// leave undefined
}
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(async () =>
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
cookieHeader
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch {
// leave empty
}
if (!orgs.some((org) => org.orgId === orgId)) {
redirect("/");
}
let keysData: ListMyVirtualApiKeysResponse | null = null;
try {
const res = await internal.get<
AxiosResponse<ListMyVirtualApiKeysResponse>
>(`/org/${orgId}/my-virtual-api-keys`, cookieHeader);
keysData = res.data.data;
} catch {
redirect(`/${orgId}`);
}
if (!keysData) {
redirect(`/${orgId}`);
}
const env = pullEnv();
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
return (
<UserProvider user={user}>
<Layout
orgId={orgId}
orgs={orgs}
navItems={[]}
commandNavItems={commandBarNavSections(env, {
isPrimaryOrg: primaryOrg
})}
showSidebar={false}
launcherMode
showViewAsAdmin={isAdminOrOwner}
>
<UserVirtualApiKeys orgId={orgId} initialData={keysData} />
</Layout>
</UserProvider>
);
}
@@ -0,0 +1,125 @@
import { Layout } from "@app/components/Layout";
import UserVirtualApiKeys from "@app/components/UserVirtualApiKeys";
import { commandBarNavSections } from "@app/app/navigation";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
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";
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { cache } from "react";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: t("myVirtualApiKeysTitle")
};
}
type ResourceKeysPageProps = {
params: Promise<{ orgId: string; resourceGuid: string }>;
};
export const dynamic = "force-dynamic";
export default async function ResourceKeysPage(props: ResourceKeysPageProps) {
const params = await props.params;
const orgId = params.orgId;
const resourceGuid = params.resourceGuid;
if (!orgId || !resourceGuid) {
redirect(`/`);
}
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect(
`/auth/resource/${encodeURIComponent(resourceGuid)}?redirect=${encodeURIComponent(`/${orgId}/resource/${resourceGuid}/keys`)}`
);
}
const cookieHeader = await authCookieHeader();
let overview: GetOrgOverviewResponse | undefined;
try {
const res = await internal.get<AxiosResponse<GetOrgOverviewResponse>>(
`/org/${orgId}/overview`,
cookieHeader
);
overview = res.data.data;
} catch {
// leave undefined
}
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(async () =>
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
cookieHeader
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch {
// leave empty
}
if (!orgs.some((org) => org.orgId === orgId)) {
redirect("/");
}
let keysData: ListMyVirtualApiKeysResponse | null = null;
try {
const res = await internal.get<
AxiosResponse<ListMyVirtualApiKeysResponse>
>(
`/org/${orgId}/my-virtual-api-keys?resourceGuid=${encodeURIComponent(resourceGuid)}`,
cookieHeader
);
keysData = res.data.data;
} catch {
redirect(`/${orgId}/keys`);
}
if (!keysData) {
redirect(`/${orgId}/keys`);
}
const env = pullEnv();
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
return (
<UserProvider user={user}>
<Layout
orgId={orgId}
orgs={orgs}
navItems={[]}
commandNavItems={commandBarNavSections(env, {
isPrimaryOrg: primaryOrg
})}
showSidebar={false}
launcherMode
showViewAsAdmin={isAdminOrOwner}
>
<UserVirtualApiKeys
orgId={orgId}
initialData={keysData}
resourceNiceId={keysData.resourceNiceId ?? undefined}
endpoint={keysData.resourceAccessUrl ?? undefined}
/>
</Layout>
</UserProvider>
);
}
@@ -181,7 +181,7 @@ export default function NetworkingPage() {
<SettingsSectionDescription>
{t("remoteExitNodeNetworkingDescription")}
<a
href="https://docs.pangolin.net/placeholder"
href="https://docs.pangolin.net/manage/remote-node/backhaul"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
@@ -38,18 +38,6 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
const accessControlsFormSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean(),
roles: z.array(
z.object({
id: z.string(),
text: z.string(),
isAdmin: z.boolean().optional()
})
)
});
export default function AccessControlsPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { user: sessionUser } = useUserContext();
@@ -69,6 +57,20 @@ export default function AccessControlsPage() {
(build === "enterprise" && !isPaid) ||
(build === "oss" && !isPaid));
const accessControlsFormSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean(),
roles: z
.array(
z.object({
id: z.string(),
text: z.string(),
isAdmin: z.boolean().optional()
})
)
.min(1, { message: t("accessRoleSelectPlease") })
});
const form = useForm({
resolver: zodResolver(accessControlsFormSchema),
defaultValues: {
@@ -108,15 +110,6 @@ export default function AccessControlsPage() {
async function executeSave() {
const values = form.getValues();
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease")
});
return;
}
setIsSaving(true);
try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
@@ -170,15 +163,6 @@ export default function AccessControlsPage() {
const values = form.getValues();
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease")
});
return;
}
const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
const isRemovingOwnAdmin =
@@ -237,10 +237,13 @@ export default function Page() {
return;
}
const useOrgIdps =
build === "saas" || env.app.identityProviderMode === "org";
const res = await api
.get<
AxiosResponse<ListIdpsResponse>
>(build === "saas" ? `/org/${orgId}/idp` : "/idp")
>(useOrgIdps ? `/org/${orgId}/idp` : "/idp")
.catch((e) => {
console.error(e);
toast({
@@ -301,8 +304,7 @@ export default function Page() {
);
const [isSubmittingExternal, setIsSubmittingExternal] = useState(false);
const loading =
isSubmittingInternal || isSubmittingExternal;
const loading = isSubmittingInternal || isSubmittingExternal;
async function onSubmitInternal() {
const isValid = await internalForm.trigger();
@@ -0,0 +1,227 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
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 { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
createAiProviderFormSchema,
toAiProviderAuthPayload,
type AiProviderFormValues
} from "@app/lib/aiProviderFormSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import {
authTypeRequiresApiKey,
type AiProviderAuthType,
type AiProviderType
} from "@app/lib/aiProviderDefaults";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
export default function AiProviderAuthenticationPage() {
const { provider, updateProvider } = useAiProviderContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]);
const form = useForm<AiProviderFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: provider.name,
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl ?? "",
apiKey: provider.apiKey ?? "",
authType: (provider.authType as AiProviderAuthType) ?? "bearer",
routingMode: (provider.routingMode as "url" | "target") ?? "url",
skipTlsVerification: provider.skipTlsVerification,
enabled: provider.enabled,
capabilities: provider.capabilities ?? []
}
});
const authType = form.watch("authType");
const showApiKey = authTypeRequiresApiKey(
(authType as AiProviderAuthType | null) ?? "bearer"
);
async function onSubmit(values: AiProviderFormValues) {
setSaveLoading(true);
try {
const res = await api.post<
AxiosResponse<CreateOrEditAiProviderResponse>
>(
`/ai-provider/${provider.providerId}`,
toAiProviderAuthPayload({
...values,
type: provider.type as AiProviderType
})
);
const updated = res.data.data.provider;
updateProvider(updated);
form.reset({
name: updated.name,
type: updated.type as AiProviderType,
upstreamUrl: updated.upstreamUrl ?? "",
apiKey: updated.apiKey ?? "",
authType: (updated.authType as AiProviderAuthType) ?? "bearer",
routingMode: (updated.routingMode as "url" | "target") ?? "url",
skipTlsVerification: updated.skipTlsVerification,
enabled: updated.enabled,
capabilities: updated.capabilities ?? []
});
toast({
title: t("success"),
description: t("aiProviderUpdated")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorUpdate"),
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
});
} finally {
setSaveLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderAuthSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderAuthSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="ai-provider-auth-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderAuthType"
)}
</FormLabel>
<FormControl>
<AiProviderAuthTypeSelect
value={
field.value ??
"bearer"
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{showApiKey && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderApiKey"
)}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderApiKeyDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="ai-provider-auth-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,22 @@
"use client";
import { SettingsContainer } from "@app/components/Settings";
import { BudgetsEditor } from "@app/components/BudgetsEditor";
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useTranslations } from "next-intl";
export default function AiProviderBudgetPage() {
const { provider } = useAiProviderContext();
const t = useTranslations();
return (
<SettingsContainer>
<BudgetsEditor
orgId={provider.orgId}
scope={{ type: "provider", id: provider.providerId }}
title={t("aiProviderBudgetSettings")}
description={t("aiProviderBudgetSettingsDescription")}
/>
</SettingsContainer>
);
}
@@ -0,0 +1,12 @@
import { redirect } from "next/navigation";
type Props = {
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function AiProviderConfigurationRedirect({
params
}: Props) {
const { orgId, niceId } = await params;
redirect(`/${orgId}/settings/ai-providers/${niceId}/network`);
}
@@ -0,0 +1,278 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect";
import { SwitchInput } from "@app/components/SwitchInput";
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 { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export default function AiProviderGeneralPage() {
const { provider, updateProvider } = useAiProviderContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const generalSchema = useMemo(
() =>
z
.object({
name: z
.string()
.trim()
.min(1, { message: t("nameRequired") }),
niceId: z.string().min(1).max(255).optional(),
enabled: z.boolean(),
capabilities: z.array(z.enum(AI_CAPABILITIES)).optional()
})
.superRefine((data, ctx) => {
if (!data.capabilities || data.capabilities.length === 0) {
ctx.addIssue({
code: "custom",
message: t("aiProviderErrorCapabilitiesRequired"),
path: ["capabilities"]
});
}
}),
[t]
);
type GeneralFormValues = z.infer<typeof generalSchema>;
const form = useForm<GeneralFormValues>({
resolver: zodResolver(generalSchema),
defaultValues: {
name: provider.name,
niceId: provider.niceId,
enabled: provider.enabled,
capabilities: provider.capabilities ?? []
}
});
async function onSubmit(values: GeneralFormValues) {
setSaveLoading(true);
try {
const body: {
name: string;
niceId?: string;
enabled: boolean;
capabilities?: AiCapability[];
} = {
name: values.name.trim(),
niceId: values.niceId,
enabled: values.enabled,
capabilities: values.capabilities ?? []
};
const res = await api.post<
AxiosResponse<CreateOrEditAiProviderResponse>
>(`/ai-provider/${provider.providerId}`, body);
const updated = res.data.data.provider;
updateProvider(updated);
form.reset({
name: updated.name,
niceId: updated.niceId,
enabled: updated.enabled,
capabilities: updated.capabilities ?? []
});
toast({
title: t("success"),
description: t("aiProviderUpdated")
});
if (values.niceId && values.niceId !== provider.niceId) {
router.replace(
`/${provider.orgId}/settings/ai-providers/${values.niceId}/general`
);
}
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorUpdate"),
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
});
} finally {
setSaveLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="ai-provider-general-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="edit-enabled"
label={t(
"aiProviderEnabled"
)}
description={t(
"aiProviderEnabledDescription"
)}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="capabilities"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderCapabilities"
)}
</FormLabel>
<FormControl>
<AiProviderCapabilitiesSelect
value={
field.value ??
[]
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderCapabilitiesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="ai-provider-general-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,102 @@
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider";
import AiProviderProvider from "@app/providers/AiProviderProvider";
import type { GetOrgResponse } from "@server/routers/org";
import type { GetAiProviderResponse } from "@server/routers/aiProvider/types";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { cache } from "react";
export const metadata: Metadata = {
title: "AI Provider"
};
export const dynamic = "force-dynamic";
type Props = {
children: React.ReactNode;
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function AiProviderLayout({ children, params }: Props) {
const { orgId, niceId } = await params;
const t = await getTranslations();
let provider = null;
try {
const res = await internal.get<AxiosResponse<GetAiProviderResponse>>(
`/org/${orgId}/ai-provider/${niceId}`,
await authCookieHeader()
);
provider = res.data.data.provider;
} catch {
redirect(`/${orgId}/settings/ai-providers`);
}
if (!provider || provider.orgId !== orgId) {
redirect(`/${orgId}/settings/ai-providers`);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${orgId}/settings/ai-providers`);
}
if (!org) {
redirect(`/${orgId}/settings/ai-providers`);
}
const navItems = [
{
title: t("general"),
href: "/{orgId}/settings/ai-providers/{niceId}/general"
},
{
title: t("aiProviderNetworkSettings"),
href: "/{orgId}/settings/ai-providers/{niceId}/network"
},
{
title: t("aiProviderModels"),
href: "/{orgId}/settings/ai-providers/{niceId}/models"
},
{
title: t("aiProviderAuthSettings"),
href: "/{orgId}/settings/ai-providers/{niceId}/authentication"
},
{
title: t("aiProviderBudgetSettings"),
href: "/{orgId}/settings/ai-providers/{niceId}/budget"
}
];
return (
<>
<SettingsSectionTitle
title={t("aiProviderSetting", {
providerName: provider.name
})}
description={t("aiProviderSettingDescription")}
/>
<OrgProvider org={org}>
<AiProviderProvider provider={provider}>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</AiProviderProvider>
</OrgProvider>
</>
);
}
@@ -0,0 +1,260 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
persistPendingModelBudgets,
type AiProviderModelListItem,
type ModelListType
} from "@app/components/AiProviderModelListEditor";
import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists";
import { Button } from "@app/components/ui/button";
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { aiProviderQueries } from "@app/lib/queries";
import type { CreateOrEditAiModelResponse } from "@server/routers/aiProvider/types";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
function toListItem(
model: {
modelId: number;
modelKey: string;
listType?: ModelListType | null;
},
listType: ModelListType
): AiProviderModelListItem {
return {
clientId: String(model.modelId),
modelId: model.modelId,
modelKey: model.modelKey,
listType,
hasBudget: false
};
}
export default function AiProviderModelsPage() {
const { provider } = useAiProviderContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const [allowItems, setAllowItems] = useState<AiProviderModelListItem[]>([]);
const [blockItems, setBlockItems] = useState<AiProviderModelListItem[]>([]);
const modelsQuery = useQuery(
aiProviderQueries.providerModels({ providerId: provider.providerId })
);
const catalogQuery = useQuery(
aiProviderQueries.catalogModels({ providerId: provider.providerId })
);
const catalogModels = useMemo(
() => (catalogQuery.data ?? []).map((entry) => entry.model),
[catalogQuery.data]
);
useEffect(() => {
if (!modelsQuery.data) return;
setAllowItems(
modelsQuery.data
.filter((model) => (model.listType ?? "allow") === "allow")
.map((model) => toListItem(model, "allow"))
);
setBlockItems(
modelsQuery.data
.filter((model) => model.listType === "block")
.map((model) => toListItem(model, "block"))
);
}, [modelsQuery.data]);
async function onSave() {
setSaveLoading(true);
try {
const existing = modelsQuery.data ?? [];
const existingById = new Map(
existing.map((model) => [model.modelId, model])
);
const existingByKey = new Map(
existing.map((model) => [model.modelKey, model])
);
const desiredItems = [...allowItems, ...blockItems].map((item) => ({
...item,
modelKey: item.modelKey.trim()
}));
const nextAllow = new Set(
allowItems.map((item) => item.modelKey.trim()).filter(Boolean)
);
const nextBlock = new Set(
blockItems.map((item) => item.modelKey.trim()).filter(Boolean)
);
const overlap = [...nextAllow].filter((key) => nextBlock.has(key));
if (overlap.length > 0) {
toast({
variant: "destructive",
title: t("aiProviderModelsErrorUpdate"),
description: t("aiProviderModelsOverlapError", {
keys: overlap.join(", ")
})
});
return;
}
const toCreate: AiProviderModelListItem[] = [];
const toUpdate: {
modelId: number;
modelKey: string;
listType: ModelListType;
}[] = [];
const retainedIds = new Set<number>();
for (const item of desiredItems) {
const listType = item.listType;
const modelKey = item.modelKey;
if (!modelKey) continue;
if (item.modelId != null && existingById.has(item.modelId)) {
retainedIds.add(item.modelId);
const existingModel = existingById.get(item.modelId)!;
if (
existingModel.modelKey !== modelKey ||
(existingModel.listType ?? "allow") !== listType
) {
toUpdate.push({
modelId: item.modelId,
modelKey,
listType
});
}
continue;
}
const existingBySameKey = existingByKey.get(modelKey);
if (
existingBySameKey &&
!retainedIds.has(existingBySameKey.modelId)
) {
retainedIds.add(existingBySameKey.modelId);
if ((existingBySameKey.listType ?? "allow") !== listType) {
toUpdate.push({
modelId: existingBySameKey.modelId,
modelKey,
listType
});
}
continue;
}
toCreate.push(item);
}
const toDelete = existing
.filter((model) => !retainedIds.has(model.modelId))
.map((model) => model.modelId);
await Promise.all([
...toCreate.map(async (item) => {
const res = await api.put<
AxiosResponse<CreateOrEditAiModelResponse>
>(`/ai-provider/${provider.providerId}/model`, {
modelKey: item.modelKey,
name: item.modelKey,
listType: item.listType
});
await persistPendingModelBudgets({
api,
orgId: provider.orgId,
modelId: res.data.data.model.modelId,
pendingBudgets: item.pendingBudgets
});
}),
...toUpdate.map(({ modelId, modelKey, listType }) =>
api.post(`/ai-model/${modelId}`, {
modelKey,
name: modelKey,
listType
})
),
...toDelete.map((modelId) => api.delete(`/ai-model/${modelId}`))
]);
await queryClient.invalidateQueries(
aiProviderQueries.providerModels({
providerId: provider.providerId
})
);
toast({
title: t("success"),
description: t("aiProviderModelsUpdated")
});
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderModelsErrorUpdate"),
description: formatAxiosError(
e,
t("aiProviderModelsErrorUpdate")
)
});
} finally {
setSaveLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderModels")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderModelsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<AiProviderModelsLists
orgId={provider.orgId}
allowItems={allowItems}
onAllowChange={setAllowItems}
blockItems={blockItems}
onBlockChange={setBlockItems}
catalogModels={catalogModels}
disabled={modelsQuery.isLoading}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="button"
loading={saveLoading}
disabled={saveLoading || modelsQuery.isLoading}
onClick={onSave}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,375 @@
"use client";
import {
ProxyResourceTargetsForm,
type ProxyResourceTargetsFormHandle
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { HeadersInput } from "@app/components/HeadersInput";
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 { useAiProviderContext } from "@app/hooks/useAiProviderContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
createAiProviderFormSchema,
showsUpstreamUrlField,
toAiProviderNetworkPayload,
type AiProviderFormValues
} from "@app/lib/aiProviderFormSchema";
import { aiProviderQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import type {
AiProviderAuthType,
AiProviderType
} from "@app/lib/aiProviderDefaults";
import type { CreateOrEditAiProviderResponse } from "@server/routers/aiProvider/types";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
export default function AiProviderNetworkPage() {
const { provider, updateProvider } = useAiProviderContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const params = useParams();
const orgId = params.orgId as string;
const router = useRouter();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const [headersValid, setHeadersValid] = useState(true);
const targetsFormRef = useRef<ProxyResourceTargetsFormHandle>(null);
const formSchema = useMemo(() => createAiProviderFormSchema(t), [t]);
const form = useForm<AiProviderFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: provider.name,
type: provider.type as AiProviderType,
upstreamUrl: provider.upstreamUrl ?? "",
apiKey: "",
authType: (provider.authType as AiProviderAuthType) ?? "bearer",
routingMode: (provider.routingMode as "url" | "target") ?? "url",
headers: provider.headers ?? [],
skipTlsVerification: provider.skipTlsVerification,
enabled: provider.enabled,
capabilities: provider.capabilities ?? []
}
});
const providerType = form.watch("type");
const routingMode = form.watch("routingMode");
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
const showRoutingMode = providerType === "custom";
const isTargetModeSelected = routingMode === "target";
const isTargetModeSaved =
provider.type === "custom" && provider.routingMode === "target";
const showTargetsForm = showRoutingMode && isTargetModeSelected;
const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery({
...aiProviderQueries.providerTargets({
providerId: provider.providerId
}),
enabled: isTargetModeSaved
});
async function onSubmit(values: AiProviderFormValues) {
setSaveLoading(true);
try {
const res = await api.post<
AxiosResponse<CreateOrEditAiProviderResponse>
>(
`/ai-provider/${provider.providerId}`,
toAiProviderNetworkPayload({
...values,
type: provider.type as AiProviderType
})
);
const updated = res.data.data.provider;
updateProvider(updated);
form.reset({
name: updated.name,
type: updated.type as AiProviderType,
upstreamUrl: updated.upstreamUrl ?? "",
apiKey: "",
authType: (updated.authType as AiProviderAuthType) ?? "bearer",
routingMode: (updated.routingMode as "url" | "target") ?? "url",
headers: updated.headers ?? [],
skipTlsVerification: updated.skipTlsVerification,
enabled: updated.enabled,
capabilities: updated.capabilities ?? []
});
if (values.routingMode === "target" && targetsFormRef.current) {
const targetsSaved = await targetsFormRef.current.save({
silent: true
});
if (!targetsSaved) {
return;
}
}
toast({
title: t("success"),
description: t("aiProviderUpdated")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorUpdate"),
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
});
} finally {
setSaveLoading(false);
}
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderNetworkSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderNetworkSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="ai-provider-network-form"
>
<SettingsFormGrid>
{showRoutingMode && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="routingMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderRoutingMode"
)}
</FormLabel>
<FormControl>
<StrategySelect
cols={2}
options={[
{
id: "url",
title: t(
"aiProviderRoutingModeUrl"
),
description:
t(
"aiProviderRoutingModeUrlDescription"
)
},
{
id: "target",
title: t(
"aiProviderRoutingModeTarget"
),
description:
t(
"aiProviderRoutingModeTargetDescription"
)
}
]}
value={
field.value ??
"url"
}
onChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<FormField
control={form.control}
name="skipTlsVerification"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="edit-skip-tls"
label={t(
"aiProviderSkipTlsVerification"
)}
description={t(
"aiProviderSkipTlsVerificationDescription"
)}
checked={
field.value ??
false
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{showUpstream && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="upstreamUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderUpstreamUrl"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderUpstreamUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<FormField
control={form.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={
field.onChange
}
onValidityChange={
setHeadersValid
}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderCustomHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
{showTargetsForm &&
(!isTargetModeSaved || !isLoadingTargets) && (
<div className="mt-6 space-y-4">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("targets")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("targetsDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
<ProxyResourceTargetsForm
ref={targetsFormRef}
orgId={orgId}
isHttp
isAiProvider
providerId={provider.providerId}
initialTargets={
isTargetModeSaved ? remoteTargets : []
}
allowedMethods={["http", "https"]}
emptyMessage={t("aiProviderTargetNoOne")}
embedded
hideSaveButton
disableAdvancedMode
/>
</div>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading || !headersValid}
form="ai-provider-network-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
type Props = {
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function AiProviderPage({ params }: Props) {
const { orgId, niceId } = await params;
redirect(`/${orgId}/settings/ai-providers/${niceId}/general`);
}
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Create AI Provider"
};
export default function CreateAiProviderLayout({
children
}: {
children: React.ReactNode;
}) {
return children;
}
@@ -0,0 +1,838 @@
"use client";
import {
ProxyResourceTargetsForm,
type LocalTarget
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect";
import {
persistPendingModelBudgets,
type AiProviderModelListItem
} from "@app/components/AiProviderModelListEditor";
import { AiProviderModelsLists } from "@app/components/AiProviderModelsLists";
import {
AiProviderTypeSelect,
aiProviderTypeLabelMap
} from "@app/components/AiProviderTypeSelect";
import { HeadersInput } from "@app/components/HeadersInput";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
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 { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
createAiProviderCreateFormSchema,
defaultAuthTypeForProvider,
defaultCapabilitiesForProvider,
emptyUpstreamForType,
showsUpstreamUrlField,
toAiProviderCreatePayload,
type AiProviderFormValues
} from "@app/lib/aiProviderFormSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import { authTypeRequiresApiKey } from "@app/lib/aiProviderDefaults";
import { aiProviderQueries } from "@app/lib/queries";
import type {
CreateOrEditAiModelResponse,
CreateOrEditAiProviderResponse
} from "@server/routers/aiProvider/types";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
export default function CreateAiProviderPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const params = useParams();
const orgId = params.orgId as string;
const router = useRouter();
const t = useTranslations();
const [loading, setLoading] = useState(false);
const [headersValid, setHeadersValid] = useState(true);
const [allowItems, setAllowItems] = useState<AiProviderModelListItem[]>([]);
const [blockItems, setBlockItems] = useState<AiProviderModelListItem[]>([]);
const targetsRef = useRef<LocalTarget[]>([]);
const formSchema = useMemo(() => createAiProviderCreateFormSchema(t), [t]);
const form = useForm<AiProviderFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: t(aiProviderTypeLabelMap.openai),
type: "openai",
upstreamUrl: emptyUpstreamForType("openai"),
apiKey: "",
authType: defaultAuthTypeForProvider("openai"),
routingMode: "url",
capabilities: defaultCapabilitiesForProvider("openai"),
headers: [],
skipTlsVerification: false,
enabled: true
}
});
const providerType = form.watch("type");
const routingMode = form.watch("routingMode");
const authType = form.watch("authType");
const showUpstream = showsUpstreamUrlField(providerType, routingMode);
const showRoutingMode = providerType === "custom";
const showTargets = providerType === "custom" && routingMode === "target";
const showApiKey = authTypeRequiresApiKey(authType ?? "bearer");
const catalogQuery = useQuery(
aiProviderQueries.catalogModelsByType({
orgId,
type: providerType
})
);
const catalogModels = useMemo(
() => (catalogQuery.data ?? []).map((entry) => entry.model),
[catalogQuery.data]
);
async function createTargets(
providerId: number,
localTargets: LocalTarget[]
) {
for (const target of localTargets) {
const data = {
ip: target.ip,
port: target.port,
method: target.method,
enabled: target.enabled,
siteId: target.siteId,
hcEnabled: target.hcEnabled,
hcPath: target.hcPath || null,
hcMethod: target.hcMethod || null,
hcInterval: target.hcInterval || null,
hcTimeout: target.hcTimeout || null,
hcHeaders: target.hcHeaders || null,
hcScheme: target.hcScheme || null,
hcHostname: target.hcHostname || null,
hcPort: target.hcPort || null,
hcFollowRedirects: target.hcFollowRedirects || null,
hcStatus: target.hcStatus || null,
hcUnhealthyInterval: target.hcUnhealthyInterval || null,
hcMode: target.hcMode || null,
hcTlsServerName: target.hcTlsServerName,
hcHealthyThreshold: target.hcHealthyThreshold || null,
hcUnhealthyThreshold: target.hcUnhealthyThreshold || null,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority
};
await api.put(`/ai-provider/${providerId}/target`, data);
}
}
async function createModels(
providerId: number,
items: AiProviderModelListItem[]
) {
for (const item of items) {
const res = await api.put<
AxiosResponse<CreateOrEditAiModelResponse>
>(`/ai-provider/${providerId}/model`, {
modelKey: item.modelKey,
name: item.modelKey,
listType: item.listType
});
await persistPendingModelBudgets({
api,
orgId,
modelId: res.data.data.model.modelId,
pendingBudgets: item.pendingBudgets
});
}
}
async function onSubmit(values: AiProviderFormValues) {
const targets = targetsRef.current;
if (showTargets) {
const invalidTargets = targets.filter(
(target) =>
!target.ip ||
target.ip.trim() === "" ||
!target.port ||
target.port <= 0 ||
isNaN(target.port)
);
if (invalidTargets.length > 0) {
toast({
variant: "destructive",
title: t("targetErrorInvalidIp"),
description: t("targetErrorInvalidIpDescription")
});
return;
}
}
const nextAllow = new Set(
allowItems.map((item) => item.modelKey.trim()).filter(Boolean)
);
const nextBlock = new Set(
blockItems.map((item) => item.modelKey.trim()).filter(Boolean)
);
const overlap = [...nextAllow].filter((key) => nextBlock.has(key));
if (overlap.length > 0) {
toast({
variant: "destructive",
title: t("aiProviderErrorCreate"),
description: t("aiProviderModelsOverlapError", {
keys: overlap.join(", ")
})
});
return;
}
const modelItems = [...allowItems, ...blockItems]
.map((item) => ({
...item,
modelKey: item.modelKey.trim()
}))
.filter((item) => item.modelKey);
setLoading(true);
try {
const res = await api.put<
AxiosResponse<CreateOrEditAiProviderResponse>
>(`/org/${orgId}/ai-provider`, toAiProviderCreatePayload(values));
const providerId = res.data.data.provider.providerId;
const niceId = res.data.data.provider.niceId;
if (showTargets && targets.length > 0) {
try {
await createTargets(providerId, targets);
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorCreate"),
description: formatAxiosError(
e,
t("aiProviderErrorCreate")
)
});
router.push(
`/${orgId}/settings/ai-providers/${niceId}/network`
);
return;
}
}
if (modelItems.length > 0) {
try {
await createModels(providerId, modelItems);
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorCreate"),
description: formatAxiosError(
e,
t("aiProviderErrorCreate")
)
});
router.push(
`/${orgId}/settings/ai-providers/${niceId}/models`
);
return;
}
}
toast({
title: t("success"),
description: t("aiProviderCreated")
});
router.push(`/${orgId}/settings/ai-providers/${niceId}`);
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorCreate"),
description: formatAxiosError(e, t("aiProviderErrorCreate"))
});
} finally {
setLoading(false);
}
}
return (
<>
<div className="flex justify-between">
<HeaderTitle
title={t("aiProviderCreate")}
description={t("aiProviderCreateDescription")}
/>
<Button
variant="outline"
onClick={() =>
router.push(`/${orgId}/settings/ai-providers`)
}
>
{t("aiProviderSeeAll")}
</Button>
</div>
<Form {...form}>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiProviderType")}
</FormLabel>
<FormControl>
<AiProviderTypeSelect
value={field.value}
onChange={(
value
) => {
const previousType =
field.value;
field.onChange(
value
);
setAllowItems(
[]
);
setBlockItems(
[]
);
form.setValue(
"upstreamUrl",
emptyUpstreamForType(
value
)
);
form.setValue(
"authType",
defaultAuthTypeForProvider(
value
)
);
form.setValue(
"capabilities",
defaultCapabilitiesForProvider(
value
)
);
const currentName =
form.getValues(
"name"
);
if (
value !==
"custom"
) {
const previousLabel =
t(
aiProviderTypeLabelMap[
previousType
]
);
if (
!currentName.trim() ||
currentName ===
previousLabel
) {
form.setValue(
"name",
t(
aiProviderTypeLabelMap[
value
]
)
);
}
form.setValue(
"routingMode",
"url"
);
targetsRef.current =
[];
} else {
const isDefaultName =
Object.entries(
aiProviderTypeLabelMap
).some(
([
type,
key
]) =>
type !==
"custom" &&
currentName ===
t(
key
)
);
if (
isDefaultName
) {
form.setValue(
"name",
""
);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="capabilities"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderCapabilities"
)}
</FormLabel>
<FormControl>
<AiProviderCapabilitiesSelect
value={
field.value ??
[]
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderCapabilitiesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderNetworkSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderNetworkSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
{showRoutingMode && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="routingMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderRoutingMode"
)}
</FormLabel>
<FormControl>
<StrategySelect
cols={2}
options={[
{
id: "url",
title: t(
"aiProviderRoutingModeUrl"
),
description:
t(
"aiProviderRoutingModeUrlDescription"
)
},
{
id: "target",
title: t(
"aiProviderRoutingModeTarget"
),
description:
t(
"aiProviderRoutingModeTargetDescription"
)
}
]}
value={
field.value ??
"url"
}
onChange={(
value
) => {
field.onChange(
value
);
if (
value !==
"target"
) {
targetsRef.current =
[];
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<FormField
control={form.control}
name="skipTlsVerification"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="skip-tls"
label={t(
"aiProviderSkipTlsVerification"
)}
description={t(
"aiProviderSkipTlsVerificationDescription"
)}
checked={
field.value ??
false
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{showUpstream && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="upstreamUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderUpstreamUrl"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderUpstreamUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<FormField
control={form.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={
field.onChange
}
onValidityChange={
setHeadersValid
}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderCustomHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
{showTargets && (
<div className="mt-6 space-y-4">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("targets")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("targetsDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
<ProxyResourceTargetsForm
orgId={orgId}
isHttp
isAiProvider
onChange={(nextTargets) => {
targetsRef.current = nextTargets;
}}
allowedMethods={["http", "https"]}
emptyMessage={t(
"aiProviderTargetNoOne"
)}
embedded
hideSaveButton
disableAdvancedMode
/>
</div>
)}
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderAuthSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderAuthSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderAuthType"
)}
</FormLabel>
<FormControl>
<AiProviderAuthTypeSelect
value={
field.value ??
"bearer"
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderAuthTypeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{showApiKey && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderApiKey"
)}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="new-password"
value={
field.value ??
""
}
onChange={
field.onChange
}
/>
</FormControl>
<FormDescription>
{t(
"aiProviderApiKeyDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiProviderModels")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiProviderCreateModelsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<AiProviderModelsLists
orgId={orgId}
allowItems={allowItems}
onAllowChange={setAllowItems}
blockItems={blockItems}
onBlockChange={setBlockItems}
catalogModels={catalogModels}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() =>
router.push(`/${orgId}/settings/ai-providers`)
}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={loading}
disabled={loading || !headersValid}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("create")}
</Button>
</div>
</Form>
</>
);
}
@@ -0,0 +1,75 @@
import AiProvidersBanner from "@app/components/AiProvidersBanner";
import AiProvidersTable from "@app/components/AiProvidersTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { ListAiProvidersResponse } from "@server/routers/aiProvider/types";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "AI Providers"
};
export const dynamic = "force-dynamic";
type Props = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export default async function AiProvidersPage({ params, searchParams }: Props) {
const { orgId } = await params;
const searchParamsObj = new URLSearchParams(await searchParams);
const t = await getTranslations();
let providers: ListAiProvidersResponse["providers"] = [];
let pagination: ListAiProvidersResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<ListAiProvidersResponse>>(
`/org/${orgId}/ai-providers?${searchParamsObj.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
providers = responseData.providers;
pagination = responseData.pagination;
} catch {
// empty list on error
}
return (
<>
<SettingsSectionTitle
title={t("aiProvidersTitle")}
description={t("aiProvidersDescription")}
/>
<AiProvidersBanner />
<AiProvidersTable
orgId={orgId}
providers={providers.map((provider) => ({
providerId: provider.providerId,
niceId: provider.niceId,
name: provider.name,
type: provider.type,
routingMode: provider.routingMode,
enabled: provider.enabled,
effectiveUpstreamUrl: provider.effectiveUpstreamUrl,
apiKeyLastChars: provider.apiKeyLastChars
}))}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}
@@ -104,7 +104,9 @@ export default async function ClientsPage(props: ClientsPageProps) {
archived: Boolean(client.archived),
blocked: Boolean(client.blocked),
approvalState: client.approvalState,
fingerprint
fingerprint,
firstSeen: client.firstSeen ?? null,
lastSeen: client.lastSeen ?? null
};
};
@@ -80,7 +80,8 @@ const SecurityFormSchema = z.object({
settingsLogRetentionDaysRequest: z.number(),
settingsLogRetentionDaysAccess: z.number(),
settingsLogRetentionDaysAction: z.number(),
settingsLogRetentionDaysConnection: z.number()
settingsLogRetentionDaysConnection: z.number(),
settingsLogRetentionDaysAISessions: z.number()
});
const LOG_RETENTION_OPTIONS = [
@@ -122,7 +123,8 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
settingsLogRetentionDaysRequest: true,
settingsLogRetentionDaysAccess: true,
settingsLogRetentionDaysAction: true,
settingsLogRetentionDaysConnection: true
settingsLogRetentionDaysConnection: true,
settingsLogRetentionDaysAISessions: true
})
),
defaultValues: {
@@ -133,7 +135,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
settingsLogRetentionDaysAction:
org.settingsLogRetentionDaysAction ?? 15,
settingsLogRetentionDaysConnection:
org.settingsLogRetentionDaysConnection ?? 15
org.settingsLogRetentionDaysConnection ?? 15,
settingsLogRetentionDaysAISessions:
org.settingsLogRetentionDaysAISessions ?? 15
},
mode: "onChange"
});
@@ -161,7 +165,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
settingsLogRetentionDaysAction:
data.settingsLogRetentionDaysAction,
settingsLogRetentionDaysConnection:
data.settingsLogRetentionDaysConnection
data.settingsLogRetentionDaysConnection,
settingsLogRetentionDaysAISessions:
data.settingsLogRetentionDaysAISessions
} as any;
// Update organization
@@ -673,6 +679,131 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
);
}}
/>
<FormField
control={form.control}
name="settingsLogRetentionDaysAISessions"
render={({ field }) => {
const isDisabled = !isPaidUser(
tierMatrix.aiSessionLogs
);
return (
<FormItem>
<FormLabel>
{t(
"logRetentionAISessionsLabel"
)}
</FormLabel>
<FormControl>
<Select
value={field.value.toString()}
onValueChange={(
value
) => {
if (
!isDisabled
) {
field.onChange(
parseInt(
value,
10
)
);
}
}}
disabled={
isDisabled
}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectLogRetention"
)}
/>
</SelectTrigger>
<SelectContent>
{LOG_RETENTION_OPTIONS.filter(
(
option
) => {
if (
build !=
"saas"
) {
return true;
}
let maxDays: number;
if (
!subscriptionTier
) {
// No tier
maxDays = 3;
} else if (
subscriptionTier ==
"enterprise"
) {
// Enterprise - no limit
return true;
} else if (
subscriptionTier ==
"tier3"
) {
maxDays = 90;
} else if (
subscriptionTier ==
"tier2"
) {
maxDays = 30;
} else if (
subscriptionTier ==
"tier1"
) {
maxDays = 7;
} else {
// Default to most restrictive
maxDays = 3;
}
// Filter out options that exceed the max
// Special values: -1 (forever) and 9001 (end of year) should be filtered
if (
option.value <
0 ||
option.value >
maxDays
) {
return false;
}
return true;
}
).map(
(
option
) => (
<SelectItem
key={
option.value
}
value={option.value.toString()}
>
{t(
option.label
)}
</SelectItem>
)
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
</form>
+2 -1
View File
@@ -80,7 +80,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
orgId={params.orgId}
orgs={orgs}
navItems={orgNavSections(env, {
isPrimaryOrg: primaryOrg
isPrimaryOrg: primaryOrg,
isServerAdmin: user.serverAdmin
})}
commandNavItems={commandBarNavSections(env, {
isPrimaryOrg: primaryOrg
+61 -10
View File
@@ -20,6 +20,8 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
import axios from "axios";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import LogRetentionWarning from "@app/components/LogRetentionWarning";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { logQueries } from "@app/lib/queries";
@@ -33,6 +35,7 @@ export default function GeneralPage() {
const t = useTranslations();
const { orgId } = useParams();
const { org } = useOrgContext();
const { isPaidUser } = usePaidStatus();
const [isExporting, startTransition] = useTransition();
@@ -152,6 +155,23 @@ export default function GeneralPage() {
setCurrentPage(newPage);
};
const handleRefresh = () => {
// When the end date has no explicit time, it represents an
// open-ended "up to now" upper bound. Since dateRange is only
// recomputed on user interaction, that upper bound otherwise stays
// frozen at whenever the page first loaded, so refreshing would
// never surface logs created since then. Bump it to the current
// time so the query key changes and refetches the latest window.
if (dateRange.endDate?.date && !dateRange.endDate.time) {
setDateRange((prev) => ({
...prev,
endDate: { date: new Date() }
}));
} else {
refetch();
}
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
@@ -308,7 +328,14 @@ export default function GeneralPage() {
}
/>
</span>
)
),
cell: ({ row }) => {
return row.original.ip ? (
row.original.ip
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
{
accessorKey: "location",
@@ -371,6 +398,14 @@ export default function GeneralPage() {
);
},
cell: ({ row }) => {
if (
!row.original.resourceNiceId ||
!row.original.resourceName
) {
return (
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<Link
href={
@@ -420,14 +455,19 @@ export default function GeneralPage() {
);
},
cell: ({ row }) => {
const typeLabel =
row.original.type === "ssh" ||
row.original.type === "rdp" ||
row.original.type === "vnc"
const typeLabel = row.original.type
? row.original.type === "ssh" ||
row.original.type === "rdp" ||
row.original.type === "vnc"
? row.original.type.toUpperCase()
: row.original.type.charAt(0).toUpperCase() +
row.original.type.slice(1);
return <span>{typeLabel || "-"}</span>;
row.original.type.slice(1)
: null;
return typeLabel ? (
<span>{typeLabel}</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
{
@@ -464,7 +504,9 @@ export default function GeneralPage() {
{row.original.actor}
</>
) : (
<>-</>
<span className="text-xs text-muted-foreground">
-
</span>
)}
</span>
);
@@ -475,7 +517,9 @@ export default function GeneralPage() {
header: () => <span className="px-2">{t("actorId")}</span>,
cell: ({ row }) => (
<span className="flex items-center gap-1">
{row.original.actorId || "-"}
{row.original.actorId || (
<span className="text-xs text-muted-foreground">-</span>
)}
</span>
)
}
@@ -519,11 +563,18 @@ export default function GeneralPage() {
<PaidFeaturesAlert tiers={tierMatrix.accessLogs} />
{org.org.settingsLogRetentionDaysAccess === 0 && (
<LogRetentionWarning
orgId={orgId as string}
logTypeLabel={t("accessLogs")}
/>
)}
<LogDataTable
columns={columns}
data={rows}
title={t("accessLogs")}
onRefresh={() => refetch()}
onRefresh={handleRefresh}
isRefreshing={isFetching}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
+38 -3
View File
@@ -3,8 +3,10 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import LogRetentionWarning from "@app/components/LogRetentionWarning";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
@@ -29,6 +31,7 @@ export default function GeneralPage() {
const { orgId } = useParams();
const searchParams = useSearchParams();
const { org } = useOrgContext();
const { isPaidUser } = usePaidStatus();
const [isExporting, startTransition] = useTransition();
@@ -135,6 +138,23 @@ export default function GeneralPage() {
setCurrentPage(newPage);
};
const handleRefresh = () => {
// When the end date has no explicit time, it represents an
// open-ended "up to now" upper bound. Since dateRange is only
// recomputed on user interaction, that upper bound otherwise stays
// frozen at whenever the page first loaded, so refreshing would
// never surface logs created since then. Bump it to the current
// time so the query key changes and refetches the latest window.
if (dateRange.endDate?.date && !dateRange.endDate.time) {
setDateRange((prev) => ({
...prev,
endDate: { date: new Date() }
}));
} else {
refetch();
}
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
@@ -286,7 +306,11 @@ export default function GeneralPage() {
) : (
<Key className="h-4 w-4" />
)}
{row.original.actor}
{row.original.actor || (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</span>
);
}
@@ -297,7 +321,11 @@ export default function GeneralPage() {
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
{row.original.actorId}
{row.original.actorId || (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</span>
);
}
@@ -334,13 +362,20 @@ export default function GeneralPage() {
<PaidFeaturesAlert tiers={tierMatrix.actionLogs} />
{org.org.settingsLogRetentionDaysAction === 0 && (
<LogRetentionWarning
orgId={orgId as string}
logTypeLabel={t("actionLogs")}
/>
)}
<LogDataTable
columns={columns}
data={rows}
title={t("actionLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="action"
onRefresh={() => refetch()}
onRefresh={handleRefresh}
isRefreshing={isFetching}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
@@ -0,0 +1,35 @@
import { AiUsageAnalyticsData } from "@app/components/AiUsageAnalyticsData";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: t("aiUsageAnalyticsTitle")
};
}
export interface AiUsageAnalyticsPageProps {
params: Promise<{ orgId: string }>;
}
export default async function AiUsageAnalyticsPage(
props: AiUsageAnalyticsPageProps
) {
const orgId = (await props.params).orgId;
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("aiUsageAnalyticsTitle")}
description={t("aiUsageAnalyticsDescription")}
/>
<div className="container mx-auto max-w-12xl">
<AiUsageAnalyticsData orgId={orgId} />
</div>
</>
);
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "AI Session Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}
+774
View File
@@ -0,0 +1,774 @@
"use client";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { AiSessionChatView } from "@app/components/AiSessionChatView";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import LogRetentionWarning from "@app/components/LogRetentionWarning";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
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 { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { ArrowUpRight, Bot, Waves, User } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import type { QueryAiSessionLogResponse } from "@server/routers/auditLogs/types";
const capabilityLabels: Record<string, string> = {
openai_chat: "OpenAI Chat Completions",
openai_responses: "OpenAI Responses",
anthropic_messages: "Anthropic Messages",
v1_models: "Models List",
gemini_generate_content: "Gemini",
google_generate_content: "Vertex AI (Generate Content)",
google_raw_predict: "Vertex AI (Raw Predict)",
bedrock_model_invoke: "Bedrock (Invoke Model)",
bedrock_converse: "Bedrock (Converse)"
};
export default function AiSessionLogsPage() {
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { orgId } = useParams();
const searchParams = useSearchParams();
const { org } = useOrgContext();
const { isPaidUser } = usePaidStatus();
const [isExporting, startTransition] = useTransition();
const [currentPage, setCurrentPage] = useState<number>(0);
const [pageSize, setPageSize] = useStoredPageSize("ai-session-logs", 20);
const [filters, setFilters] = useState<{
providerId?: string;
capability?: string;
resourceId?: string;
actor?: string;
virtualApiKeyId?: string;
model?: string;
isStream?: string;
}>({
providerId: searchParams.get("providerId") || undefined,
capability: searchParams.get("capability") || undefined,
resourceId: searchParams.get("resourceId") || undefined,
actor: searchParams.get("actor") || undefined,
virtualApiKeyId: searchParams.get("virtualApiKeyId") || undefined,
model: searchParams.get("model") || undefined,
isStream: searchParams.get("isStream") || undefined
});
const getDefaultDateRange = () => {
const startParam = searchParams.get("start");
const endParam = searchParams.get("end");
if (startParam && endParam) {
return {
startDate: { date: new Date(startParam) },
endDate: { date: new Date(endParam) }
};
}
return {
startDate: { date: getSevenDaysAgo() },
endDate: { date: new Date() }
};
};
const [dateRange, setDateRange] = useState<{
startDate: DateTimeValue;
endDate: DateTimeValue;
}>(getDefaultDateRange());
const queryFilters = useMemo(() => {
let timeStart: string | undefined;
let timeEnd: string | undefined;
if (dateRange.startDate?.date) {
const dt = new Date(dateRange.startDate.date);
if (dateRange.startDate.time) {
const [h, m, s] = dateRange.startDate.time
.split(":")
.map(Number);
dt.setHours(h, m, s || 0);
}
timeStart = dt.toISOString();
}
if (dateRange.endDate?.date) {
const dt = new Date(dateRange.endDate.date);
if (dateRange.endDate.time) {
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
dt.setHours(h, m, s || 0);
} else {
const now = new Date();
dt.setHours(
now.getHours(),
now.getMinutes(),
now.getSeconds(),
now.getMilliseconds()
);
}
timeEnd = dt.toISOString();
}
return {
timeStart,
timeEnd,
page: currentPage,
pageSize,
...filters
};
}, [dateRange, currentPage, pageSize, filters]);
const { data, isFetching, isLoading, refetch } = useQuery({
...logQueries.aiSessions({
orgId: orgId as string,
filters: queryFilters
}),
enabled: isPaidUser(tierMatrix.aiSessionLogs) && build !== "oss"
});
const rows = isLoading ? generateSampleAiSessionLogs() : (data?.log ?? []);
const totalCount = data?.pagination?.total ?? 0;
const filterAttributes = data?.filterAttributes ?? {
providers: [],
resources: [],
users: [],
virtualApiKeys: [],
models: []
};
const handleDateRangeChange = (
startDate: DateTimeValue,
endDate: DateTimeValue
) => {
setDateRange({ startDate, endDate });
setCurrentPage(0);
updateUrlParamsForAllFilters({
start: startDate.date?.toISOString() || "",
end: endDate.date?.toISOString() || ""
});
};
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
};
const handleRefresh = () => {
// When the end date has no explicit time, it represents an
// open-ended "up to now" upper bound. Since dateRange is only
// recomputed on user interaction, that upper bound otherwise stays
// frozen at whenever the page first loaded, so refreshing would
// never surface logs created since then. Bump it to the current
// time so the query key changes and refetches the latest window.
if (dateRange.endDate?.date && !dateRange.endDate.time) {
setDateRange((prev) => ({
...prev,
endDate: { date: new Date() }
}));
} else {
refetch();
}
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
};
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters);
setCurrentPage(0);
updateUrlParamsForAllFilters(newFilters);
};
const updateUrlParamsForAllFilters = (
newFilters:
| typeof filters
| {
start: string;
end: string;
}
) => {
const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
router.replace(`?${params.toString()}`, { scroll: false });
};
const exportData = async () => {
try {
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
: undefined,
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
...filters
};
const response = await api.get(`/org/${orgId}/logs/ai/export`, {
responseType: "blob",
params
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
const epoch = Math.floor(Date.now() / 1000);
link.setAttribute(
"download",
`ai-session-logs-${orgId}-${epoch}.csv`
);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
} catch (error) {
let apiErrorMessage: string | null = null;
if (axios.isAxiosError(error) && error.response) {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
}
}
toast({
title: t("error"),
description: apiErrorMessage ?? t("exportError"),
variant: "destructive"
});
}
};
const columns: ColumnDef<any>[] = [
{
accessorKey: "createdAt",
header: ({ column }) => (
<span className="px-2">{t("timestamp")}</span>
),
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
{new Date(row.original.createdAt).toLocaleString()}
</div>
);
}
},
{
accessorKey: "providerName",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.providers.map(
(provider) => ({
value: provider.id.toString(),
label: provider.name || "Unnamed Provider"
})
)}
selectedValue={filters.providerId}
onValueChange={(value) =>
handleFilterChange("providerId", value)
}
label={t("provider")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
<Bot className="h-4 w-4" />
{row.original.providerName || (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</span>
);
}
},
{
accessorKey: "capability",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={Object.entries(capabilityLabels).map(
([value, label]) => ({ value, label })
)}
selectedValue={filters.capability}
onValueChange={(value) =>
handleFilterChange("capability", value)
}
label={t("capability")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return (
<span className="text-xs">
{capabilityLabels[row.original.capability] ||
row.original.capability}
</span>
);
}
},
{
accessorKey: "requestedModel",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.models.map((model) => ({
value: model,
label: model
}))}
selectedValue={filters.model}
onValueChange={(value) =>
handleFilterChange("model", value)
}
label={t("model")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return row.original.requestedModel ? (
<span>{row.original.requestedModel}</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
{
accessorKey: "resourceName",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.resources.map((res) => ({
value: res.id.toString(),
label: res.name || "Unnamed Resource"
}))}
selectedValue={filters.resourceId}
onValueChange={(value) =>
handleFilterChange("resourceId", value)
}
label={t("resource")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
if (
!row.original.resourceNiceId ||
!row.original.resourceName
) {
return (
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<Link
href={
row.original.resourceType === "site"
? getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
}
onClick={(e) => e.stopPropagation()}
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
{
accessorKey: "isStream",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={[
{ value: "true", label: t("streaming") },
{ value: "false", label: t("nonStreaming") }
]}
label={t("stream")}
selectedValue={filters.isStream}
onValueChange={(value) =>
handleFilterChange("isStream", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
{row.original.isStream ? (
<>
<Waves className="h-4 w-4" />
{t("streaming")}
</>
) : (
<span className="text-muted-foreground text-xs">
{t("nonStreaming")}
</span>
)}
</span>
);
}
},
{
accessorKey: "userEmail",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.users.map((user) => ({
value: user.id,
label: user.email || user.id
}))}
selectedValue={filters.actor}
onValueChange={(value) =>
handleFilterChange("actor", value)
}
label={t("actor")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
{row.original.userEmail ? (
<>
<User className="h-4 w-4" />
{row.original.userEmail}
</>
) : (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</span>
);
}
},
{
accessorKey: "virtualApiKeyId",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.virtualApiKeys.map(
(key) => ({
value: key.id,
label:
key.name ??
(key.lastChars
? formatVirtualApiKeyPreview(
key.id,
key.lastChars
)
: key.id)
})
)}
selectedValue={filters.virtualApiKeyId}
onValueChange={(value) =>
handleFilterChange("virtualApiKeyId", value)
}
label={t("virtualApiKey")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
if (!row.original.virtualApiKeyId) {
return (
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<div className="flex flex-col min-w-0">
<span className="truncate">
{row.original.virtualApiKeyName ??
t("aiUsageUnnamedVirtualApiKey")}
</span>
{row.original.virtualApiKeyLastChars && (
<span className="text-xs text-muted-foreground truncate">
{formatVirtualApiKeyPreview(
row.original.virtualApiKeyId,
row.original.virtualApiKeyLastChars
)}
</span>
)}
</div>
);
}
}
];
const renderExpandedRow = (row: any) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 text-xs">
<div>
<strong>{t("aiSessionId")}</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.sessionId}
</p>
</div>
<div>
<strong>{t("statusCode")}</strong>
<p className="text-muted-foreground mt-1">
{row.statusCode ?? "N/A"}
</p>
</div>
<div>
<strong>{t("cost")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage && row.usage.costUsd != null
? `$${row.usage.costUsd.toFixed(4)}`
: "N/A"}
</p>
</div>
<div>
<strong>{t("estimated")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage
? row.usage.estimated
? t("yes")
: t("no")
: "N/A"}
</p>
</div>
<div>
<strong>{t("totalTokens")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage.totalTokens.toLocaleString()}
</p>
</div>
</div>
{row.usage && (
<div>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-4 text-xs">
<div>
<strong>{t("promptTokens")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage.promptTokens.toLocaleString()}
</p>
</div>
<div>
<strong>{t("cacheReadTokens")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage.cacheReadTokens.toLocaleString()}
</p>
</div>
<div>
<strong>{t("cacheWriteTokens")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage.cacheWriteTokens.toLocaleString()}
</p>
</div>
<div>
<strong>{t("completionTokens")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage.completionTokens.toLocaleString()}
</p>
</div>
<div>
<strong>{t("reasoningTokens")}</strong>
<p className="text-muted-foreground mt-1">
{row.usage.reasoningTokens.toLocaleString()}
</p>
</div>
</div>
</div>
)}
<AiSessionChatView
normalizedRequest={row.normalizedRequest}
normalizedResponse={row.normalizedResponse}
requestBody={row.requestBody}
responseBody={row.responseBody}
truncated={row.truncated}
/>
</div>
);
};
return (
<>
<SettingsSectionTitle
title={t("aiSessionLogs")}
description={t("aiSessionLogsDescription")}
/>
<PaidFeaturesAlert tiers={tierMatrix.aiSessionLogs} />
{org.org.settingsLogRetentionDaysAISessions === 0 && (
<LogRetentionWarning
orgId={orgId as string}
logTypeLabel={t("aiSessionLogs")}
/>
)}
<LogDataTable
columns={columns}
data={rows}
title={t("aiSessionLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="providerName"
onRefresh={handleRefresh}
isRefreshing={isFetching}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
dateRange={{
start: dateRange.startDate,
end: dateRange.endDate
}}
defaultSort={{
id: "createdAt",
desc: true
}}
totalCount={totalCount}
currentPage={currentPage}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
isLoading={isLoading}
pageSize={pageSize}
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={
!isPaidUser(tierMatrix.aiSessionLogs) || build === "oss"
}
/>
</>
);
}
function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
const capabilities = Object.keys(capabilityLabels);
const providers = [
{ id: 1, name: "OpenAI Production" },
{ id: 2, name: "Anthropic Default" },
{ id: 3, name: "Vertex AI" }
];
const resourcesSample = [
{ id: 1, niceId: "resource-1", name: "Resource 1" },
{ id: 2, niceId: "resource-2", name: "Resource 2" }
];
const actors = ["alice@example.com", "bob@example.com", null];
const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"];
const virtualApiKeysSample = [
{ id: "vak00001", name: "CI pipeline", lastChars: "ab12" },
{ id: "vak00002", name: null, lastChars: "cd34" },
null
];
const now = Date.now();
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
return Array.from({ length: 10 }, (_, i) => {
const provider =
providers[Math.floor(Math.random() * providers.length)];
const resource =
resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
const actor = actors[Math.floor(Math.random() * actors.length)];
const virtualApiKey =
virtualApiKeysSample[
Math.floor(Math.random() * virtualApiKeysSample.length)
];
return {
id: i,
sessionId: `sample-session-${i}`,
orgId: "sample-org",
providerId: provider.id,
providerName: provider.name,
providerType: "openai",
capability:
capabilities[Math.floor(Math.random() * capabilities.length)],
resourceId: resource.id,
siteResourceId: null,
resourceName: resource.name,
resourceNiceId: resource.niceId,
resourceType: "public",
userId: actor ? `user-${i}` : null,
userEmail: actor,
virtualApiKeyId: virtualApiKey?.id ?? null,
virtualApiKeyName: virtualApiKey?.name ?? null,
virtualApiKeyLastChars: virtualApiKey?.lastChars ?? null,
requestedModel: models[Math.floor(Math.random() * models.length)],
isStream: Math.random() > 0.5,
requestBody: null,
responseBody: null,
normalizedRequest: null,
normalizedResponse: null,
truncated: false,
statusCode: 200,
createdAt: Math.floor(
sevenDaysAgoMs + Math.random() * (now - sevenDaysAgoMs)
),
usage: {
promptTokens: 500,
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: 150,
reasoningTokens: 0,
totalTokens: 650,
costUsd: 0.0123,
estimated: false
}
};
});
}
@@ -4,8 +4,10 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import LogRetentionWarning from "@app/components/LogRetentionWarning";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
@@ -47,6 +49,7 @@ export default function ConnectionLogsPage() {
const { orgId } = useParams();
const searchParams = useSearchParams();
const { org } = useOrgContext();
const { isPaidUser } = usePaidStatus();
const [isExporting, startTransition] = useTransition();
@@ -170,6 +173,23 @@ export default function ConnectionLogsPage() {
setCurrentPage(newPage);
};
const handleRefresh = () => {
// When the end date has no explicit time, it represents an
// open-ended "up to now" upper bound. Since dateRange is only
// recomputed on user interaction, that upper bound otherwise stays
// frozen at whenever the page first loaded, so refreshing would
// never surface logs created since then. Bump it to the current
// time so the query key changes and refetches the latest window.
if (dateRange.endDate?.date && !dateRange.endDate.time) {
setDateRange((prev) => ({
...prev,
endDate: { date: new Date() }
}));
} else {
refetch();
}
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
@@ -294,7 +314,9 @@ export default function ConnectionLogsPage() {
cell: ({ row }) => {
return (
<span className="whitespace-nowrap font-mono text-xs">
{row.original.protocol?.toUpperCase()}
{row.original.protocol?.toUpperCase() || (
<span className="text-muted-foreground">-</span>
)}
</span>
);
}
@@ -321,25 +343,26 @@ export default function ConnectionLogsPage() {
);
},
cell: ({ row }) => {
if (row.original.resourceName && row.original.resourceNiceId) {
if (
!row.original.resourceNiceId ||
!row.original.resourceName
) {
return (
<Link
href={getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)}
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<span className="whitespace-nowrap">
{row.original.resourceName ?? "-"}
</span>
<Link
href={getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)}
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
@@ -379,11 +402,14 @@ export default function ConnectionLogsPage() {
</Link>
);
}
return (
<span className="whitespace-nowrap">
{row.original.clientName ?? "-"}
</span>
);
if (row.original.clientName) {
return (
<span className="whitespace-nowrap">
{row.original.clientName}
</span>
);
}
return <span className="text-xs text-muted-foreground">-</span>;
}
},
{
@@ -416,17 +442,19 @@ export default function ConnectionLogsPage() {
</span>
);
}
return <span>-</span>;
return <span className="text-xs text-muted-foreground">-</span>;
}
},
{
accessorKey: "sourceAddr",
header: () => <span className="px-2">{t("sourceAddress")}</span>,
cell: ({ row }) => {
return (
return row.original.sourceAddr ? (
<span className="whitespace-nowrap font-mono text-xs">
{row.original.sourceAddr}
</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
@@ -452,10 +480,12 @@ export default function ConnectionLogsPage() {
);
},
cell: ({ row }) => {
return (
return row.original.destAddr ? (
<span className="whitespace-nowrap font-mono text-xs">
{row.original.destAddr}
</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
@@ -555,13 +585,20 @@ export default function ConnectionLogsPage() {
<PaidFeaturesAlert tiers={tierMatrix.connectionLogs} />
{org.org.settingsLogRetentionDaysConnection === 0 && (
<LogRetentionWarning
orgId={orgId as string}
logTypeLabel={t("connectionLogs")}
/>
)}
<LogDataTable
columns={columns}
data={rows}
title={t("connectionLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="protocol"
onRefresh={() => refetch()}
onRefresh={handleRefresh}
isRefreshing={isFetching}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
+13 -1
View File
@@ -1,6 +1,8 @@
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type GeneralSettingsProps = {
children: React.ReactNode;
@@ -11,6 +13,8 @@ export default async function GeneralSettingsPage({
children,
params
}: GeneralSettingsProps) {
const { orgId } = await params;
const getUser = cache(verifySession);
const user = await getUser();
@@ -18,5 +22,13 @@ export default async function GeneralSettingsPage({
redirect(`/`);
}
return children;
let org = null;
try {
const res = await getCachedOrg(orgId);
org = res.data.data;
} catch {
redirect(`/${orgId}`);
}
return <OrgProvider org={org}>{children}</OrgProvider>;
}
+74 -4
View File
@@ -2,9 +2,11 @@
import { ColumnFilter } from "@app/components/ColumnFilter";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import LogRetentionWarning from "@app/components/LogRetentionWarning";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useTranslations } from "next-intl";
@@ -31,6 +33,8 @@ export default function GeneralPage() {
const { orgId } = useParams();
const searchParams = useSearchParams();
const { org } = useOrgContext();
const [isExporting, startTransition] = useTransition();
const [currentPage, setCurrentPage] = useState<number>(0);
@@ -155,6 +159,23 @@ export default function GeneralPage() {
setCurrentPage(newPage);
};
const handleRefresh = () => {
// When the end date has no explicit time, it represents an
// open-ended "up to now" upper bound. Since dateRange is only
// recomputed on user interaction, that upper bound otherwise stays
// frozen at whenever the page first loaded, so refreshing would
// never surface logs created since then. Bump it to the current
// time so the query key changes and refetches the latest window.
if (dateRange.endDate?.date && !dateRange.endDate.time) {
setDateRange((prev) => ({
...prev,
endDate: { date: new Date() }
}));
} else {
refetch();
}
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
@@ -259,6 +280,7 @@ export default function GeneralPage() {
// 106 - Valid email
// 107 - Valid SSO
// 108 - Connected Client
// 109 - Valid Virtual API Key
// 201 - Resource Not Found
// 202 - Resource Blocked
@@ -277,6 +299,7 @@ export default function GeneralPage() {
106: t("validEmail"),
107: t("validSSO"),
108: t("connectedClient"),
109: t("validVirtualAPIKey"),
201: t("resourceNotFound"),
202: t("resourceBlocked"),
203: t("droppedByRule"),
@@ -357,7 +380,14 @@ export default function GeneralPage() {
}
/>
</span>
)
),
cell: ({ row }) => {
return row.original.ip ? (
row.original.ip
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
{
accessorKey: "location",
@@ -422,6 +452,14 @@ export default function GeneralPage() {
);
},
cell: ({ row }) => {
if (
!row.original.resourceNiceId ||
!row.original.resourceName
) {
return (
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<Link
href={
@@ -464,6 +502,11 @@ export default function GeneralPage() {
);
},
cell: ({ row }) => {
if (!row.original.host) {
return (
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<span className="flex items-center gap-1">
{row.original.tls ? (
@@ -496,6 +539,13 @@ export default function GeneralPage() {
/>
</div>
);
},
cell: ({ row }) => {
return row.original.path ? (
row.original.path
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
@@ -530,6 +580,13 @@ export default function GeneralPage() {
/>
</div>
);
},
cell: ({ row }) => {
return row.original.method ? (
row.original.method
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
{
@@ -572,7 +629,11 @@ export default function GeneralPage() {
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
{reasonMap[row.original.reason]}
{reasonMap[row.original.reason] ?? (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</span>
);
}
@@ -611,7 +672,9 @@ export default function GeneralPage() {
{row.original.actor}
</>
) : (
<>-</>
<span className="text-xs text-muted-foreground">
-
</span>
)}
</span>
);
@@ -685,13 +748,20 @@ export default function GeneralPage() {
description={t("requestLogsDescription")}
/>
{org.org.settingsLogRetentionDaysRequest === 0 && (
<LogRetentionWarning
orgId={orgId as string}
logTypeLabel={t("requestLogs")}
/>
)}
<LogDataTable
columns={columns}
data={rows}
title={t("requestLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="host"
onRefresh={() => refetch()}
onRefresh={handleRefresh}
isRefreshing={isFetching}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
@@ -1,112 +0,0 @@
"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>
);
}
@@ -1,175 +0,0 @@
"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>
)}
/>
);
}
@@ -1,297 +0,0 @@
"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>
);
}
@@ -1,24 +0,0 @@
"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>
);
}
@@ -1,306 +0,0 @@
"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} />;
}
@@ -1,133 +0,0 @@
"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>
)}
/>
);
}
@@ -1,333 +0,0 @@
"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>;
}
@@ -29,7 +29,7 @@ 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";
import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields";
export default function PrivateResourceAccessPage() {
const t = useTranslations();
@@ -0,0 +1,379 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import {
AiProviderAttachments,
type AiProviderAttachmentValue
} from "@app/components/AiProviderAttachments";
import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export default function PrivateResourceInferencePage() {
const t = useTranslations();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const { siteResource } = useSiteResourceContext();
const { save } = useSaveSiteResource();
useEffect(() => {
if (siteResource.mode !== "inference") {
router.replace(
`/${siteResource.orgId}/settings/resources/private/${siteResource.niceId}/general`
);
}
}, [router, siteResource.mode, siteResource.niceId, siteResource.orgId]);
const formSchema = useMemo(
() =>
z.object({
providers: z.array(
z.object({
providerId: z.number().int().positive(),
niceId: z.string(),
name: z.string(),
accessMode: z.enum(["inherit", "select"]),
enabled: z.boolean(),
selectedModelIds: z.array(z.number().int().positive())
})
),
httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(),
httpConfigFullDomain: z.string().nullish(),
ssl: z.boolean().optional()
}),
[]
);
type FormValues = z.infer<typeof formSchema>;
const attachedQuery = useQuery({
...resourceQueries.siteResourceAiProviders({
siteResourceId: siteResource.id
}),
enabled: siteResource.mode === "inference"
});
const modelsQuery = useQuery({
...resourceQueries.siteResourceAiModels({
siteResourceId: siteResource.id
}),
enabled: siteResource.mode === "inference"
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
providers: [],
httpConfigSubdomain: siteResource.subdomain ?? null,
httpConfigDomainId: siteResource.domainId ?? null,
httpConfigFullDomain: siteResource.fullDomain ?? null,
ssl: siteResource.ssl ?? false
}
});
const httpConfigSubdomain = form.watch("httpConfigSubdomain");
const httpConfigDomainId = form.watch("httpConfigDomainId");
const httpConfigFullDomain = form.watch("httpConfigFullDomain");
useEffect(() => {
if (!attachedQuery.data) return;
const hasSelect = attachedQuery.data.some(
(provider) => provider.accessMode === "select"
);
if (hasSelect && modelsQuery.isLoading) return;
const modelsByProvider = new Map<number, number[]>();
for (const model of modelsQuery.data ?? []) {
if (model.listType !== "allow") continue;
const existing = modelsByProvider.get(model.providerId) ?? [];
existing.push(model.modelId);
modelsByProvider.set(model.providerId, existing);
}
form.setValue(
"providers",
attachedQuery.data.map((provider) => ({
providerId: provider.providerId,
niceId: provider.niceId,
name: provider.name,
accessMode: provider.accessMode,
enabled: provider.enabled,
selectedModelIds:
provider.accessMode === "select"
? (modelsByProvider.get(provider.providerId) ?? [])
: []
}))
);
}, [attachedQuery.data, modelsQuery.data, modelsQuery.isLoading, form]);
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
await save({
mode: "inference",
httpConfigSubdomain: data.httpConfigSubdomain,
httpConfigDomainId: data.httpConfigDomainId,
httpConfigFullDomain: data.httpConfigFullDomain,
ssl: data.ssl
});
await api.post(`/site-resource/${siteResource.id}/ai-providers`, {
providers: data.providers.map((provider) => ({
providerId: provider.providerId,
accessMode: provider.accessMode,
enabled: provider.enabled
}))
});
const selectProviders = data.providers.filter(
(provider) => provider.accessMode === "select"
);
if (selectProviders.length > 0) {
await api.post(`/site-resource/${siteResource.id}/ai-models`, {
models: selectProviders.flatMap((provider) =>
provider.selectedModelIds.map((modelId) => ({
modelId,
listType: "allow" as const
}))
)
});
}
await queryClient.invalidateQueries(
resourceQueries.siteResourceAiProviders({
siteResourceId: siteResource.id
})
);
await queryClient.invalidateQueries(
resourceQueries.siteResourceAiModels({
siteResourceId: siteResource.id
})
);
toast({
title: t("success"),
description: t("aiResourceProvidersUpdated")
});
} catch (error) {
toast({
variant: "destructive",
title: t("aiResourceProvidersErrorUpdate"),
description: formatAxiosError(
error,
t("aiResourceProvidersErrorUpdate")
)
});
}
}, null);
if (siteResource.mode !== "inference") {
return null;
}
const providersLoading =
attachedQuery.isLoading ||
(attachedQuery.data?.some((p) => p.accessMode === "select") &&
modelsQuery.isLoading);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiResourceProviders")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiResourceProvidersDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-providers-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="providers"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiResourceProviders"
)}
</FormLabel>
<FormControl>
<AiProviderAttachments
orgId={
siteResource.orgId
}
value={
field.value as AiProviderAttachmentValue[]
}
disabled={
providersLoading
}
onChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"aiResourceDomainConfiguration"
)}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"aiResourceDomainConfigurationDescription"
)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
<SettingsFormCell span="full">
<DomainPicker
key={`inference-domain-${siteResource.id}`}
orgId={siteResource.orgId}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ??
undefined
}
onDomainChange={(res) => {
if (res === null) {
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
);
}}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="private-resource-inference-ssl"
label={t(
"editInternalResourceDialogEnableSsl"
)}
description={t(
"editInternalResourceDialogEnableSslDescription"
)}
checked={
!!field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-providers-form"
loading={saveLoading}
disabled={providersLoading || saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,42 @@
"use client";
import { SettingsContainer } from "@app/components/Settings";
import { BudgetsEditor } from "@app/components/BudgetsEditor";
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function PrivateResourceBudgetPage() {
const { siteResource } = useSiteResourceContext();
const router = useRouter();
const t = useTranslations();
useEffect(() => {
if (siteResource.mode !== "inference") {
router.replace(
`/${siteResource.orgId}/settings/resources/private/${siteResource.niceId}/general`
);
}
}, [
router,
siteResource.mode,
siteResource.niceId,
siteResource.orgId
]);
if (siteResource.mode !== "inference") {
return null;
}
return (
<SettingsContainer>
<BudgetsEditor
orgId={siteResource.orgId}
scope={{ type: "siteResource", id: siteResource.id }}
title={t("resourceBudgetSettings")}
description={t("resourceBudgetSettingsDescription")}
/>
</SettingsContainer>
);
}
@@ -20,12 +20,12 @@ 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";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceCidrPage() {
const t = useTranslations();
@@ -16,19 +16,23 @@ 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 { SwitchInput } from "@app/components/SwitchInput";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useSaveSiteResource } from "../../useSaveSiteResource";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
export default function PrivateResourceGeneralPage() {
const t = useTranslations();
@@ -41,7 +45,8 @@ export default function PrivateResourceGeneralPage() {
resolver: zodResolver(formSchema),
defaultValues: {
name: siteResource.name,
niceId: siteResource.niceId
niceId: siteResource.niceId,
enabled: siteResource.enabled
}
});
@@ -52,7 +57,8 @@ export default function PrivateResourceGeneralPage() {
const data = form.getValues();
await save({
name: data.name,
niceId: data.niceId
niceId: data.niceId,
enabled: data.enabled
});
}, null);
@@ -65,6 +71,25 @@ export default function PrivateResourceGeneralPage() {
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("privateResourceGeneralDescription")}
{siteResource.mode === "inference" ? (
<>
{" "}
{t.rich(
"resourceGeneralAiClientConfigDescription",
{
configLink: (chunks) => (
<Link
href={`/${siteResource.orgId}?openResource=${encodeURIComponent(siteResource.niceId)}&openResourceQuery=${encodeURIComponent(siteResource.name)}`}
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
)
}
)}
</>
) : null}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -76,6 +101,42 @@ export default function PrivateResourceGeneralPage() {
id="private-resource-general-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={() => (
<FormItem>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
siteResource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
@@ -20,16 +20,16 @@ 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 { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
} from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceHostPage() {
const t = useTranslations();
@@ -22,23 +22,19 @@ 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 { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
} from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
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)
);
@@ -120,7 +116,7 @@ export default function PrivateResourceHttpPage() {
)}
orgId={siteResource.orgId}
watch={asAnyWatch(form.watch)}
disabled={httpSectionDisabled}
disabled={false}
siteResourceId={siteResource.id}
/>
</SettingsFormCell>
@@ -135,7 +131,6 @@ export default function PrivateResourceHttpPage() {
type="submit"
form="private-resource-http-form"
loading={saveLoading}
disabled={httpSectionDisabled}
>
{t("saveSettings")}
</Button>
@@ -4,7 +4,7 @@ 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 SiteResourceInfoBox from "@app/components/PrivateResourceInfoBox";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
@@ -52,7 +52,8 @@ export default async function PrivateResourceLayout(
| "hostSettings"
| "cidrSettings"
| "httpSettings"
| "sshSettings";
| "sshSettings"
| "inferenceSettings";
const navItems = [
{
@@ -61,7 +62,7 @@ export default async function PrivateResourceLayout(
},
{
title: t(modeSettingsKey),
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode === "inference" ? "ai-gateway" : siteResource.mode}`
},
{
title: t("authentication"),
@@ -69,6 +70,13 @@ export default async function PrivateResourceLayout(
}
];
if (siteResource.mode === "inference") {
navItems.push({
title: t("resourceBudgetSettings"),
href: `/{orgId}/settings/resources/private/{niceId}/budget`
});
}
return (
<>
<SettingsSectionTitle
@@ -12,35 +12,30 @@ import {
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 { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
import type { Selectedsite } from "@app/components/site-selector";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
import type { Selectedsite } from "@app/components/site-selector";
} from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
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"
@@ -150,7 +145,6 @@ export default function PrivateResourceSshPage() {
return (
<SettingsContainer>
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -161,68 +155,56 @@ export default function PrivateResourceSshPage() {
</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>
<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
isNativeSsh={isNative}
/>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<form action={formAction}>
<Button type="submit" loading={saveLoading}>
{t("saveSettings")}
</Button>
</form>
</SettingsSectionFooter>
</Form>
</fieldset>
<SettingsSectionFooter>
<form action={formAction}>
<Button type="submit" loading={saveLoading}>
{t("saveSettings")}
</Button>
</form>
</SettingsSectionFooter>
</Form>
</SettingsSection>
</SettingsContainer>
);
@@ -12,11 +12,10 @@ import {
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
DescribedSelect,
type DescribedSelectOption
} from "@app/components/DescribedSelect";
import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button";
import {
Form,
@@ -30,7 +29,6 @@ import {
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 {
@@ -50,16 +48,24 @@ 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 { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import {
PrivateResourceAliasField,
PrivateResourceCidrDestinationField,
PrivateResourceHostDestinationFields
} from "../PrivateResourceDestinationFields";
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
} from "@app/components/PrivateResourceDestinationFields";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
import {
AiProvidersSelector,
type SelectedAiProvider
} from "@app/components/AiProvidersSelector";
export default function CreatePrivateResourcePage() {
const params = useParams();
@@ -69,12 +75,6 @@ export default function CreatePrivateResourcePage() {
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");
@@ -84,6 +84,9 @@ export default function CreatePrivateResourcePage() {
: null;
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [selectedProviders, setSelectedProviders] = useState<
SelectedAiProvider[]
>([]);
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
@@ -108,7 +111,8 @@ export default function CreatePrivateResourcePage() {
pamMode: "passthrough",
tcpPortRangeString: "*",
udpPortRangeString: "*",
disableIcmp: false
disableIcmp: false,
providerIds: []
}
});
@@ -135,28 +139,34 @@ export default function CreatePrivateResourcePage() {
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 modeOptions: DescribedSelectOption<PrivateResourceMode>[] = [
{
value: "host",
title: t("createInternalResourceDialogModeHost"),
description: t("privateResourceTypeHostDescription")
},
{
value: "cidr",
title: t("createInternalResourceDialogModeCidr"),
description: t("privateResourceTypeCidrDescription")
},
{
value: "http" as const,
title: t("createInternalResourceDialogModeHttp"),
description: t("privateResourceTypeHttpDescription")
},
{
value: "ssh" as const,
title: t("createInternalResourceDialogModeSsh"),
description: t("privateResourceTypeSshDescription")
},
{
value: "inference" as const,
title: t("createInternalResourceDialogModeInference"),
description: t("resourceTypeInferenceDescription")
}
];
const submitDisabled =
isSubmitting ||
(mode === "http" && httpSectionDisabled) ||
(mode === "ssh" && sshSectionDisabled);
function onSubmit(values: FormValues) {
startTransition(async () => {
try {
@@ -188,7 +198,9 @@ export default function CreatePrivateResourcePage() {
}
router.push(
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
created.mode === "inference"
? `/${orgId}/settings/resources/private/${created.niceId}/general`
: `/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
);
} catch (error) {
toast({
@@ -242,6 +254,110 @@ export default function CreatePrivateResourcePage() {
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("type")}
</FormLabel>
<FormControl>
<DescribedSelect<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 if (
newMode ===
"inference"
) {
form.setValue(
"siteIds",
[]
);
setSelectedSites(
[]
);
form.setValue(
"destination",
null
);
form.setValue(
"destinationPort",
null
);
form.setValue(
"providerIds",
[]
);
setSelectedProviders(
[]
);
} else {
form.setValue(
"destinationPort",
null
);
}
}}
searchPlaceholder={t(
"resourceTypeSearch"
)}
emptyMessage={t(
"resourceTypeNotFound"
)}
placeholder={t(
"noneSelected"
)}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"privateResourceTypeDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
@@ -265,110 +381,63 @@ export default function CreatePrivateResourcePage() {
/>
</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" && (
{(mode === "http" ||
mode === "inference") && (
<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>
<FormField
control={form.control}
name="httpConfigDomainId"
render={() => (
<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,
{
shouldValidate: true
}
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
)}
@@ -381,10 +450,6 @@ export default function CreatePrivateResourcePage() {
)}
watch={asAnyWatch(form.watch)}
labelPrefix="create"
disabled={
mode === "ssh" &&
sshSectionDisabled
}
/>
</SettingsFormCell>
)}
@@ -498,9 +563,6 @@ export default function CreatePrivateResourcePage() {
{/* HTTP configuration */}
{mode === "http" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("httpSettings")}
@@ -511,101 +573,132 @@ export default function CreatePrivateResourcePage() {
)}
</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>
<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)}
labelPrefix="create"
hideDomainPicker
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* SSH server */}
{mode === "ssh" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshServer")}
{t("sshSettings")}
</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>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<PrivateResourceSshFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(form.setValue)}
watch={asAnyWatch(form.watch)}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={setSelectedSites}
labelPrefix="create"
showSshSettings={true}
layout="wizard"
hideAlias
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{mode === "inference" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiResourceProviders")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiResourceProvidersDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="providerIds"
render={() => (
<FormItem>
<FormLabel>
{t(
"aiResourceProviders"
)}
</FormLabel>
<FormControl>
<AiProvidersSelector
orgId={orgId}
selectedProviders={
selectedProviders
}
onSelectProviders={(
providers
) => {
setSelectedProviders(
providers
);
form.setValue(
"providerIds",
providers.map(
(
p
) =>
parseInt(
p.id,
10
)
),
{
shouldValidate: true
}
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
@@ -625,7 +718,7 @@ export default function CreatePrivateResourcePage() {
<Button
type="submit"
form="create-private-resource-form"
disabled={submitDisabled}
disabled={isSubmitting}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
@@ -1,24 +0,0 @@
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,11 +1,13 @@
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
import type { PrivateResourceRow } from "@app/components/PrivateResourcesTable";
import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
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 { build } from "@server/build";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
@@ -27,6 +29,7 @@ export default async function ClientResourcesPage(
const params = await props.params;
const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
@@ -58,7 +61,7 @@ export default async function ClientResourcesPage(
redirect(`/${params.orgId}/settings/resources`);
}
const internalResourceRows: InternalResourceRow[] = siteResources.map(
const internalResourceRows: PrivateResourceRow[] = siteResources.map(
(siteResource) => {
return {
id: siteResource.siteResourceId,
@@ -98,6 +101,42 @@ export default async function ClientResourcesPage(
};
}
);
// Prefetched in one batched call so the table doesn't fire a separate
// certificate request per visible row once it mounts on the client.
const certDomains = Array.from(
new Set(
internalResourceRows
.filter(
(r) =>
r.mode === "http" &&
!r.alias &&
r.ssl &&
r.domainId &&
r.fullDomain
)
.map((r) => r.fullDomain as string)
)
);
let initialCertificates: GetBatchedCertificateResponse | undefined;
if (build !== "oss" && certDomains.length > 0) {
try {
const certSearchParams = new URLSearchParams(
certDomains.map((domain) => ["domains", domain])
);
const certRes = await internal.get<
AxiosResponse<GetBatchedCertificateResponse>
>(
`/org/${params.orgId}/batched-certificates?${certSearchParams.toString()}`,
await authCookieHeader()
);
initialCertificates = certRes.data.data;
} catch {
// leave undefined so each row falls back to fetching its own
}
}
return (
<>
<SettingsSectionTitle
@@ -116,6 +155,7 @@ export default async function ClientResourcesPage(
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialCertificates={initialCertificates}
/>
</OrgProvider>
</>
@@ -1,36 +0,0 @@
"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";
}
@@ -1,106 +0,0 @@
"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 };
}
@@ -42,7 +42,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { orgQueries, resourceQueries, aiProviderQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { type GetResourceResponse } from "@server/routers/resource";
import { CreateTargetResponse } from "@server/routers/target";
@@ -63,9 +63,11 @@ import { ExternalLink, Info, Plus } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import {
forwardRef,
useActionState,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState
} from "react";
@@ -80,27 +82,65 @@ export type LocalTarget = Omit<
"protocol"
>;
interface ProxyResourceTargetsFormProps {
export type ProxyResourceTargetsFormHandle = {
save: (options?: { silent?: boolean }) => Promise<boolean>;
};
const DEFAULT_ALLOWED_METHODS: ("http" | "https" | "h2c")[] = [
"http",
"https",
"h2c"
];
const EMPTY_TARGETS: LocalTarget[] = [];
type ProxyResourceTargetsFormProps = {
orgId: string;
isHttp: boolean;
initialTargets?: LocalTarget[];
/** Edit mode: when provided, shows a save button and polls for health status */
/** Edit mode for a public resource: save button + health polling */
resource?: GetResourceResponse;
/** Edit mode for an AI provider: save button + health polling */
providerId?: number;
updateResource?: ResourceContextType["updateResource"];
/** Create mode: called whenever the targets list changes */
onChange?: (targets: LocalTarget[]) => void;
}
/** HTTP method options for address selector. Defaults to http/https/h2c. */
allowedMethods?: ("http" | "https" | "h2c")[];
emptyMessage?: string;
/** Render table without its own SettingsSection wrapper */
embedded?: boolean;
/** Hide the built-in save button (use ref.save from parent) */
hideSaveButton?: boolean;
/** Hide the advanced mode toggle and always use non-advanced mode (e.g. AI providers) */
disableAdvancedMode?: boolean;
/** Targets picker is for an AI provider (changes which routing warnings are shown) */
isAiProvider?: boolean;
};
export function ProxyResourceTargetsForm({
orgId,
isHttp,
initialTargets = [],
resource,
updateResource,
onChange
}: ProxyResourceTargetsFormProps) {
export const ProxyResourceTargetsForm = forwardRef<
ProxyResourceTargetsFormHandle,
ProxyResourceTargetsFormProps
>(function ProxyResourceTargetsForm(
{
orgId,
isHttp,
initialTargets = EMPTY_TARGETS,
resource,
providerId,
updateResource,
onChange,
allowedMethods = DEFAULT_ALLOWED_METHODS,
emptyMessage,
embedded = false,
hideSaveButton = false,
disableAdvancedMode = false,
isAiProvider = false
},
ref
) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const isEditMode = !!resource || !!providerId;
const [targets, setTargets] = useState<LocalTarget[]>(initialTargets);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
@@ -111,7 +151,7 @@ export function ProxyResourceTargetsForm({
}, [targets]);
// Poll health status only in edit mode
const { data: polledTargets } = useQuery({
const { data: polledResourceTargets } = useQuery({
...resourceQueries.resourceTargets({
resourceId: resource?.resourceId ?? 0
}),
@@ -119,6 +159,18 @@ export function ProxyResourceTargetsForm({
enabled: !!resource
});
const { data: polledProviderTargets } = useQuery({
...aiProviderQueries.providerTargets({
providerId: providerId ?? 0
}),
refetchInterval: 10_000,
enabled: !!providerId
});
const polledTargets = providerId
? polledProviderTargets
: polledResourceTargets;
useEffect(() => {
if (!polledTargets) return;
setTargets((prev) =>
@@ -182,6 +234,9 @@ export function ProxyResourceTargetsForm({
);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (disableAdvancedMode) {
return false;
}
if (typeof window !== "undefined") {
const saved = localStorage.getItem("proxy-advanced-mode");
return saved === "true";
@@ -207,6 +262,14 @@ export function ProxyResourceTargetsForm({
})
);
const { data: remoteExitNodes = [] } = useQuery({
...orgQueries.remoteExitNodes({ orgId }),
enabled: build === "saas" && isAiProvider
});
const hasRemoteExitNodes = remoteExitNodes.some(
(node) => node.exitNodeId !== null
);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
@@ -221,7 +284,7 @@ export function ProxyResourceTargetsForm({
);
});
},
[sites]
[]
);
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
@@ -427,6 +490,7 @@ export function ProxyResourceTargetsForm({
isHttp={isHttp}
proxyTarget={row.original}
updateTarget={updateTarget}
allowedMethods={allowedMethods}
/>
);
},
@@ -570,22 +634,27 @@ export function ProxyResourceTargetsForm({
}, [
isAdvancedMode,
isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
allowedMethods,
t
]);
function addNewTarget() {
const defaultMethod = providerId
? (allowedMethods[0] ?? "https")
: isHttp
? "http"
: null;
const newTarget: LocalTarget = {
targetId: -Date.now(),
ip: "",
mode: ((resource?.mode as LocalTarget["mode"]) ??
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
method: isHttp ? "http" : null,
method: defaultMethod,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,
siteName: sites.length > 0 ? sites[0].name : "",
@@ -595,7 +664,8 @@ export function ProxyResourceTargetsForm({
rewritePathType: null,
priority: 100,
enabled: true,
resourceId: resource?.resourceId ?? 0,
resourceId: resource?.resourceId ?? null,
providerId: providerId ?? null,
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -662,18 +732,25 @@ export function ProxyResourceTargetsForm({
}, [sites]);
useEffect(() => {
if (disableAdvancedMode) return;
if (typeof window !== "undefined") {
localStorage.setItem(
"proxy-advanced-mode",
isAdvancedMode.toString()
);
}
}, [isAdvancedMode]);
}, [isAdvancedMode, disableAdvancedMode]);
const [, formAction, isSubmitting] = useActionState(saveTargets, null);
const [, formAction, isSubmitting] = useActionState(
async () => {
await saveTargets();
return null;
},
null
);
const addTargetButton = (
<Button onClick={addNewTarget} variant="outline">
<Button type="button" onClick={addNewTarget} variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
@@ -681,8 +758,8 @@ export function ProxyResourceTargetsForm({
const hasTargets = targets.length > 0;
async function saveTargets() {
if (!resource) return;
async function saveTargets(options?: { silent?: boolean }) {
if (!isEditMode) return true;
const targetsWithInvalidFields = targets.filter(
(target) =>
@@ -698,7 +775,7 @@ export function ProxyResourceTargetsForm({
title: t("targetErrorInvalidIp"),
description: t("targetErrorInvalidIpDescription")
});
return;
return false;
}
try {
@@ -742,9 +819,12 @@ export function ProxyResourceTargetsForm({
}
if (target.new) {
const createPath = providerId
? `/ai-provider/${providerId}/target`
: `/resource/${resource!.resourceId}/target`;
const res = await api.put<
AxiosResponse<CreateTargetResponse>
>(`/resource/${resource.resourceId}/target`, data);
>(createPath, data);
target.targetId = res.data.data.targetId;
target.new = false;
} else if (target.updated) {
@@ -753,24 +833,33 @@ export function ProxyResourceTargetsForm({
}
}
toast({
title:
targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description:
targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
});
if (!options?.silent) {
toast({
title:
targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description:
targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
});
}
setTargetsToRemove([]);
router.refresh();
await queryClient.invalidateQueries(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
})
);
if (providerId) {
await queryClient.invalidateQueries(
aiProviderQueries.providerTargets({ providerId })
);
} else if (resource) {
await queryClient.invalidateQueries(
resourceQueries.resourceTargets({
resourceId: resource.resourceId
})
);
}
return true;
} catch (err) {
console.error(err);
toast({
@@ -781,151 +870,173 @@ export function ProxyResourceTargetsForm({
t("settingsErrorUpdateDescription")
)
});
return false;
}
}
useImperativeHandle(ref, () => ({
save: saveTargets
}));
const advancedModeToggleId = providerId
? `advanced-mode-toggle-provider-${providerId}`
: resource
? `advanced-mode-toggle-resource-${resource.resourceId}`
: "advanced-mode-toggle";
const targetsTable = (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isActionsColumn =
header.column.id === "actions";
const isSiteColumn =
header.column.id === "site";
return (
<TableHead
key={header.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => {
const isActionsColumn =
cell.column.id === "actions";
const isSiteColumn =
cell.column.id === "site";
return (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<DataTableEmptyState
colSpan={columns.length}
message={emptyMessage ?? t("targetNoOne")}
action={addTargetButton}
compact
/>
)}
</TableBody>
</Table>
</div>
{hasTargets && (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{addTargetButton}
{!disableAdvancedMode && (
<div className="flex items-center gap-2">
<Switch
id={advancedModeToggleId}
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor={advancedModeToggleId}
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
)}
</div>
</div>
)}
{build === "saas" &&
!isAiProvider &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
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>
)}
{build === "saas" && isAiProvider && hasRemoteExitNodes && (
<p className="text-sm text-muted-foreground mt-3">
{t("aiProviderRemoteNodeTargetsWarning")}
</p>
)}
</>
);
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("targets")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("targetsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isActionsColumn =
header.column.id === "actions";
const isSiteColumn =
header.column.id === "site";
return (
<TableHead
key={header.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => {
const isActionsColumn =
cell.column.id ===
"actions";
const isSiteColumn =
cell.column.id ===
"site";
return (
<TableCell
key={cell.id}
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<DataTableEmptyState
colSpan={columns.length}
message={t("targetNoOne")}
action={addTargetButton}
/>
)}
</TableBody>
</Table>
</div>
{hasTargets && (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between w-full gap-2">
{addTargetButton}
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm"
>
{t("advancedMode")}
</label>
</div>
</div>
</div>
)}
{build === "saas" &&
targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3">
{t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
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>
)}
</SettingsSectionBody>
{embedded ? (
<div className="space-y-4">{targetsTable}</div>
) : (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("targets")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("targetsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>{targetsTable}</SettingsSectionBody>
{/* Save button — only shown in edit mode */}
{resource && (
<form className="self-end mt-4" action={formAction}>
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveResourceTargets")}
</Button>
</form>
)}
</SettingsSection>
{isEditMode && !hideSaveButton && (
<form className="self-end mt-4" action={formAction}>
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveResourceTargets")}
</Button>
</form>
)}
</SettingsSection>
)}
{selectedTargetForHealthCheck && (
<HealthCheckCredenza
@@ -986,4 +1097,4 @@ export function ProxyResourceTargetsForm({
)}
</>
);
}
});
@@ -0,0 +1,267 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
AiProviderAttachments,
type AiProviderAttachmentValue
} from "@app/components/AiProviderAttachments";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export default function PublicResourceInferencePage() {
const t = useTranslations();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const { resource } = useResourceContext();
useEffect(() => {
if (resource.mode !== "inference") {
router.replace(
`/${resource.orgId}/settings/resources/public/${resource.niceId}/general`
);
}
}, [router, resource.mode, resource.niceId, resource.orgId]);
const formSchema = useMemo(
() =>
z.object({
providers: z.array(
z.object({
providerId: z.number().int().positive(),
niceId: z.string(),
name: z.string(),
accessMode: z.enum(["inherit", "select"]),
enabled: z.boolean(),
selectedModelIds: z.array(z.number().int().positive())
})
)
}),
[]
);
type FormValues = z.infer<typeof formSchema>;
const attachedQuery = useQuery({
...resourceQueries.resourceAiProviders({
resourceId: resource.resourceId
}),
enabled: resource.mode === "inference"
});
const modelsQuery = useQuery({
...resourceQueries.resourceAiModels({
resourceId: resource.resourceId
}),
enabled: resource.mode === "inference"
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
providers: []
}
});
useEffect(() => {
if (!attachedQuery.data) return;
const hasSelect = attachedQuery.data.some(
(provider) => provider.accessMode === "select"
);
if (hasSelect && modelsQuery.isLoading) return;
const modelsByProvider = new Map<number, number[]>();
for (const model of modelsQuery.data ?? []) {
if (model.listType !== "allow") continue;
const existing = modelsByProvider.get(model.providerId) ?? [];
existing.push(model.modelId);
modelsByProvider.set(model.providerId, existing);
}
form.reset({
providers: attachedQuery.data.map((provider) => ({
providerId: provider.providerId,
niceId: provider.niceId,
name: provider.name,
accessMode: provider.accessMode,
enabled: provider.enabled,
selectedModelIds:
provider.accessMode === "select"
? (modelsByProvider.get(provider.providerId) ?? [])
: []
}))
});
}, [
attachedQuery.data,
modelsQuery.data,
modelsQuery.isLoading,
form
]);
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
await api.post(`/resource/${resource.resourceId}/ai-providers`, {
providers: data.providers.map((provider) => ({
providerId: provider.providerId,
accessMode: provider.accessMode,
enabled: provider.enabled
}))
});
const selectProviders = data.providers.filter(
(provider) => provider.accessMode === "select"
);
if (selectProviders.length > 0) {
await api.post(`/resource/${resource.resourceId}/ai-models`, {
models: selectProviders.flatMap((provider) =>
provider.selectedModelIds.map((modelId) => ({
modelId,
listType: "allow" as const
}))
)
});
}
await queryClient.invalidateQueries(
resourceQueries.resourceAiProviders({
resourceId: resource.resourceId
})
);
await queryClient.invalidateQueries(
resourceQueries.resourceAiModels({
resourceId: resource.resourceId
})
);
toast({
title: t("success"),
description: t("aiResourceProvidersUpdated")
});
} catch (error) {
toast({
variant: "destructive",
title: t("aiResourceProvidersErrorUpdate"),
description: formatAxiosError(
error,
t("aiResourceProvidersErrorUpdate")
)
});
}
}, null);
if (resource.mode !== "inference") {
return null;
}
const providersLoading =
attachedQuery.isLoading ||
(attachedQuery.data?.some((p) => p.accessMode === "select") &&
modelsQuery.isLoading);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiResourceProviders")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("aiResourceProvidersDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="public-resource-providers-form"
>
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="providers"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiResourceProviders"
)}
</FormLabel>
<FormControl>
<AiProviderAttachments
orgId={
resource.orgId
}
value={
field.value as AiProviderAttachmentValue[]
}
disabled={
providersLoading
}
onChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="public-resource-providers-form"
loading={saveLoading}
disabled={providersLoading || saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,37 @@
"use client";
import { SettingsContainer } from "@app/components/Settings";
import { BudgetsEditor } from "@app/components/BudgetsEditor";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function PublicResourceBudgetPage() {
const { resource } = useResourceContext();
const router = useRouter();
const t = useTranslations();
useEffect(() => {
if (resource.mode !== "inference") {
router.replace(
`/${resource.orgId}/settings/resources/public/${resource.niceId}/general`
);
}
}, [router, resource.mode, resource.niceId, resource.orgId]);
if (resource.mode !== "inference") {
return null;
}
return (
<SettingsContainer>
<BudgetsEditor
orgId={resource.orgId}
scope={{ type: "resource", id: resource.resourceId }}
title={t("resourceBudgetSettings")}
description={t("resourceBudgetSettingsDescription")}
/>
</SettingsContainer>
);
}
@@ -50,6 +50,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
import { build } from "@server/build";
import { TierFeature } from "@server/lib/billing/tierMatrix";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
@@ -114,14 +115,20 @@ export default function GeneralForm() {
.refine(
(data) => {
// For non-HTTP resources, proxyPort should be defined
if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
if (
!["http", "ssh", "rdp", "vnc", "inference"].includes(
resource.mode
)
) {
return data.proxyPort !== undefined;
}
// For HTTP resources, proxyPort should be undefined
return data.proxyPort === undefined;
},
{
message: !["http", "ssh", "rdp", "vnc"].includes(resource.mode)
message: !["http", "ssh", "rdp", "vnc", "inference"].includes(
resource.mode
)
? "Port number is required for non-HTTP resources"
: "Port number should not be set for HTTP resources",
path: ["proxyPort"]
@@ -153,7 +160,7 @@ export default function GeneralForm() {
let resourcePolicyId: number | null | undefined;
if (!["tcp", "udp"].includes(resource.mode)) {
if (!["tcp", "udp", "inference"].includes(resource.mode)) {
if (hasResourcePolicies || selectedSharedPolicyId === null) {
resourcePolicyId = selectedSharedPolicyId;
}
@@ -249,6 +256,25 @@ export default function GeneralForm() {
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceGeneralDescription")}
{resource.mode === "inference" ? (
<>
{" "}
{t.rich(
"resourceGeneralAiClientConfigDescription",
{
configLink: (chunks) => (
<Link
href={`/${resource.orgId}?openResource=${encodeURIComponent(resource.niceId)}&openResourceQuery=${encodeURIComponent(resource.name)}`}
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
)
}
)}
</>
) : null}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -339,7 +365,7 @@ export default function GeneralForm() {
/>
</SettingsFormCell>
{!["http", "ssh", "rdp", "vnc"].includes(
{!["http", "ssh", "rdp", "vnc", "inference"].includes(
resource.mode
) && (
<SettingsFormCell span="half">
@@ -393,13 +419,16 @@ export default function GeneralForm() {
</SettingsFormCell>
)}
{["http", "ssh", "rdp", "vnc"].includes(
{["http", "ssh", "rdp", "vnc", "inference"].includes(
resource.mode
) && (
<SettingsFormCell span="full">
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={true}
allowWildcard={
resource.mode !==
"inference"
}
key={
resource.resourceId
}
@@ -453,9 +482,11 @@ export default function GeneralForm() {
</div>
</SettingsFormCell>
)}
{ !["tcp", "udp"].includes(
resource.mode
) && !env.flags.disableEnterpriseFeatures && (
{!["tcp", "udp", "inference"].includes(
resource.mode
) &&
!env.flags
.disableEnterpriseFeatures && (
<>
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
@@ -82,18 +82,18 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
redirect(`/${params.orgId}/settings/resources`);
}
const navItems = [
{
title: t("general"),
href: `/{orgId}/settings/resources/public/{niceId}/general`
},
{
title: t(`${resource.mode}Settings`),
href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode}`
}
];
const navItems = [
{
title: t("general"),
href: `/{orgId}/settings/resources/public/{niceId}/general`
},
{
title: t(`${resource.mode}Settings`),
href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode === "inference" ? "ai-gateway" : resource.mode}`
}
];
if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) {
if (["http", "ssh", "rdp", "vnc", "inference"].includes(resource.mode)) {
navItems.push(
{
title: t("authentication"),
@@ -105,7 +105,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
}
);
if (!env.flags.disableEnterpriseFeatures) {
if (!env.flags.disableEnterpriseFeatures && resource.mode !== "inference") {
navItems.push({
title: t("maintenanceMode"),
href: `/{orgId}/settings/resources/public/{niceId}/maintenance`
@@ -113,6 +113,13 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
}
}
if (resource.mode === "inference") {
navItems.push({
title: t("resourceBudgetSettings"),
href: `/{orgId}/settings/resources/public/{niceId}/budget`
});
}
return (
<>
<SettingsSectionTitle
@@ -161,7 +161,7 @@ export default function ResourceMaintenancePage() {
return null;
}
const isMaintenanceDisabled = !isPaidUser(tierMatrix.maintencePage);
const isMaintenanceDisabled = !isPaidUser(tierMatrix.maintenancePage);
const maintenanceModeTypeOptions: StrategyOption<
"automatic" | "forced"
@@ -180,7 +180,7 @@ export default function ResourceMaintenancePage() {
return (
<>
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
<PaidFeaturesAlert tiers={tierMatrix.maintenancePage} />
<div
className={
isMaintenanceDisabled
@@ -55,11 +55,7 @@ export default function RdpSettingsPage(props: {
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
@@ -75,14 +71,10 @@ export default function RdpSettingsPage(props: {
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<RdpServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
@@ -92,13 +84,11 @@ export default function RdpSettingsPage(props: {
function RdpServerForm({
orgId,
resource,
disabled,
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
@@ -215,10 +205,6 @@ function RdpServerForm({
{t("rdpServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
@@ -244,7 +230,6 @@ function RdpServerForm({
</Button>
</form>
</Form>
</fieldset>
</SettingsSection>
);
}
@@ -75,11 +75,7 @@ export default function SshSettingsPage(props: {
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
@@ -95,14 +91,10 @@ export default function SshSettingsPage(props: {
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
@@ -113,13 +105,11 @@ function SshServerForm({
orgId,
resource,
updateResource,
disabled,
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
@@ -375,10 +365,6 @@ function SshServerForm({
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
@@ -530,7 +516,6 @@ function SshServerForm({
</Button>
</form>
</Form>
</fieldset>
</SettingsSection>
);
}
@@ -55,11 +55,7 @@ export default function VncSettingsPage(props: {
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
@@ -75,14 +71,10 @@ export default function VncSettingsPage(props: {
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<VncServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
@@ -92,13 +84,11 @@ export default function VncSettingsPage(props: {
function VncServerForm({
orgId,
resource,
disabled,
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
@@ -215,10 +205,6 @@ function VncServerForm({
{t("vncServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
@@ -244,7 +230,6 @@ function VncServerForm({
</Button>
</form>
</Form>
</fieldset>
</SettingsSection>
);
}
@@ -18,9 +18,9 @@ import {
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
DescribedSelect,
type DescribedSelectOption
} from "@app/components/DescribedSelect";
import {
StrategySelect,
type StrategyOption
@@ -72,6 +72,10 @@ import {
LocalTarget,
ProxyResourceTargetsForm
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
import {
AiProvidersSelector,
type SelectedAiProvider
} from "@app/components/AiProvidersSelector";
import { AxiosResponse } from "axios";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -206,7 +210,7 @@ function createAddTargetSchema(t: TranslateFn) {
);
}
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference";
type CreateBgTargetFormValues = SshSettingsFormValues;
@@ -235,16 +239,11 @@ export default function Page() {
// Resource type state
const [resourceType, setResourceType] = useState<NewResourceType>("http");
const isBrowserGatewayType =
resourceType === "ssh" ||
resourceType === "rdp" ||
resourceType === "vnc";
const browserGatewayDisabled =
isBrowserGatewayType &&
!isPaidUser(tierMatrix[TierFeature.AdvancedPublicResources]);
// Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit)
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [selectedProviders, setSelectedProviders] = useState<
SelectedAiProvider[]
>([]);
// SSH-specific state
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
@@ -333,7 +332,7 @@ export default function Page() {
!env.flags.disableEnterpriseFeatures;
const availableTypes = useMemo((): NewResourceType[] => {
const base: NewResourceType[] = ["http"];
const base: NewResourceType[] = ["http", "inference"];
if (enterpriseModesAllowed) {
base.push("ssh", "rdp", "vnc");
}
@@ -478,29 +477,41 @@ export default function Page() {
? finalizeSubdomainSanitize(httpData.subdomain, true)
: undefined;
const effectiveMode = isNative
? "native"
: standardDaemonLocation;
const portVal = sshDaemonPortForm.getValues().authDaemonPort;
const effectivePort =
!isNative &&
standardDaemonLocation === "remote" &&
pamMode === "push" &&
portVal
? Number(portVal)
: undefined;
Object.assign(payload, {
subdomain: sanitizedSubdomain
? toASCII(sanitizedSubdomain)
: undefined,
domainId: httpData.domainId,
protocol: "tcp",
mode: resourceType,
pamMode,
authDaemonMode: effectiveMode,
authDaemonPort: effectivePort || undefined
mode: resourceType
});
if (resourceType === "inference") {
Object.assign(payload, {
aiProviders: selectedProviders.map((provider) => ({
providerId: parseInt(provider.id, 10)
}))
});
} else if (resourceType === "ssh") {
const effectiveMode = isNative
? "native"
: standardDaemonLocation;
const portVal =
sshDaemonPortForm.getValues().authDaemonPort;
const effectivePort =
!isNative &&
standardDaemonLocation === "remote" &&
pamMode === "push" &&
portVal
? Number(portVal)
: undefined;
Object.assign(payload, {
pamMode,
authDaemonMode: effectiveMode,
authDaemonPort: effectivePort || undefined
});
}
} else {
const tcpUdpData = tcpUdpForm.getValues();
Object.assign(payload, {
@@ -529,7 +540,11 @@ export default function Page() {
const newNiceId = res.data.data.niceId;
setNiceId(newNiceId);
if (resourceType === "http") {
if (resourceType === "inference") {
router.push(
`/${orgId}/settings/resources/public/${newNiceId}/general`
);
} else if (resourceType === "http") {
if (targets.length > 0) {
try {
for (const target of targets) {
@@ -752,25 +767,45 @@ export default function Page() {
}
];
let typeLabels: Partial<Record<NewResourceType, string>> = {
http: "HTTP",
tcp: "TCP",
udp: "UDP"
const typeMeta: Record<
NewResourceType,
{ title: string; description: string }
> = {
http: {
title: t("createInternalResourceDialogModeHttp"),
description: t("resourceTypeHttpDescription")
},
inference: {
title: t("createInternalResourceDialogModeInference"),
description: t("resourceTypeInferenceDescription")
},
ssh: {
title: t("createInternalResourceDialogModeSsh"),
description: t("resourceTypeSshDescription")
},
rdp: {
title: t("rdpTitle"),
description: t("resourceTypeRdpDescription")
},
vnc: {
title: t("vncTitle"),
description: t("resourceTypeVncDescription")
},
tcp: {
title: t("createInternalResourceDialogTcp"),
description: t("resourceTypeTcpDescription")
},
udp: {
title: t("createInternalResourceDialogUdp"),
description: t("resourceTypeUdpDescription")
}
};
if (enterpriseModesAllowed) {
typeLabels = {
...typeLabels,
ssh: "SSH",
rdp: "RDP",
vnc: "VNC",
};
}
const typeOptions: OptionSelectOption<NewResourceType>[] =
const typeOptions: DescribedSelectOption<NewResourceType>[] =
availableTypes.map((type) => ({
value: type,
label: typeLabels[type] ?? type.toUpperCase()
title: typeMeta[type].title,
description: typeMeta[type].description
}));
return (
@@ -807,6 +842,35 @@ export default function Page() {
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<div className="grid gap-2">
<Label>
{t("type")}
</Label>
<DescribedSelect<NewResourceType>
options={typeOptions}
value={resourceType}
onChange={
setResourceType
}
searchPlaceholder={t(
"resourceTypeSearch"
)}
emptyMessage={t(
"resourceTypeNotFound"
)}
placeholder={t(
"noneSelected"
)}
/>
<p className="text-muted-foreground text-sm">
{t(
"resourceTypeDescription"
)}
</p>
</div>
</SettingsFormCell>
<SettingsFormCell span="half">
<Form {...baseForm}>
<form
@@ -852,27 +916,6 @@ export default function Page() {
</Form>
</SettingsFormCell>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="text-sm font-medium">
{t("type")}
</p>
<OptionSelect<NewResourceType>
options={typeOptions}
value={resourceType}
onChange={
setResourceType
}
cols={6}
/>
<p className="text-sm text-muted-foreground">
{t(
"resourceTypeDescription"
)}
</p>
</div>
</SettingsFormCell>
{isHttpResource && (
<SettingsFormCell span="full">
<Form {...httpForm}>
@@ -885,7 +928,8 @@ export default function Page() {
<FormItem>
<DomainPicker
allowWildcard={
true
resourceType !==
"inference"
}
orgId={
orgId as string
@@ -1005,14 +1049,6 @@ export default function Page() {
{/* SSH Server Section */}
{resourceType === "ssh" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshServer")}
@@ -1021,14 +1057,7 @@ export default function Page() {
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
@@ -1267,21 +1296,12 @@ export default function Page() {
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
{/* RDP Server Section */}
{resourceType === "rdp" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("rdpServer")}
@@ -1290,14 +1310,6 @@ export default function Page() {
{t("rdpServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...bgTargetForm}>
@@ -1314,21 +1326,12 @@ export default function Page() {
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
{/* VNC Server Section */}
{resourceType === "vnc" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("vncServer")}
@@ -1337,14 +1340,7 @@ export default function Page() {
{t("vncServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...bgTargetForm}>
@@ -1361,7 +1357,6 @@ export default function Page() {
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
@@ -1376,6 +1371,51 @@ export default function Page() {
/>
)}
{resourceType === "inference" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("aiResourceProviders")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"aiResourceProvidersDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="full">
<div className="space-y-2">
<Label>
{t(
"aiResourceProviders"
)}
</Label>
<AiProvidersSelector
orgId={
orgId as string
}
selectedProviders={
selectedProviders
}
onSelectProviders={(
providers
) => {
setSelectedProviders(
providers
);
}}
/>
</div>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
@@ -1429,7 +1469,10 @@ export default function Page() {
}
}}
loading={createLoading}
disabled={!areAllTargetsValid() || browserGatewayDisabled || createLoading}
disabled={
!areAllTargetsValid() ||
createLoading
}
>
{t("resourceCreate")}
</Button>
@@ -5,6 +5,8 @@ import PublicResourcesBanner from "@app/components/PublicResourcesBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider";
import { build } from "@server/build";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import type { GetOrgResponse } from "@server/routers/org";
import type { ListResourcesResponse } from "@server/routers/resource";
import { GetSiteResponse } from "@server/routers/site/getSite";
@@ -38,6 +40,7 @@ export default async function ProxyResourcesPage(
const params = await props.params;
const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let resources: ListResourcesResponse["resources"] = [];
let pagination: ListResourcesResponse["pagination"] = {
@@ -59,29 +62,7 @@ export default async function ProxyResourcesPage(
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 {
@@ -139,6 +120,34 @@ export default async function ProxyResourcesPage(
health: (resource.health as ResourceRow["health"]) ?? undefined
};
});
// Prefetched in one batched call so the table doesn't fire a separate
// certificate request per visible row once it mounts on the client.
const certDomains = Array.from(
new Set(
resourceRows
.filter((r) => r.ssl && r.fullDomain)
.map((r) => r.fullDomain as string)
)
);
let initialCertificates: GetBatchedCertificateResponse | undefined;
if (build !== "oss" && certDomains.length > 0) {
try {
const certSearchParams = new URLSearchParams(
certDomains.map((domain) => ["domains", domain])
);
const certRes = await internal.get<
AxiosResponse<GetBatchedCertificateResponse>
>(
`/org/${params.orgId}/batched-certificates?${certSearchParams.toString()}`,
await authCookieHeader()
);
initialCertificates = certRes.data.data;
} catch {
// leave undefined so each row falls back to fetching its own
}
}
return (
<>
<SettingsSectionTitle
@@ -157,7 +166,7 @@ export default async function ProxyResourcesPage(
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialFilterSite={initialFilterSite}
initialCertificates={initialCertificates}
/>
</OrgProvider>
</>
@@ -40,6 +40,8 @@ import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { AxiosResponse } from "axios";
import { useQuery } from "@tanstack/react-query";
import { productUpdatesQueries } from "@app/lib/queries";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -67,6 +69,11 @@ export default function CredentialsPage() {
const { isPaidUser } = usePaidStatus();
const { data: latestVersions } = useQuery(
productUpdatesQueries.latestVersion(true)
);
const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest";
// Fetch site defaults for wireguard sites to show in obfuscated config
useEffect(() => {
const fetchSiteDefaults = async () => {
@@ -302,6 +309,7 @@ export default function CredentialsPage() {
id={displayNewtId ?? "**********"}
secret={displaySecret ?? "**************"}
endpoint={env.app.dashboardUrl}
version={newtVersion}
/>
</>
)}
@@ -345,7 +353,7 @@ export default function CredentialsPage() {
text={generateObfuscatedWireGuardConfig(
{
subnet:
site?.subnet ||
site?.exitNodeSubnet ||
siteDefaults?.subnet ||
null,
address:
+8 -40
View File
@@ -56,6 +56,8 @@ import { QRCodeCanvas } from "qrcode.react";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
import { useQuery } from "@tanstack/react-query";
import { productUpdatesQueries } from "@app/lib/queries";
type SiteType = "newt" | "wireguard" | "local";
@@ -189,9 +191,14 @@ export default function Page() {
const [wgConfig, setWgConfig] = useState("");
const [createLoading, setCreateLoading] = useState(false);
const [newtVersion, setNewtVersion] = useState("latest");
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const { data: latestVersions } = useQuery(
productUpdatesQueries.latestVersion(true)
);
const newtVersion =
latestVersions?.data?.newt?.latestVersion ?? "latest";
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
@@ -302,45 +309,6 @@ export default function Page() {
const load = async () => {
setLoadingPage(true);
let currentNewtVersion = "latest";
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(
`https://api.github.com/repos/fosrl/newt/releases/latest`,
{ signal: controller.signal }
);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
t("newtErrorFetchReleases", {
err: response.statusText
})
);
}
const data = await response.json();
const latestVersion = data.tag_name;
currentNewtVersion = latestVersion;
setNewtVersion(latestVersion);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.error(t("newtErrorFetchTimeout"));
} else {
console.error(
t("newtErrorFetchLatest", {
err:
error instanceof Error
? error.message
: String(error)
})
);
}
}
const generatedKeypair = generateKeypair();
const privateKey = generatedKeypair.privateKey;
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import IdentityKeysSplash from "@app/components/IdentityKeysSplash";
export const metadata: Metadata = {
title: "Identity Keys"
};
type IdentityKeysPageProps = {
params: Promise<{ orgId: string }>;
};
export default async function IdentityKeysPage(props: IdentityKeysPageProps) {
const params = await props.params;
return <IdentityKeysSplash orgId={params.orgId} />;
}
@@ -0,0 +1,142 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import VirtualApiKeysTable, {
type VirtualApiKeyRow
} from "@app/components/VirtualApiKeysTable";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import type { ListVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type { ListUsersResponse } from "@server/routers/user";
import type { ListResourcesResponse } from "@server/routers/resource";
export const metadata: Metadata = {
title: "Virtual Keys"
};
type VirtualApiKeysTablePageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function VirtualApiKeysTablePage(
props: VirtualApiKeysTablePageProps
) {
const params = await props.params;
const cookieHeader = await authCookieHeader();
const t = await getTranslations();
let keys: ListVirtualApiKeysResponse["virtualApiKeys"] = [];
let users: {
userId: string;
email: string | null;
name: string | null;
username: string | null;
}[] = [];
let resources: {
resourceId: number;
name: string;
niceId: string;
}[] = [];
try {
const [keysRes, usersRes, resourcesRes] = await Promise.all([
internal.get<AxiosResponse<ListVirtualApiKeysResponse>>(
`/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`,
cookieHeader
),
internal.get<AxiosResponse<ListUsersResponse>>(
`/org/${params.orgId}/users?page=1&pageSize=1000`,
cookieHeader
),
internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources?page=1&pageSize=1000`,
cookieHeader
)
]);
keys = keysRes.data.data.virtualApiKeys ?? [];
users = (usersRes.data.data.users ?? []).map((u) => ({
userId: u.id,
email: u.email ?? null,
name: u.name ?? null,
username: u.username ?? null
}));
resources = (resourcesRes.data.data.resources ?? []).map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId
}));
} catch {
// leave empty; page still renders
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
cookieHeader
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const userById = new Map(users.map((u) => [u.userId, u]));
const resourceById = new Map(resources.map((r) => [r.resourceId, r]));
const rows: VirtualApiKeyRow[] = keys.map((key) => {
const user = key.userId ? userById.get(key.userId) : undefined;
const keyResources = key.resourceIds
.map((id) => resourceById.get(id))
.filter(Boolean) as {
resourceId: number;
name: string;
niceId: string;
}[];
const resourceNames = key.allResources
? t("virtualApiKeysAllResources")
: keyResources.map((r) => r.name).join(", ") ||
t("virtualApiKeysNoResources");
return {
virtualApiKeyId: key.virtualApiKeyId,
orgId: key.orgId,
kind: key.kind,
userId: key.userId,
name: key.name,
description: key.description,
lastChars: key.lastChars,
allResources: key.allResources,
expiresAt: key.expiresAt,
lastUsedAt: key.lastUsedAt,
createdAt: key.createdAt,
createdByUserId: key.createdByUserId,
resourceIds: key.resourceIds,
userName: user?.name ?? null,
username: user?.username ?? null,
userEmail: user?.email ?? null,
resourceNames,
resources: keyResources
};
});
return (
<OrgProvider org={org}>
<VirtualApiKeysTable virtualApiKeys={rows} orgId={params.orgId} />
</OrgProvider>
);
}
@@ -0,0 +1,37 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
type VirtualApiKeysListLayoutProps = {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
};
export default async function VirtualApiKeysListLayout({
children,
params
}: VirtualApiKeysListLayoutProps) {
const { orgId } = await params;
const t = await getTranslations();
const navItems = [
{
title: t("virtualApiKeysTabIdentity"),
href: `/${orgId}/settings/virtual-api-keys/identity`
},
{
title: t("virtualApiKeysTabVirtual"),
href: `/${orgId}/settings/virtual-api-keys/keys`
}
];
return (
<>
<SettingsSectionTitle
title={t("virtualApiKeysTitle")}
description={t("virtualApiKeysDescription")}
/>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</>
);
}
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Virtual API Keys"
};
type VirtualApiKeysIndexPageProps = {
params: Promise<{ orgId: string }>;
};
export default async function VirtualApiKeysIndexPage(
props: VirtualApiKeysIndexPageProps
) {
const params = await props.params;
redirect(`/${params.orgId}/settings/virtual-api-keys/identity`);
}
+45 -8
View File
@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { LoginFormIDP } from "@app/components/LoginForm";
import { ListIdpsResponse } from "@server/routers/idp";
import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import z from "zod";
export const metadata: Metadata = {
title: "Log In"
@@ -29,8 +32,9 @@ export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
const user = await verifySession({ skipCheckVerifyEmail: true });
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin;
@@ -85,19 +89,48 @@ export default async function Page(props: {
(build === "enterprise" && env.app.identityProviderMode === "org");
let loginIdps: LoginFormIDP[] = [];
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
null;
if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || env.app.identityProviderMode !== "org") {
const idpsRes = await cache(
async () =>
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
const idpsRes =
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.type
})) as LoginFormIDP[];
}
} else {
if (lastUsedIdpCookie) {
const lastUsedIdpSchema = z.object({
orgId: z.string().optional(),
idpId: z.number()
});
try {
const persistedData = lastUsedIdpSchema.parse(
JSON.parse(lastUsedIdpCookie.value)
);
const idpRes = await priv.get<AxiosResponse<GetIdpResponse>>(
`/idp/${persistedData.idpId}`
);
const res = idpRes.data.data;
lastUsedIdpForSmartLogin = {
idpId: res.idp.idpId,
name: res.idp.name,
variant: res.idpOidcConfig?.variant ?? res.idp.type,
orgId: persistedData.orgId,
lastUsed: true
};
} catch (error) {
// the idp might not exist or the data is malformatted, skip this
}
}
}
const t = await getTranslations();
@@ -160,6 +193,10 @@ export default async function Page(props: {
redirect={redirectUrl}
forceLogin={forceLogin}
defaultUser={defaultUser}
inviteMode={isInvite}
lastUsedIdp={
isInvite ? null : lastUsedIdpForSmartLogin
}
orgSignIn={
!isInvite &&
(build === "saas" ||
@@ -179,7 +216,7 @@ export default async function Page(props: {
) : (
<DashboardLoginForm
redirect={redirectUrl}
idps={loginIdps}
idps={isInvite ? [] : loginIdps}
forceLogin={forceLogin}
showOrgLogin={
!isInvite &&
+17 -8
View File
@@ -13,6 +13,8 @@ import { redirect } from "next/navigation";
import OrgLoginPage from "@app/components/OrgLoginPage";
import { pullEnv } from "@app/lib/pullEnv";
import type { Metadata } from "next";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
export const metadata: Metadata = {
title: "Organization Login"
@@ -68,15 +70,22 @@ export default async function OrgAuthPage(props: {
variant: idp.variant
})) as LoginFormIDP[];
const hasLoginPageBranding = await isOrgSubscribed(
orgId,
tierMatrix.loginPageBranding
);
let branding: LoadLoginPageBrandingResponse | null = null;
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
} catch (error) {}
if (hasLoginPageBranding) {
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
} catch (error) {}
}
return (
<OrgLoginPage
+5 -1
View File
@@ -19,6 +19,7 @@ import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
import OrgLoginPage from "@app/components/OrgLoginPage";
import type { Metadata } from "next";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export const metadata: Metadata = {
title: "Choose Organization"
@@ -83,7 +84,10 @@ export default async function OrgAuthPage(props: {
redirect(env.app.dashboardUrl);
}
const subscribed = await isOrgSubscribed(loginPage.orgId);
const subscribed = await isOrgSubscribed(
loginPage.orgId,
tierMatrix.loginPageDomain
);
if (build === "saas" && !subscribed) {
console.log(
+43 -12
View File
@@ -27,6 +27,7 @@ import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next";
export const metadata: Metadata = {
@@ -70,14 +71,28 @@ export default async function ResourceAuthPage(props: {
);
}
const subscribed = await isOrgSubscribed(authInfo.orgId);
const isInference = authInfo.mode === "inference";
const keysPath = `/${authInfo.orgId}/resource/${authInfo.resourceGuid}/keys`;
const hasLoginPageDomain = await isOrgSubscribed(
authInfo.orgId,
tierMatrix.loginPageDomain
);
const hasOrgOidc = await isOrgSubscribed(
authInfo.orgId,
tierMatrix.orgOidc
);
const hasLoginPageBranding = await isOrgSubscribed(
authInfo.orgId,
tierMatrix.loginPageBranding
);
const allHeaders = await headers();
const host = allHeaders.get("host");
const expectedHost = env.app.dashboardUrl.split("//")[1];
if (host !== expectedHost) {
if (build === "saas" && !subscribed) {
if (build === "saas" && !hasLoginPageDomain) {
redirect(env.app.dashboardUrl);
}
@@ -106,7 +121,10 @@ export default async function ResourceAuthPage(props: {
const redirectPort = new URL(searchParams.redirect).port;
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => {
const wildcardMatchesRedirect = (
wildcardDomain: string,
host: string
): boolean => {
if (!wildcardDomain.startsWith("*.")) return false;
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
return host.endsWith(suffix) && host.length > suffix.length;
@@ -144,7 +162,9 @@ export default async function ResourceAuthPage(props: {
if (user && !user.emailVerified && env.flags.emailVerificationRequired) {
redirect(
`/auth/verify-email?redirect=/auth/resource/${authInfo.resourceGuid}`
`/auth/verify-email?redirect=${encodeURIComponent(
`/auth/resource/${authInfo.resourceGuid}`
)}`
);
}
@@ -178,6 +198,20 @@ export default async function ResourceAuthPage(props: {
);
}
// Inference resources never establish a resource session on the inference
// host. Authenticated users retrieve their virtual API key on the dashboard.
if (isInference && user) {
if (host !== expectedHost) {
redirect(`/auth/org?redirect=${encodeURIComponent(keysPath)}`);
} else {
redirect(keysPath);
}
}
// After password/pincode/SSO, do not send the browser back to the
// inference host (session alone cannot pass Badger). Land on keys instead.
const postAuthRedirect = isInference ? keysPath : redirectUrl;
if (!hasAuth) {
// no authentication so always go straight to the resource
redirect(redirectUrl);
@@ -218,17 +252,14 @@ export default async function ResourceAuthPage(props: {
if (searchParams.token) {
return (
<div className="w-full max-w-md">
<AccessToken
token={searchParams.token}
resourceId={authInfo.resourceId}
/>
<AccessToken token={searchParams.token} />
</div>
);
}
let loginIdps: LoginFormIDP[] = [];
if (build === "saas" || env.app.identityProviderMode === "org") {
if (subscribed) {
if (hasOrgOidc) {
const idpsRes = await cache(
async () =>
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
@@ -262,7 +293,7 @@ export default async function ResourceAuthPage(props: {
<AutoLoginHandler
resourceId={authInfo.resourceId}
skipToIdpId={authInfo.skipToIdpId}
redirectUrl={redirectUrl}
redirectUrl={postAuthRedirect}
orgId={build === "saas" ? authInfo.orgId : undefined}
/>
);
@@ -271,7 +302,7 @@ export default async function ResourceAuthPage(props: {
let branding: LoadLoginPageBrandingResponse | null = null;
try {
if (subscribed) {
if (hasLoginPageBranding) {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${authInfo.orgId}`);
@@ -300,7 +331,7 @@ export default async function ResourceAuthPage(props: {
name: authInfo.resourceName,
id: authInfo.resourceId
}}
redirect={redirectUrl}
redirect={postAuthRedirect}
idps={loginIdps}
orgId={build === "saas" ? authInfo.orgId : undefined}
branding={
+84 -1
View File
@@ -3,10 +3,12 @@ import { Env } from "@app/lib/types/env";
import { build } from "@server/build";
import {
BellRing,
Bot,
Boxes,
Building2,
Cable,
ChartLine,
Coins,
Combine,
CreditCard,
Fingerprint,
@@ -18,6 +20,8 @@ import {
LayoutGrid,
Link as LinkIcon,
Logs,
MessageSquare,
MessagesSquare,
MonitorUp,
Plug,
ReceiptText,
@@ -25,11 +29,13 @@ import {
Server,
Settings,
ShieldIcon,
Sparkles,
SquareMousePointer,
TagIcon,
TicketCheck,
Unplug,
User,
UserCheck,
UserCog,
Users,
Waypoints
@@ -43,6 +49,7 @@ export type SidebarNavSection = {
export type OrgNavSectionsOptions = {
isPrimaryOrg?: boolean;
isServerAdmin?: boolean;
};
// Merged from 'user-management-and-resources' branch
@@ -51,6 +58,11 @@ export const orgLangingNavItems: SidebarNavItem[] = [
title: "sidebarAccount",
href: "/{orgId}",
icon: <LayoutGrid className="size-4 flex-none" />
},
{
title: "sidebarMyApiKeys",
href: "/{orgId}/keys",
icon: <KeyRound className="size-4 flex-none" />
}
];
@@ -58,6 +70,27 @@ export const orgNavSections = (
env?: Env,
options?: OrgNavSectionsOptions
): SidebarNavSection[] => [
{
heading: "sidebarOverview",
items: [
{
title: "resourceSidebarLauncherTitle",
href: "/{orgId}",
icon: <LayoutGrid className="size-4 flex-none" />,
exact: true
},
...(options?.isServerAdmin
? [
{
title: "serverAdmin",
href: "/admin",
icon: <Server className="size-4 flex-none" />,
exact: true
}
]
: [])
]
},
{
heading: "network",
items: [
@@ -175,7 +208,7 @@ export const orgNavSections = (
{
title: "sidebarApprovals",
href: "/{orgId}/settings/access/approvals",
icon: <UserCog className="size-4 flex-none" />
icon: <UserCheck className="size-4 flex-none" />
}
]
: []),
@@ -186,6 +219,31 @@ export const orgNavSections = (
}
]
},
{
heading: "sidebarAiGateway",
items: [
{
title: "sidebarAiProviders",
href: "/{orgId}/settings/ai-providers",
icon: <Sparkles className="size-4 flex-none" />
},
{
title: "sidebarVirtualApiKeys",
href: "/{orgId}/settings/virtual-api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
{
title: "sidebarLogsAi",
href: "/{orgId}/settings/logs/ai",
icon: <MessagesSquare className="size-4 flex-none" />
},
{
title: "sidebarLogsAiUsage",
href: "/{orgId}/settings/logs/ai-usage",
icon: <Coins className="size-4 flex-none" />
}
]
},
{
heading: "sidebarOrganization",
items: [
@@ -471,6 +529,21 @@ export const commandBarNavSections = (
}
]
},
{
heading: "sidebarAiGateway",
items: [
{
title: "commandAiProviders",
href: "/{orgId}/settings/ai-providers",
icon: <Sparkles className="size-4 flex-none" />
},
{
title: "commandVirtualApiKeys",
href: "/{orgId}/settings/virtual-api-keys",
icon: <KeyRound className="size-4 flex-none" />
}
]
},
{
heading: "commandLogsAndAnalytics",
items: [
@@ -484,6 +557,16 @@ export const commandBarNavSections = (
href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="size-4 flex-none" />
},
{
title: "commandLogsAi",
href: "/{orgId}/settings/logs/ai",
icon: <Bot className="size-4 flex-none" />
},
{
title: "commandLogsAiUsage",
href: "/{orgId}/settings/logs/ai-usage",
icon: <Coins className="size-4 flex-none" />
},
...(!env?.flags.disableEnterpriseFeatures
? [
{
+9 -7
View File
@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
import RedirectToOrg from "@app/components/RedirectToOrg";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers";
import { build } from "@server/build";
export const dynamic = "force-dynamic";
@@ -29,17 +27,21 @@ export default async function Page(props: {
const env = pullEnv();
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
const user = await verifySession({ skipCheckVerifyEmail: true });
let complete = false;
let complete: boolean | null = null; // null means "unknown" (request errored)
try {
const setupRes = await internal.get<
AxiosResponse<InitialSetupCompleteResponse>
>(`/auth/initial-setup-complete`, await authCookieHeader());
complete = setupRes.data.data.complete;
} catch (e) {}
if (!complete) {
} catch (e) {
// Swallow errors (e.g. 429 rate limit, 500, network failure).
// Only redirect to initial-setup when the server *confirms* setup
// is incomplete (complete === false). If the request itself failed we
// cannot tell, so fall through to the login redirect instead.
}
if (complete === false) {
redirect("/auth/initial-setup");
}