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
+33
View File
@@ -248,6 +248,39 @@ export async function loginProxy(
return await makeApiRequest<LoginResponse>(url, "POST", request);
}
export async function logoutProxy(): Promise<ResponseT<null>> {
const env = pullEnv();
const serverPort = process.env.SERVER_EXTERNAL_PORT;
const url = `http://localhost:${serverPort}/api/v1/auth/logout`;
const result = await makeApiRequest<null>(url, "POST");
try {
const headersList = await reqHeaders();
const host = headersList.get("host")?.split(":")[0];
const allCookies = await cookies();
const clearOptions = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/",
maxAge: 0
};
// Clear both host-only and domain-scoped variants.
allCookies.set(env.server.sessionCookieName, "", clearOptions);
if (host) {
allCookies.set(env.server.sessionCookieName, "", {
...clearOptions,
domain: host
});
}
} catch (cookieError) {
console.error("Failed to clear session cookie:", cookieError);
}
return result;
}
export async function securityKeyStartProxy(
request: SecurityKeyStartRequest,
forceLogin?: boolean
+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}
@@ -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,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>
</>
@@ -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");
}
+4 -33
View File
@@ -17,10 +17,9 @@ import { useTranslations } from "next-intl";
type AccessTokenProps = {
token: string;
resourceId?: number;
};
export default function AccessToken({ token, resourceId }: AccessTokenProps) {
export default function AccessToken({ token }: AccessTokenProps) {
const [loading, setLoading] = useState(true);
const [isValid, setIsValid] = useState(false);
@@ -59,13 +58,13 @@ export default function AccessToken({ token, resourceId }: AccessTokenProps) {
return;
}
async function checkSHA256() {
async function check() {
try {
const res = await api.post<
AxiosResponse<AuthWithAccessTokenResponse>
>(`/auth/access-token`, {
accessToken,
accessTokenId
accessTokenId: accessTokenId || undefined
});
if (res.data.data.session) {
@@ -82,35 +81,7 @@ export default function AccessToken({ token, resourceId }: AccessTokenProps) {
}
}
async function check() {
try {
const res = await api.post<
AxiosResponse<AuthWithAccessTokenResponse>
>(`/auth/resource/${resourceId}/access-token`, {
accessToken,
accessTokenId
});
if (res.data.data.session) {
setIsValid(true);
window.location.href = appendRequestToken(
res.data.data.redirectUrl!,
res.data.data.session
);
}
} catch (e) {
console.error(t("accessTokenError"), e);
} finally {
setLoading(false);
}
}
if (!accessTokenId) {
// no access token id so check the sha256
checkSHA256();
} else {
check();
}
check();
}, [token]);
function renderTitle() {
+604
View File
@@ -0,0 +1,604 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { type TagValue } from "@app/components/multi-select/multi-select-content";
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Switch } from "@app/components/ui/switch";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { cn } from "@app/lib/cn";
import { aiProviderQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Plus, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export type AiProviderAttachmentValue = {
providerId: number;
niceId: string;
name: string;
accessMode: "inherit" | "select";
enabled: boolean;
selectedModelIds: number[];
};
export type AiProviderAttachmentsProps = {
orgId: string;
value: AiProviderAttachmentValue[];
onChange: (value: AiProviderAttachmentValue[]) => void;
disabled?: boolean;
};
export function AiProviderAttachments({
orgId,
value,
onChange,
disabled
}: AiProviderAttachmentsProps) {
const t = useTranslations();
const [editingProviderId, setEditingProviderId] = useState<number | null>(
null
);
const { data: providers = [] } = useQuery(
aiProviderQueries.orgProviders({ orgId })
);
const attachedIds = useMemo(
() => new Set(value.map((v) => v.providerId)),
[value]
);
const availableProviders = providers
.filter((provider) => provider.enabled)
.filter((provider) => !attachedIds.has(provider.providerId));
const editing = value.find((v) => v.providerId === editingProviderId);
function addProvider(providerId: number, niceId: string, name: string) {
if (value.some((v) => v.providerId === providerId)) {
return;
}
onChange([
...value,
{
providerId,
niceId,
name,
accessMode: "inherit",
enabled: true,
selectedModelIds: []
}
]);
}
function removeProvider(providerId: number) {
onChange(value.filter((v) => v.providerId !== providerId));
}
function updateProvider(updated: AiProviderAttachmentValue) {
onChange(
value.map((v) =>
v.providerId === updated.providerId ? updated : v
)
);
setEditingProviderId(null);
}
return (
<div className="flex flex-col gap-3">
{value.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("aiResourceProvidersNoneAttached")}
</p>
) : (
<div className="flex flex-col gap-2">
{value.map((attachment) => (
<AttachmentRow
key={attachment.providerId}
attachment={attachment}
disabled={disabled}
onEdit={() =>
setEditingProviderId(attachment.providerId)
}
onRemove={() =>
removeProvider(attachment.providerId)
}
onToggleEnabled={(enabled) => {
onChange(
value.map((v) =>
v.providerId === attachment.providerId
? { ...v, enabled }
: v
)
);
}}
/>
))}
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
disabled={disabled || availableProviders.length === 0}
>
<Plus className="size-4" />
{t("aiResourceProvidersAdd")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{availableProviders.map((provider) => (
<DropdownMenuItem
key={provider.providerId}
onSelect={() =>
addProvider(
provider.providerId,
provider.niceId,
provider.name
)
}
>
{provider.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{editing && (
<EditAttachmentCredenza
orgId={orgId}
attachment={editing}
open={editingProviderId !== null}
onOpenChange={(open) => {
if (!open) setEditingProviderId(null);
}}
onSave={updateProvider}
/>
)}
</div>
);
}
function AttachmentRow({
attachment,
disabled,
onEdit,
onRemove,
onToggleEnabled
}: {
attachment: AiProviderAttachmentValue;
disabled?: boolean;
onEdit: () => void;
onRemove: () => void;
onToggleEnabled: (enabled: boolean) => void;
}) {
const t = useTranslations();
const summary =
attachment.accessMode === "inherit"
? t("aiResourceProviderModeInherit")
: t("aiResourceProviderModeSelectSummary", {
count: attachment.selectedModelIds.length
});
return (
<div
className={cn(
"flex items-center gap-3 rounded-md border border-input p-3 min-w-0",
(disabled || !attachment.enabled) && "opacity-60",
!disabled && "cursor-pointer hover:bg-muted/50"
)}
onClick={disabled ? undefined : onEdit}
onKeyDown={
disabled
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onEdit();
}
}
}
role={disabled ? undefined : "button"}
tabIndex={disabled ? undefined : 0}
>
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium truncate">
{attachment.name}
</span>
<p className="truncate text-sm text-muted-foreground">
{attachment.enabled
? summary
: t("aiResourceProviderDisabled")}
</p>
</div>
<div
className="flex shrink-0 items-center gap-2"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Button
type="button"
variant="text"
size="sm"
className="h-auto px-0"
disabled={disabled}
onClick={onEdit}
>
{t("edit")}
</Button>
<button
type="button"
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
disabled={disabled}
aria-label={t("aiResourceProvidersRemove")}
onClick={onRemove}
>
<XIcon className="size-4" />
</button>
<Switch
checked={attachment.enabled}
disabled={disabled}
aria-label={t("aiResourceProviderToggleEnabled")}
onCheckedChange={onToggleEnabled}
/>
</div>
</div>
);
}
type EditFormValues = {
accessMode: "inherit" | "select";
selectedModels: TagValue[];
};
function EditAttachmentCredenza({
orgId,
attachment,
open,
onOpenChange,
onSave
}: {
orgId: string;
attachment: AiProviderAttachmentValue;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (value: AiProviderAttachmentValue) => void;
}) {
const t = useTranslations();
const [modelSearch, setModelSearch] = useState("");
const editSchema = useMemo(
() =>
z.object({
accessMode: z.enum(["inherit", "select"]),
selectedModels: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
}),
[]
);
const modelsQuery = useQuery({
...aiProviderQueries.providerModels({
providerId: attachment.providerId
}),
enabled: open
});
const allowCatalog = useMemo(() => {
const models = modelsQuery.data ?? [];
return models.filter(
(model) => model.enabled && (model.listType ?? "allow") === "allow"
);
}, [modelsQuery.data]);
const allowOptions: TagValue[] = useMemo(() => {
const query = modelSearch.trim().toLowerCase();
return allowCatalog
.filter((model) => {
if (!query) return true;
return (
model.modelKey.toLowerCase().includes(query) ||
model.name.toLowerCase().includes(query)
);
})
.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}));
}, [allowCatalog, modelSearch]);
const form = useForm<EditFormValues>({
resolver: zodResolver(editSchema),
defaultValues: {
accessMode: attachment.accessMode,
selectedModels: []
}
});
const accessMode = form.watch("accessMode");
const pendingSeedRef = useRef(false);
useEffect(() => {
if (!open) return;
form.reset({
accessMode: attachment.accessMode,
selectedModels: attachment.selectedModelIds.map((modelId) => {
const catalog = (modelsQuery.data ?? []).find(
(model) => model.modelId === modelId
);
return {
id: String(modelId),
text: catalog?.modelKey ?? String(modelId)
};
})
});
setModelSearch("");
pendingSeedRef.current = false;
// Only re-init when opening or switching which attachment is edited.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, attachment.providerId]);
useEffect(() => {
if (!open || allowCatalog.length === 0) return;
const current = form.getValues("selectedModels");
const upgraded = current.map((model) => {
const catalog = allowCatalog.find(
(entry) => String(entry.modelId) === model.id
);
return catalog ? { id: model.id, text: catalog.modelKey } : model;
});
const changed = upgraded.some(
(model, index) => model.text !== current[index]?.text
);
if (changed) {
form.setValue("selectedModels", upgraded);
}
if (pendingSeedRef.current) {
form.setValue(
"selectedModels",
allowCatalog.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}))
);
pendingSeedRef.current = false;
}
}, [open, allowCatalog, form]);
function handleAccessModeChange(next: "inherit" | "select") {
form.setValue("accessMode", next);
if (next === "inherit") {
form.setValue("selectedModels", []);
pendingSeedRef.current = false;
return;
}
if (attachment.accessMode === "select") {
form.setValue(
"selectedModels",
attachment.selectedModelIds.map((modelId) => {
const catalog = allowCatalog.find(
(model) => model.modelId === modelId
);
return {
id: String(modelId),
text: catalog?.modelKey ?? String(modelId)
};
})
);
pendingSeedRef.current = false;
return;
}
if (allowCatalog.length > 0) {
form.setValue(
"selectedModels",
allowCatalog.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}))
);
pendingSeedRef.current = false;
return;
}
form.setValue("selectedModels", []);
pendingSeedRef.current = true;
}
function onSubmit(values: EditFormValues) {
onSave({
providerId: attachment.providerId,
niceId: attachment.niceId,
name: attachment.name,
accessMode: values.accessMode,
enabled: attachment.enabled,
selectedModelIds:
values.accessMode === "select"
? values.selectedModels.map((model) =>
parseInt(model.id, 10)
)
: []
});
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{attachment.name}</CredenzaTitle>
<CredenzaDescription>
{t("aiResourceProviderEditDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
id="ai-provider-attachment-edit-form"
className="space-y-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="accessMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiResourceProviderMode")}
</FormLabel>
<Select
value={field.value}
onValueChange={(value) =>
handleAccessModeChange(
value as
| "inherit"
| "select"
)
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="inherit">
{t(
"aiResourceProviderModeInherit"
)}
</SelectItem>
<SelectItem value="select">
{t(
"aiResourceProviderModeSelect"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{field.value === "inherit"
? t(
"aiResourceProviderModeInheritHelp"
)
: t(
"aiResourceProviderModeSelectHelp"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{accessMode === "select" && (
<FormField
control={form.control}
name="selectedModels"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiResourceProviderAllowModels"
)}
</FormLabel>
<FormControl>
<MultiSelectTagInput
buttonText={t(
"aiResourceProviderAllowModelsSelect"
)}
emptyPlaceholder={t(
"aiResourceProviderAllowModelsEmpty"
)}
searchPlaceholder={t(
"aiResourceProviderAllowModelsSearch"
)}
searchQuery={modelSearch}
options={allowOptions}
value={field.value}
onChange={field.onChange}
onSearch={setModelSearch}
disabled={
modelsQuery.isLoading
}
/>
</FormControl>
<FormDescription>
{t(
"aiResourceProviderAllowModelsHelp"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
variant="link"
size="sm"
className="mr-auto px-0"
asChild
>
<Link
href={`/${orgId}/settings/ai-providers/${attachment.niceId}`}
>
{t("viewProviderSettings")}
</Link>
</Button>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="ai-provider-attachment-edit-form"
>
{t("done")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import {
AI_PROVIDER_AUTH_TYPES,
type AiProviderAuthType
} from "@app/lib/aiProviderDefaults";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
const authLabelMap = {
bearer: "aiProviderAuthTypeBearer",
"x-api-key": "aiProviderAuthTypeXApiKey",
"x-goog-api-key": "aiProviderAuthTypeXGoogApiKey",
hec: "aiProviderAuthTypeHec",
"cf-aig-authorization": "aiProviderAuthTypeCfAigAuthorization",
none: "aiProviderAuthTypeNone",
passthrough: "aiProviderAuthTypePassthrough"
} as const;
const authDescriptionMap = {
bearer: "aiProviderAuthTypeBearerDescription",
"x-api-key": "aiProviderAuthTypeXApiKeyDescription",
"x-goog-api-key": "aiProviderAuthTypeXGoogApiKeyDescription",
hec: "aiProviderAuthTypeHecDescription",
"cf-aig-authorization": "aiProviderAuthTypeCfAigAuthorizationDescription",
none: "aiProviderAuthTypeNoneDescription",
passthrough: "aiProviderAuthTypePassthroughDescription"
} as const;
type AiProviderAuthTypeSelectProps = {
value: AiProviderAuthType;
onChange: (value: AiProviderAuthType) => void;
disabled?: boolean;
className?: string;
};
export function AiProviderAuthTypeSelect({
value,
onChange,
disabled,
className
}: AiProviderAuthTypeSelectProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const options = useMemo(
() =>
AI_PROVIDER_AUTH_TYPES.map((authType) => ({
authType,
title: t(authLabelMap[authType]),
description: t(authDescriptionMap[authType])
})),
[t]
);
const selected = options.find((option) => option.authType === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between",
!selected && "text-muted-foreground",
className
)}
>
<span className="truncate text-left">
{selected?.title ?? t("noneSelected")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder={t("aiProviderAuthTypeSearch")} />
<CommandList>
<CommandEmpty>
{t("aiProviderAuthTypeNotFound")}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.authType}
value={`${option.authType} ${option.title} ${option.description}`}
onSelect={() => {
onChange(option.authType);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
option.authType === value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
{option.title}
</span>
<span className="text-muted-foreground text-xs leading-snug">
{option.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,90 @@
"use client";
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
export type CapabilityOption = {
id: string;
text: string;
};
export type AiProviderCapabilitiesSelectProps = {
value: AiCapability[];
onChange: (capabilities: AiCapability[]) => void;
disabled?: boolean;
};
const CAPABILITY_LABEL_KEYS: Record<AiCapability, string> = {
openai_chat: "aiCapabilityOpenaiChat",
openai_responses: "aiCapabilityOpenaiResponses",
anthropic_messages: "aiCapabilityAnthropicMessages",
v1_models: "aiCapabilityV1Models",
gemini_generate_content: "aiCapabilityGeminiGenerateContent",
bedrock_model_invoke: "aiCapabilityBedrockModelInvoke",
google_generate_content: "aiCapabilityGoogleGenerateContent",
google_raw_predict: "aiCapabilityGoogleRawPredict",
bedrock_converse: "aiCapabilityBedrockConverse"
};
export function capabilityLabelKey(capability: AiCapability): string {
return CAPABILITY_LABEL_KEYS[capability];
}
export function AiProviderCapabilitiesSelect({
value,
onChange,
disabled
}: AiProviderCapabilitiesSelectProps) {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState("");
const options: CapabilityOption[] = useMemo(
() =>
AI_CAPABILITIES.map((id) => ({
id,
text: t(CAPABILITY_LABEL_KEYS[id])
})),
[t]
);
const filtered = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) {
return options;
}
return options.filter(
(o) =>
o.text.toLowerCase().includes(q) ||
o.id.toLowerCase().includes(q)
);
}, [options, searchQuery]);
const selected: CapabilityOption[] = value.map((id) => ({
id,
text: t(CAPABILITY_LABEL_KEYS[id])
}));
return (
<MultiSelectTagInput
buttonText={t("aiProviderCapabilitiesSelect")}
emptyPlaceholder={t("aiProviderCapabilitiesEmpty")}
searchPlaceholder={t("aiProviderCapabilitiesSearch")}
searchQuery={searchQuery}
options={filtered}
value={selected}
onChange={(next) =>
onChange(
next
.map((item) => item.id)
.filter((id): id is AiCapability =>
(AI_CAPABILITIES as readonly string[]).includes(id)
)
)
}
onSearch={setSearchQuery}
disabled={disabled}
/>
);
}
@@ -0,0 +1,933 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { isModelKeyPattern } from "@server/lib/aiModelKeyMatch";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import {
BudgetRowsFields,
getBudgetRowsErrors,
rowsFromBudgets,
saveBudgetRows,
type BudgetRow
} from "@app/components/BudgetsEditor";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { aiBudgetQueries } from "@app/lib/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import type { AxiosInstance } from "axios";
import { Globe, Plus, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export type ModelListType = "allow" | "block";
export type ModelSource = "catalog" | "custom" | "pattern" | "all";
/** Matches every model key via the provider policy wildcard. */
export const ALL_MODELS_KEY = "*";
const COLLAPSED_ROWS = 5;
const GRID_COLUMNS = 2;
export type AiProviderModelListItem = {
clientId: string;
modelId?: number;
modelKey: string;
listType: ModelListType;
hasBudget?: boolean;
pendingBudgets?: BudgetRow[];
};
export async function persistPendingModelBudgets({
api,
orgId,
modelId,
pendingBudgets
}: {
api: AxiosInstance;
orgId: string;
modelId: number;
pendingBudgets?: BudgetRow[];
}): Promise<void> {
if (!pendingBudgets || pendingBudgets.length === 0) {
return;
}
await saveBudgetRows({
api,
orgId,
scope: { type: "model", id: modelId },
existingBudgets: [],
rows: pendingBudgets
});
}
export type AiProviderModelListEditorProps = {
orgId: string;
listType: ModelListType;
items: AiProviderModelListItem[];
catalogModels: string[];
/** Keys already used on this list or the sibling list. */
excludeKeys?: ReadonlySet<string>;
onChange: (items: AiProviderModelListItem[]) => void;
disabled?: boolean;
emptyMessage: string;
addPlaceholder: string;
};
export function isAllModelsKey(modelKey: string): boolean {
return modelKey.trim() === ALL_MODELS_KEY;
}
export function resolveModelSource(
modelKey: string,
catalogModels: ReadonlySet<string> | readonly string[]
): ModelSource {
if (isAllModelsKey(modelKey)) {
return "all";
}
if (isModelKeyPattern(modelKey)) {
return "pattern";
}
const set =
catalogModels instanceof Set ? catalogModels : new Set(catalogModels);
return set.has(modelKey) ? "catalog" : "custom";
}
function newClientId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function parseBulkKeys(raw: string): string[] {
const seen = new Set<string>();
const keys: string[] = [];
for (const part of raw.split(/[\n,]+/)) {
const key = part.trim();
if (!key || seen.has(key)) continue;
seen.add(key);
keys.push(key);
}
return keys;
}
export function AiProviderModelListEditor({
orgId,
listType,
items,
catalogModels,
excludeKeys,
onChange,
disabled,
emptyMessage,
addPlaceholder
}: AiProviderModelListEditorProps) {
const t = useTranslations();
const [editingClientId, setEditingClientId] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [addQuery, setAddQuery] = useState("");
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [listExpanded, setListExpanded] = useState(false);
const [clipHeight, setClipHeight] = useState<number | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const collapsedLimit = GRID_COLUMNS * COLLAPSED_ROWS;
const hasOverflow = items.length > collapsedLimit;
const isCollapsed = hasOverflow && !listExpanded;
const catalogSet = useMemo(() => new Set(catalogModels), [catalogModels]);
const blockedKeys = useMemo(() => {
const set = new Set<string>(excludeKeys ? [...excludeKeys] : []);
for (const item of items) {
set.add(item.modelKey);
}
return set;
}, [excludeKeys, items]);
const availableCatalog = useMemo(() => {
const q = addQuery.trim().toLowerCase();
return catalogModels
.filter((model) => !blockedKeys.has(model))
.filter((model) => (q ? model.toLowerCase().includes(q) : true));
}, [addQuery, blockedKeys, catalogModels]);
const trimmedQuery = addQuery.trim();
const bulkKeys = useMemo(
() =>
parseBulkKeys(addQuery).filter(
(key) => !blockedKeys.has(key) && !catalogSet.has(key)
),
[addQuery, blockedKeys, catalogSet]
);
const canAddCustom =
bulkKeys.length === 1 &&
!trimmedQuery.includes("\n") &&
!trimmedQuery.includes(",") &&
!isAllModelsKey(trimmedQuery) &&
!catalogSet.has(trimmedQuery) &&
!blockedKeys.has(trimmedQuery);
const canAddBulkCustom = bulkKeys.length > 1;
const allModelsLabel = t("aiProviderModelsAllLabel");
const showAllModelsOption =
!blockedKeys.has(ALL_MODELS_KEY) &&
(!trimmedQuery ||
trimmedQuery === ALL_MODELS_KEY ||
"all".includes(trimmedQuery.toLowerCase()) ||
allModelsLabel.toLowerCase().includes(trimmedQuery.toLowerCase()));
const editing = items.find((item) => item.clientId === editingClientId);
function appendModels(modelKeys: string[]) {
if (disabled) return;
const nextBlocked = new Set(blockedKeys);
const additions: AiProviderModelListItem[] = [];
for (const raw of modelKeys) {
const key = raw.trim();
if (!key || nextBlocked.has(key)) continue;
nextBlocked.add(key);
additions.push({
clientId: newClientId(),
modelKey: key,
listType,
hasBudget: false
});
}
if (additions.length === 0) return;
onChange([...items, ...additions]);
}
function addModel(modelKey: string, options?: { keepOpen?: boolean }) {
appendModels([modelKey]);
setAddQuery("");
setSelectedKeys(new Set());
if (!options?.keepOpen) {
setAddOpen(false);
}
}
function addSelected() {
appendModels([...selectedKeys]);
setSelectedKeys(new Set());
setAddQuery("");
// Keep open so more can be selected after filter refresh
}
function addBulkCustom() {
appendModels(bulkKeys);
setAddQuery("");
setSelectedKeys(new Set());
}
function toggleSelected(model: string) {
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(model)) {
next.delete(model);
} else {
next.add(model);
}
return next;
});
}
function selectAllVisible() {
setSelectedKeys((prev) => {
const next = new Set(prev);
for (const model of availableCatalog) {
next.add(model);
}
return next;
});
}
function clearSelected() {
setSelectedKeys(new Set());
}
function removeModel(clientId: string) {
onChange(items.filter((item) => item.clientId !== clientId));
}
function updateModel(updated: AiProviderModelListItem) {
onChange(
items.map((item) =>
item.clientId === updated.clientId ? updated : item
)
);
setEditingClientId(null);
}
// Drop selections that are no longer available (already added).
useEffect(() => {
setSelectedKeys((prev) => {
let changed = false;
const next = new Set<string>();
for (const key of prev) {
if (blockedKeys.has(key)) {
changed = true;
continue;
}
next.add(key);
}
return changed ? next : prev;
});
}, [blockedKeys]);
useEffect(() => {
if (!hasOverflow) {
setListExpanded(false);
}
}, [hasOverflow]);
useLayoutEffect(() => {
if (!isCollapsed || !gridRef.current) {
setClipHeight(null);
return;
}
const children = Array.from(gridRef.current.children) as HTMLElement[];
const lastVisible = children[collapsedLimit - 1];
if (!lastVisible) {
setClipHeight(null);
return;
}
const gridTop = gridRef.current.getBoundingClientRect().top;
const cardBottom = lastVisible.getBoundingClientRect().bottom;
// Peek slightly into the next row so the fade has content to soften.
setClipHeight(cardBottom - gridTop + 12);
}, [isCollapsed, collapsedLimit, items]);
return (
<div className="flex flex-col gap-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
) : (
<div>
<div className="relative">
<div
ref={gridRef}
className={cn(
"grid grid-cols-2 gap-2",
isCollapsed && "overflow-hidden"
)}
style={
isCollapsed && clipHeight != null
? { maxHeight: clipHeight }
: undefined
}
>
{items.map((item) => (
<ModelCard
key={item.clientId}
item={item}
source={resolveModelSource(
item.modelKey,
catalogSet
)}
disabled={disabled}
onEdit={() =>
setEditingClientId(item.clientId)
}
onRemove={() => removeModel(item.clientId)}
/>
))}
</div>
{isCollapsed ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-card from-25% via-card/80 to-transparent" />
) : null}
</div>
{isCollapsed ? (
<div className="relative z-10 flex justify-center pt-2">
<Button
type="button"
variant="text"
size="sm"
className="bg-card px-2 text-muted-foreground hover:text-foreground"
onClick={() => setListExpanded(true)}
>
{t("aiProviderModelsViewMore", {
count: items.length - collapsedLimit
})}
</Button>
</div>
) : null}
{hasOverflow && listExpanded ? (
<div className="flex justify-center pt-1">
<Button
type="button"
variant="text"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setListExpanded(false)}
>
{t("aiProviderModelsViewLess")}
</Button>
</div>
) : null}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Popover
open={addOpen}
onOpenChange={(open) => {
if (disabled) return;
setAddOpen(open);
if (!open) {
setAddQuery("");
setSelectedKeys(new Set());
}
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
disabled={disabled}
>
<Plus className="size-4" />
{t("aiProviderModelsAdd")}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
collisionPadding={8}
className="flex w-[min(100vw-2rem,24rem)] max-h-[min(24rem,var(--radix-popover-content-available-height))] flex-col overflow-hidden p-0"
>
<Command
shouldFilter={false}
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
<CommandInput
placeholder={addPlaceholder}
value={addQuery}
onValueChange={setAddQuery}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
if (canAddBulkCustom) {
e.preventDefault();
addBulkCustom();
return;
}
if (canAddCustom) {
e.preventDefault();
addModel(trimmedQuery, {
keepOpen: true
});
}
}}
/>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5">
<p className="text-xs text-muted-foreground">
{t("aiProviderModelsBulkHint")}
</p>
{availableCatalog.length > 0 ? (
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="text"
size="sm"
className="h-auto px-1 text-xs"
onClick={selectAllVisible}
>
{t("aiProviderModelsSelectAll")}
</Button>
{selectedKeys.size > 0 ? (
<Button
type="button"
variant="text"
size="sm"
className="h-auto px-1 text-xs"
onClick={clearSelected}
>
{t(
"aiProviderModelsClearSelected"
)}
</Button>
) : null}
</div>
) : null}
</div>
<CommandList className="max-h-none min-h-0 flex-1 overflow-y-auto overscroll-contain">
<CommandEmpty>
{canAddBulkCustom
? t("aiProviderModelsAddBulkHint", {
count: bulkKeys.length
})
: canAddCustom
? t("aiProviderModelsAddCustomHint")
: t("aiProviderModelsCatalogEmpty")}
</CommandEmpty>
{showAllModelsOption ? (
<CommandGroup className="overflow-visible">
<CommandItem
value={`all:${ALL_MODELS_KEY}`}
onSelect={() =>
addModel(ALL_MODELS_KEY, {
keepOpen: true
})
}
className="items-start gap-2 py-2"
>
<Globe className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{listType === "allow"
? t(
"aiProviderModelsAddAllAllow"
)
: t(
"aiProviderModelsAddAllBlock"
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"aiProviderModelsAddAllDescription"
)}
</p>
</div>
</CommandItem>
</CommandGroup>
) : null}
{canAddBulkCustom ? (
<CommandGroup className="overflow-visible">
<CommandItem
value={`bulk:${bulkKeys.join(",")}`}
onSelect={addBulkCustom}
>
<Plus className="mr-2 size-4" />
{t("aiProviderModelsAddBulk", {
count: bulkKeys.length
})}
</CommandItem>
</CommandGroup>
) : null}
{canAddCustom ? (
<CommandGroup className="overflow-visible">
<CommandItem
value={`custom:${trimmedQuery}`}
onSelect={() =>
addModel(trimmedQuery, {
keepOpen: true
})
}
>
<Plus className="mr-2 size-4" />
{t("aiProviderModelsAddCustom", {
key: trimmedQuery
})}
</CommandItem>
</CommandGroup>
) : null}
{availableCatalog.length > 0 ? (
<CommandGroup
heading={t(
"aiProviderModelsCatalogHeading"
)}
className="overflow-visible"
>
{availableCatalog.map((model) => {
const isSelected =
selectedKeys.has(model);
return (
<CommandItem
key={model}
value={model}
onSelect={() => {
// Toggle selection for bulk;
// double-purpose: shift-free multi-pick.
toggleSelected(model);
}}
className="gap-2"
>
<Checkbox
checked={isSelected}
className="pointer-events-none"
tabIndex={-1}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{model}
</span>
</CommandItem>
);
})}
</CommandGroup>
) : null}
</CommandList>
{selectedKeys.size > 0 ? (
<div className="flex shrink-0 items-center justify-between gap-2 border-t p-2">
<span className="text-xs text-muted-foreground">
{t("aiProviderModelsSelectedCount", {
count: selectedKeys.size
})}
</span>
<Button
type="button"
size="sm"
onClick={addSelected}
>
{t("aiProviderModelsAddSelected")}
</Button>
</div>
) : null}
</Command>
</PopoverContent>
</Popover>
{items.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
disabled={disabled}
onClick={() => onChange([])}
>
{t("aiProviderModelsClearAll")}
</Button>
) : null}
</div>
{editing && (
<EditModelCredenza
orgId={orgId}
item={editing}
open={editingClientId !== null}
onOpenChange={(open) => {
if (!open) setEditingClientId(null);
}}
existingKeys={blockedKeys}
onSave={updateModel}
/>
)}
</div>
);
}
function ModelCard({
item,
source,
disabled,
onEdit,
onRemove
}: {
item: AiProviderModelListItem;
source: ModelSource;
disabled?: boolean;
onEdit: () => void;
onRemove: () => void;
}) {
const t = useTranslations();
return (
<div
className={cn(
"flex min-w-0 items-center gap-2 rounded-md border border-input px-2.5 py-2",
disabled && "opacity-60",
!disabled && "cursor-pointer hover:bg-muted/50"
)}
onClick={disabled ? undefined : onEdit}
onKeyDown={
disabled
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onEdit();
}
}
}
role={disabled ? undefined : "button"}
tabIndex={disabled ? undefined : 0}
title={t("aiProviderModelsEditHint")}
>
<div className="min-w-0 flex-1">
{source === "all" ? (
<span className="block truncate text-xs font-medium">
{t("aiProviderModelsAllLabel")}
</span>
) : (
<span className="block truncate font-mono text-xs font-medium">
{item.modelKey}
</span>
)}
</div>
<button
type="button"
className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
disabled={disabled}
aria-label={t("aiProviderModelsRemove")}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<XIcon className="size-3.5" />
</button>
</div>
);
}
type EditFormValues = {
modelKey: string;
};
function EditModelCredenza({
orgId,
item,
open,
onOpenChange,
existingKeys,
onSave
}: {
orgId: string;
item: AiProviderModelListItem;
open: boolean;
onOpenChange: (open: boolean) => void;
existingKeys: ReadonlySet<string>;
onSave: (item: AiProviderModelListItem) => void;
}) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const editSchema = useMemo(
() =>
z.object({
modelKey: z
.string()
.trim()
.min(1, t("aiProviderModelsKeyRequired"))
.refine(
(key) =>
key === item.modelKey || !existingKeys.has(key),
t("aiProviderModelsKeyDuplicate")
)
}),
[existingKeys, item.modelKey, t]
);
const form = useForm<EditFormValues>({
resolver: zodResolver(editSchema),
defaultValues: { modelKey: item.modelKey }
});
const [pendingBudgetRows, setPendingBudgetRows] = useState<BudgetRow[]>([]);
const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false);
const [savingBudgets, setSavingBudgets] = useState(false);
const budgetScope =
item.modelId !== undefined
? { type: "model" as const, id: item.modelId }
: null;
const budgetsQuery = useQuery({
...aiBudgetQueries.scoped({
scope: budgetScope ?? { type: "model", id: -1 }
}),
enabled: open && budgetScope !== null
});
useEffect(() => {
if (!open) return;
form.reset({ modelKey: item.modelKey });
setAttemptedBudgetsSave(false);
if (item.modelId === undefined) {
setPendingBudgetRows(item.pendingBudgets ?? []);
} else {
setPendingBudgetRows([]);
}
}, [
form,
item.clientId,
item.modelId,
item.modelKey,
item.pendingBudgets,
open
]);
useEffect(() => {
if (!open || !budgetsQuery.data) return;
setPendingBudgetRows(rowsFromBudgets(budgetsQuery.data));
}, [open, budgetsQuery.data]);
async function handleSubmit(values: EditFormValues) {
const { conflictingKeys, invalidAmountKeys } =
getBudgetRowsErrors(pendingBudgetRows);
if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) {
setAttemptedBudgetsSave(true);
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: conflictingKeys.size
? t("aiBudgetConflictError")
: t("aiBudgetInvalidAmountError")
});
return;
}
if (budgetScope) {
setSavingBudgets(true);
try {
const existingBudgets = await queryClient.fetchQuery(
aiBudgetQueries.scoped({ scope: budgetScope })
);
await saveBudgetRows({
api,
orgId,
scope: budgetScope,
existingBudgets,
rows: pendingBudgetRows
});
await queryClient.invalidateQueries(
aiBudgetQueries.scoped({ scope: budgetScope })
);
} catch (e) {
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: formatAxiosError(e, t("aiBudgetErrorSave"))
});
setSavingBudgets(false);
return;
}
setSavingBudgets(false);
}
onSave({
...item,
modelKey: values.modelKey.trim(),
pendingBudgets: pendingBudgetRows,
hasBudget: pendingBudgetRows.length > 0
});
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("aiProviderModelsEditTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("aiProviderModelsEditDescription")}
</CredenzaDescription>
</CredenzaHeader>
<Form {...form}>
<form
id="ai-provider-model-edit-form"
onSubmit={form.handleSubmit(handleSubmit)}
>
<CredenzaBody>
<HorizontalTabs
clientSide={true}
defaultTab={0}
items={[
{ title: t("general"), href: "#" },
{
title: t("aiProviderModelsBudgetTab"),
href: "#"
}
]}
>
<div className="space-y-4 mt-4">
<FormField
control={form.control}
name="modelKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"aiProviderModelsKeyLabel"
)}
</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
spellCheck={false}
className="font-mono"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4 mt-4">
<BudgetRowsFields
rows={pendingBudgetRows}
onChange={setPendingBudgetRows}
disabled={
budgetScope !== null &&
budgetsQuery.isLoading
}
attemptedSave={attemptedBudgetsSave}
/>
</div>
</HorizontalTabs>
</CredenzaBody>
</form>
</Form>
<CredenzaFooter>
<CredenzaClose asChild>
<Button type="button" variant="outline">
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="ai-provider-model-edit-form"
loading={savingBudgets}
disabled={savingBudgets}
>
{t("save")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import {
AiProviderModelListEditor,
type AiProviderModelListItem
} from "@app/components/AiProviderModelListEditor";
import { Label } from "@app/components/ui/label";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
export type AiProviderModelsListsProps = {
orgId: string;
allowItems: AiProviderModelListItem[];
onAllowChange: (items: AiProviderModelListItem[]) => void;
blockItems: AiProviderModelListItem[];
onBlockChange: (items: AiProviderModelListItem[]) => void;
catalogModels: string[];
disabled?: boolean;
};
export function AiProviderModelsLists({
orgId,
allowItems,
onAllowChange,
blockItems,
onBlockChange,
catalogModels,
disabled
}: AiProviderModelsListsProps) {
const t = useTranslations();
const allowExcludeKeys = useMemo(
() => new Set(blockItems.map((item) => item.modelKey)),
[blockItems]
);
const blockExcludeKeys = useMemo(
() => new Set(allowItems.map((item) => item.modelKey)),
[allowItems]
);
return (
<div className="space-y-6">
<div className="space-y-2">
<Label>{t("aiProviderModelsAllow")}</Label>
<AiProviderModelListEditor
orgId={orgId}
listType="allow"
items={allowItems}
onChange={onAllowChange}
catalogModels={catalogModels}
excludeKeys={allowExcludeKeys}
disabled={disabled}
emptyMessage={t("aiProviderModelsAllowEmpty")}
addPlaceholder={t("aiProviderModelsAllowPlaceholder")}
/>
<p className="text-sm text-muted-foreground">
{t("aiProviderModelsAllowDescription")}
</p>
</div>
<div className="space-y-2">
<Label>{t("aiProviderModelsBlock")}</Label>
<AiProviderModelListEditor
orgId={orgId}
listType="block"
items={blockItems}
onChange={onBlockChange}
catalogModels={catalogModels}
excludeKeys={blockExcludeKeys}
disabled={disabled}
emptyMessage={t("aiProviderModelsBlockEmpty")}
addPlaceholder={t("aiProviderModelsBlockPlaceholder")}
/>
<p className="text-sm text-muted-foreground">
{t("aiProviderModelsBlockDescription")}
</p>
</div>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema";
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
export const aiProviderTypeLabelMap = {
openai: "aiProviderTypeOpenai",
anthropic: "aiProviderTypeAnthropic",
googleGemini: "aiProviderTypeGoogleGemini",
vertexAi: "aiProviderTypeVertexAi",
bedrock: "aiProviderTypeBedrock",
microsoftFoundry: "aiProviderTypeMicrosoftFoundry",
openRouter: "aiProviderTypeOpenRouter",
vercelAiGateway: "aiProviderTypeVercelAiGateway",
custom: "aiProviderTypeCustom"
} as const;
const typeDescriptionMap = {
openai: "aiProviderTypeOpenaiDescription",
anthropic: "aiProviderTypeAnthropicDescription",
googleGemini: "aiProviderTypeGoogleGeminiDescription",
vertexAi: "aiProviderTypeVertexAiDescription",
bedrock: "aiProviderTypeBedrockDescription",
microsoftFoundry: "aiProviderTypeMicrosoftFoundryDescription",
openRouter: "aiProviderTypeOpenRouterDescription",
vercelAiGateway: "aiProviderTypeVercelAiGatewayDescription",
custom: "aiProviderTypeCustomDescription"
} as const;
type AiProviderTypeSelectProps = {
value: AiProviderType;
onChange: (value: AiProviderType) => void;
disabled?: boolean;
className?: string;
};
export function AiProviderTypeSelect({
value,
onChange,
disabled,
className
}: AiProviderTypeSelectProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const options = useMemo(
() =>
aiProviderTypeValues.map((type) => ({
type,
title: t(aiProviderTypeLabelMap[type]),
description: t(typeDescriptionMap[type])
})),
[t]
);
const selected = options.find((option) => option.type === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between",
!selected && "text-muted-foreground",
className
)}
>
<span className="truncate text-left">
{selected?.title ?? t("noneSelected")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder={t("aiProviderTypeSearch")} />
<CommandList>
<CommandEmpty>
{t("aiProviderTypeNotFound")}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.type}
value={`${option.type} ${option.title} ${option.description}`}
onSelect={() => {
onChange(option.type);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
option.type === value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
{option.title}
</span>
<span className="text-muted-foreground text-xs leading-snug">
{option.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Sparkles } from "lucide-react";
import { useTranslations } from "next-intl";
import DismissableBanner from "./DismissableBanner";
export const AiProvidersBanner = () => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="ai-providers-banner-dismissed"
version={1}
title={t("aiProvidersBannerTitle")}
titleIcon={<Sparkles className="w-5 h-5 text-primary" />}
description={t("aiProvidersBannerDescription")}
/>
);
};
export default AiProvidersBanner;
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { aiProviderQueries } from "@app/lib/queries";
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useDebounce } from "use-debounce";
export type SelectedAiProvider = {
id: string;
text: string;
};
export type AiProvidersSelectorProps = {
orgId: string;
selectedProviders?: SelectedAiProvider[];
onSelectProviders: (providers: SelectedAiProvider[]) => void;
disabled?: boolean;
buttonText?: string;
};
export function AiProvidersSelector({
orgId,
selectedProviders = [],
onSelectProviders,
disabled,
buttonText
}: AiProvidersSelectorProps) {
const t = useTranslations();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedValue] = useDebounce(searchQuery, 150);
const { data: providers = [] } = useQuery(
aiProviderQueries.orgProviders({
orgId,
query: debouncedValue || undefined
})
);
const options: SelectedAiProvider[] = providers
.filter((provider) => provider.enabled)
.map((provider) => ({
id: String(provider.providerId),
text: provider.name
}));
return (
<MultiSelectTagInput
buttonText={buttonText ?? t("aiResourceProvidersSelect")}
emptyPlaceholder={t("aiResourceProvidersEmpty")}
searchPlaceholder={t("aiProvidersSearch")}
searchQuery={searchQuery}
options={options}
value={selectedProviders}
onChange={onSelectProviders}
onSearch={setSearchQuery}
disabled={disabled}
/>
);
}
+318
View File
@@ -0,0 +1,318 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Switch } from "@app/components/ui/switch";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "@app/components/ui/controlled-data-table";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { PaginationState } from "@tanstack/react-table";
import { ArrowRight, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
export type AiProviderRow = {
providerId: number;
niceId: string;
name: string;
type: string;
routingMode: string;
enabled: boolean;
effectiveUpstreamUrl: string | null;
apiKeyLastChars: string | null;
};
type AiProvidersTableProps = {
providers: AiProviderRow[];
orgId: string;
pagination: PaginationState;
rowCount: number;
};
export default function AiProvidersTable({
providers,
orgId,
pagination,
rowCount
}: AiProvidersTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [rows, setRows] = useState(providers);
const [selected, setSelected] = useState<AiProviderRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
useEffect(() => {
setRows(providers);
}, [providers]);
function refreshData() {
startTransition(() => {
try {
router.refresh();
} catch {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({ searchParams });
}, 300);
function typeLabel(type: string) {
const key = `aiProviderType${type.charAt(0).toUpperCase()}${type.slice(1)}`;
const map: Record<string, string> = {
openai: "aiProviderTypeOpenai",
anthropic: "aiProviderTypeAnthropic",
googleGemini: "aiProviderTypeGoogleGemini",
vertexAi: "aiProviderTypeVertexAi",
bedrock: "aiProviderTypeBedrock",
microsoftFoundry: "aiProviderTypeMicrosoftFoundry",
openRouter: "aiProviderTypeOpenRouter",
vercelAiGateway: "aiProviderTypeVercelAiGateway",
custom: "aiProviderTypeCustom"
};
return t(map[type] ?? key);
}
function routingLabel(mode: string) {
return mode === "target"
? t("aiProviderRoutingModeTarget")
: t("aiProviderRoutingModeUrl");
}
async function toggleEnabled(row: AiProviderRow, enabled: boolean) {
setRows((prev) =>
prev.map((r) =>
r.providerId === row.providerId ? { ...r, enabled } : r
)
);
try {
await api.post(`/ai-provider/${row.providerId}`, { enabled });
toast({
title: t("success"),
description: t("aiProviderUpdated")
});
router.refresh();
} catch (e) {
setRows((prev) =>
prev.map((r) =>
r.providerId === row.providerId
? { ...r, enabled: row.enabled }
: r
)
);
toast({
variant: "destructive",
title: t("aiProviderErrorUpdate"),
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
});
}
}
function deleteProvider(row: AiProviderRow) {
startTransition(async () => {
try {
await api.delete(`/ai-provider/${row.providerId}`);
setRows((prev) =>
prev.filter((r) => r.providerId !== row.providerId)
);
setIsDeleteModalOpen(false);
setSelected(null);
toast({
title: t("success"),
description: t("aiProviderDeleted")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorDelete"),
description: formatAxiosError(e, t("aiProviderErrorDelete"))
});
}
});
}
const columns = useMemo<ExtendedColumnDef<AiProviderRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => (
<Link
href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
className="hover:underline"
>
{row.original.name}
</Link>
)
},
{
accessorKey: "type",
header: () => (
<span className="p-3">{t("aiProviderType")}</span>
),
cell: ({ row }) => typeLabel(row.original.type)
},
{
accessorKey: "routingMode",
header: () => (
<span className="p-3">{t("aiProviderRoutingMode")}</span>
),
cell: ({ row }) => routingLabel(row.original.routingMode)
},
{
accessorKey: "effectiveUpstreamUrl",
header: () => (
<span className="p-3">{t("aiProviderUpstreamUrl")}</span>
),
cell: ({ row }) => (
<span>{row.original.effectiveUpstreamUrl ?? "-"}</span>
)
},
{
accessorKey: "enabled",
header: () => (
<span className="p-3">{t("aiProviderEnabled")}</span>
),
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={(checked) =>
toggleEnabled(row.original, checked)
}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
>
{t("edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
>
<Button variant="outline">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
)
}
],
[orgId, t]
);
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) {
setSelected(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("aiProviderQuestionRemove")}</p>
<p>{t("aiProviderMessageRemove")}</p>
</div>
}
buttonText={t("aiProviderDeleteConfirm")}
onConfirm={async () => deleteProvider(selected)}
string={selected.name}
title={t("aiProviderDelete")}
/>
)}
<ControlledDataTable
columns={columns}
rows={rows}
addButtonText={t("aiProvidersAdd")}
onAdd={() =>
router.push(`/${orgId}/settings/ai-providers/create`)
}
tableId="ai-providers-table"
searchPlaceholder={t("aiProvidersSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
stickyRightColumn="actions"
/>
</>
);
}
+217
View File
@@ -0,0 +1,217 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import {
AlertTriangle,
Bot,
Code,
MessagesSquare,
Terminal,
User as UserIcon,
Wrench
} from "lucide-react";
import { Button } from "@app/components/ui/button";
import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization";
type AiSessionChatViewProps = {
normalizedRequest: string | null;
normalizedResponse: string | null;
requestBody: string | null;
responseBody: string | null;
truncated: boolean;
};
function parseMessages(json: string | null): NormalizedAiMessage[] | null {
if (!json) return null;
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? (parsed as NormalizedAiMessage[]) : null;
} catch {
return null;
}
}
function prettyRaw(raw: string | null): string | null {
if (!raw) return null;
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
function MessageBubble({ message }: { message: NormalizedAiMessage }) {
const isUser = message.role === "user";
const isSystem = message.role === "system";
const isTool = message.role === "tool";
if (isSystem) {
return (
<div className="flex items-start gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<Terminal className="h-3.5 w-3.5 mt-0.5 flex-none" />
<pre className="whitespace-pre-wrap break-words font-sans">
{message.content}
</pre>
</div>
);
}
return (
<div
className={`flex items-start gap-2 ${isUser ? "flex-row-reverse" : ""}`}
>
<div
className={`flex h-7 w-7 flex-none items-center justify-center rounded-full ${
isUser
? "bg-primary text-primary-foreground"
: isTool
? "bg-amber-100 dark:bg-amber-900/40"
: "bg-muted"
}`}
>
{isUser ? (
<UserIcon className="h-4 w-4" />
) : isTool ? (
<Wrench className="h-3.5 w-3.5" />
) : (
<Bot className="h-4 w-4" />
)}
</div>
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
isUser
? "bg-primary text-primary-foreground"
: isTool
? "bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 font-mono text-xs"
: "bg-muted"
}`}
>
{message.content || (
<span className="italic opacity-60">&nbsp;</span>
)}
</div>
</div>
);
}
function RawFallbackBlock({
label,
raw,
noDataLabel,
unparsedLabel
}: {
label: string;
raw: string | null;
noDataLabel: string;
unparsedLabel?: string;
}) {
const pretty = prettyRaw(raw);
return (
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-1 text-xs font-medium text-muted-foreground">
{label}
{pretty && unparsedLabel && (
<span className="ml-2 font-normal italic opacity-70">
{unparsedLabel}
</span>
)}
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
{pretty ?? noDataLabel}
</pre>
</div>
);
}
export function AiSessionChatView({
normalizedRequest,
normalizedResponse,
requestBody,
responseBody,
truncated
}: AiSessionChatViewProps) {
const t = useTranslations();
const [rawMode, setRawMode] = useState(false);
const requestMessages = useMemo(
() => parseMessages(normalizedRequest),
[normalizedRequest]
);
const responseMessages = useMemo(
() => parseMessages(normalizedResponse),
[normalizedResponse]
);
const hasRequestMessages = !!requestMessages && requestMessages.length > 0;
const hasResponseMessages =
!!responseMessages && responseMessages.length > 0;
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
{truncated ? (
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="h-3.5 w-3.5 flex-none" />
{t("aiSessionLogTruncated")}
</div>
) : (
<div />
)}
<Button
variant="outline"
size="sm"
onClick={() => setRawMode((prev) => !prev)}
>
{rawMode ? (
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
) : (
<Code className="mr-2 h-3.5 w-3.5" />
)}
{rawMode ? t("aiSessionViewChat") : t("aiSessionViewRaw")}
</Button>
</div>
{rawMode ? (
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
<RawFallbackBlock
label={t("aiSessionRequest")}
raw={normalizedRequest}
noDataLabel={t("aiSessionNoData")}
/>
<RawFallbackBlock
label={t("aiSessionResponse")}
raw={normalizedResponse}
noDataLabel={t("aiSessionNoData")}
/>
</div>
) : (
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
{hasRequestMessages ? (
requestMessages!.map((message, i) => (
<MessageBubble key={`req-${i}`} message={message} />
))
) : (
<RawFallbackBlock
label={t("aiSessionRequest")}
raw={requestBody}
noDataLabel={t("aiSessionNoData")}
unparsedLabel={t("aiSessionCouldNotParse")}
/>
)}
{hasResponseMessages ? (
responseMessages!.map((message, i) => (
<MessageBubble key={`res-${i}`} message={message} />
))
) : (
<RawFallbackBlock
label={t("aiSessionResponse")}
raw={responseBody}
noDataLabel={t("aiSessionNoData")}
unparsedLabel={t("aiSessionCouldNotParse")}
/>
)}
</div>
)}
</div>
);
}
+384
View File
@@ -0,0 +1,384 @@
"use client";
import { cn } from "@app/lib/cn";
import {
aiUsageAnalyticsFiltersSchema,
aiUsageAnalyticsQueries,
type AiUsageAnalyticsFilters
} from "@app/lib/queries";
import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckIcon, ChevronsUpDown, RefreshCw, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
import { Button } from "./ui/button";
import { Card, CardHeader } from "./ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Label } from "./ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Separator } from "./ui/separator";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { HorizontalTabs, type TabItem } from "./HorizontalTabs";
import { OverviewTab } from "./ai-usage-analytics/OverviewTab";
import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab";
import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab";
import { RolesTab } from "./ai-usage-analytics/RolesTab";
import { UsersTab } from "./ai-usage-analytics/UsersTab";
import { VirtualApiKeysTab } from "./ai-usage-analytics/VirtualApiKeysTab";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
export type AiUsageAnalyticsDataProps = {
orgId: string;
};
const AI_USAGE_ANALYTICS_QUERY_PREFIX = ["AI_USAGE_ANALYTICS"];
export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
const t = useTranslations();
const searchParams = useSearchParams();
const path = usePathname();
const router = useRouter();
const queryClient = useQueryClient();
const filters = aiUsageAnalyticsFiltersSchema.parse(
Object.fromEntries(searchParams.entries())
);
const isEmptySearchParams = Object.values(filters).every(
(v) => v === undefined
);
const dateRange = {
startDate: filters.timeStart
? new Date(filters.timeStart)
: getSevenDaysAgo(),
endDate: filters.timeEnd ? new Date(filters.timeEnd) : new Date()
};
const { data: filterOptions } = useQuery(
aiUsageAnalyticsQueries.filterOptions({
orgId: props.orgId,
filters: {
timeStart: filters.timeStart,
timeEnd: filters.timeEnd
}
})
);
const isFetching =
useIsFetching({
queryKey: [...AI_USAGE_ANALYTICS_QUERY_PREFIX, props.orgId]
}) > 0;
function setFilter(key: keyof AiUsageAnalyticsFilters, value?: string) {
const newSearch = new URLSearchParams(searchParams);
newSearch.delete(key);
if (value !== undefined) {
newSearch.set(key, value);
}
router.replace(`${path}?${newSearch.toString()}`);
}
function handleTimeRangeUpdate(start: DateTimeValue, end: DateTimeValue) {
const newSearch = new URLSearchParams(searchParams);
const timeRegex =
/^(?<hours>\d{1,2})\:(?<minutes>\d{1,2})(\:(?<seconds>\d{1,2}))?$/;
if (start.date) {
const startDate = new Date(start.date);
if (start.time) {
const time = timeRegex.exec(start.time);
const groups = time?.groups ?? {};
startDate.setHours(Number(groups.hours));
startDate.setMinutes(Number(groups.minutes));
if (groups.seconds) {
startDate.setSeconds(Number(groups.seconds));
}
}
newSearch.set("timeStart", startDate.toISOString());
}
if (end.date) {
const endDate = new Date(end.date);
if (end.time) {
const time = timeRegex.exec(end.time);
const groups = time?.groups ?? {};
endDate.setHours(Number(groups.hours));
endDate.setMinutes(Number(groups.minutes));
if (groups.seconds) {
endDate.setSeconds(Number(groups.seconds));
}
}
newSearch.set("timeEnd", endDate.toISOString());
}
router.replace(`${path}?${newSearch.toString()}`);
}
function getDateTime(date: Date) {
return `${date.getHours()}:${date.getMinutes()}`;
}
const providerOptions = (filterOptions?.providers ?? []).map((p) => ({
value: String(p.id),
label: p.name ?? `Provider #${p.id}`
}));
const modelOptions = (filterOptions?.models ?? []).map((m) => ({
value: m,
label: m
}));
const resourceOptions = (filterOptions?.resources ?? []).map((r) => ({
value: String(r.id),
label: r.name ?? `Resource #${r.id}`
}));
const roleOptions = (filterOptions?.roles ?? []).map((r) => ({
value: String(r.id),
label: r.name ?? `Role #${r.id}`
}));
const userOptions = (filterOptions?.users ?? []).map((u) => ({
value: u.id,
label: u.email ?? u.id
}));
const virtualApiKeyOptions = (filterOptions?.virtualApiKeys ?? []).map(
(k) => ({
value: k.id,
label: k.name ?? formatVirtualApiKeyPreview(k.id, k.lastChars)
})
);
const tabs: TabItem[] = [
{ title: t("aiUsageTabOverview"), href: "#" },
{ title: t("aiUsageTabProviders"), href: "#" },
{ title: t("aiUsageTabResources"), href: "#" },
{ title: t("aiUsageRolesTab"), href: "#" },
{ title: t("aiUsageUsersTab"), href: "#" },
{ title: t("aiUsageVirtualApiKeysTab"), href: "#" }
];
return (
<div className="flex flex-col gap-5">
<Card>
<CardHeader className="flex flex-row flex-wrap items-end gap-x-3 gap-y-3 space-y-0">
<DateRangePicker
startValue={{
date: dateRange.startDate,
time: dateRange.startDate
? getDateTime(dateRange.startDate)
: undefined
}}
endValue={{
date: dateRange.endDate,
time: dateRange.endDate
? getDateTime(dateRange.endDate)
: undefined
}}
onRangeChange={handleTimeRangeUpdate}
className="flex-wrap gap-2"
/>
<Separator className="w-px h-6 self-end relative bottom-1.5 hidden lg:block" />
<FilterSelect
id="providerId"
label={t("aiUsageFilterProvider")}
value={filters.providerId?.toString()}
options={providerOptions}
placeholder={t("aiUsageFilterAllProviders")}
onValueChange={(v) => setFilter("providerId", v)}
/>
<FilterSelect
id="model"
label={t("aiUsageFilterModel")}
value={filters.model}
options={modelOptions}
placeholder={t("aiUsageFilterAllModels")}
onValueChange={(v) => setFilter("model", v)}
/>
<FilterSelect
id="resourceId"
label={t("aiUsageFilterResource")}
value={filters.resourceId?.toString()}
options={resourceOptions}
placeholder={t("aiUsageFilterAllResources")}
onValueChange={(v) => setFilter("resourceId", v)}
/>
<FilterSelect
id="roleId"
label={t("aiUsageFilterRole")}
value={filters.roleId?.toString()}
options={roleOptions}
placeholder={t("aiUsageFilterAllRoles")}
onValueChange={(v) => setFilter("roleId", v)}
/>
<FilterSelect
id="userId"
label={t("aiUsageFilterUser")}
value={filters.userId}
options={userOptions}
placeholder={t("aiUsageFilterAllUsers")}
onValueChange={(v) => setFilter("userId", v)}
/>
<FilterSelect
id="virtualApiKeyId"
label={t("aiUsageFilterVirtualApiKey")}
value={filters.virtualApiKeyId}
options={virtualApiKeyOptions}
placeholder={t("aiUsageFilterAllVirtualApiKeys")}
onValueChange={(v) => setFilter("virtualApiKeyId", v)}
/>
<div className="flex items-center gap-2 ml-auto">
{!isEmptySearchParams && (
<Button
variant="ghost"
onClick={() => router.replace(path)}
className="gap-2"
>
<XIcon className="size-4" />
{t("aiUsageResetFilters")}
</Button>
)}
<Button
variant="outline"
onClick={() =>
queryClient.invalidateQueries({
queryKey: [
...AI_USAGE_ANALYTICS_QUERY_PREFIX,
props.orgId
]
})
}
disabled={isFetching}
className="gap-2"
>
<RefreshCw
className={cn(
"size-4",
isFetching && "animate-spin"
)}
/>
{t("aiUsageRefresh")}
</Button>
</div>
</CardHeader>
</Card>
<HorizontalTabs items={tabs} clientSide>
<OverviewTab orgId={props.orgId} filters={filters} />
<ProvidersTab orgId={props.orgId} filters={filters} />
<ResourcesTab orgId={props.orgId} filters={filters} />
<RolesTab orgId={props.orgId} filters={filters} />
<UsersTab orgId={props.orgId} filters={filters} />
<VirtualApiKeysTab orgId={props.orgId} filters={filters} />
</HorizontalTabs>
</div>
);
}
type FilterSelectProps = {
id: string;
label: string;
value?: string;
options: { value: string; label: string }[];
placeholder: string;
onValueChange: (value?: string) => void;
};
function FilterSelect(props: FilterSelectProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const selected = props.options.find(
(option) => option.value === props.value
);
return (
<div className="flex flex-col items-start gap-2 w-44">
<Label htmlFor={props.id}>{props.label}</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={props.id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal",
!selected && "text-muted-foreground"
)}
>
<span className="truncate text-left">
{selected?.label ?? props.placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] min-w-56 p-0"
align="start"
>
<Command>
<CommandInput placeholder={t("aiUsageFilterSearch")} />
<CommandList>
<CommandEmpty>
{t("aiUsageFilterNotFound")}
</CommandEmpty>
<CommandGroup>
<CommandItem
value={props.placeholder}
onSelect={() => {
props.onValueChange(undefined);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
!props.value
? "opacity-100"
: "opacity-0"
)}
/>
{props.placeholder}
</CommandItem>
{props.options.map((option) => (
<CommandItem
key={option.value}
value={`${option.value} ${option.label}`}
onSelect={() => {
props.onValueChange(
option.value === props.value
? undefined
: option.value
);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
option.value === props.value
? "opacity-100"
: "opacity-0"
)}
/>
<span className="truncate">
{option.label}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
+1 -2
View File
@@ -399,7 +399,7 @@ function AuthPageSettings({
</div>
)}
{build !== "oss" && (build === "enterprise" ||
{(build === "enterprise" ||
!isPaidUser(
tierMatrix.loginPageDomain
)) &&
@@ -412,7 +412,6 @@ function AuthPageSettings({
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
+562
View File
@@ -0,0 +1,562 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
AI_BUDGET_PERIODS,
AI_BUDGET_UNITS,
getAiBudgetScopeBodyField,
type AiBudgetPeriod,
type AiBudgetScope,
type AiBudgetUnit
} from "@app/lib/aiBudgetScope";
import { cn } from "@app/lib/cn";
import { aiBudgetQueries } from "@app/lib/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AiBudget } from "@server/db";
import type { AxiosInstance } from "axios";
import { Plus, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
export type BudgetRow = {
key: string;
budgetId?: number;
amount: string;
unit: AiBudgetUnit;
period: AiBudgetPeriod;
};
export function rowsFromBudgets(budgets: AiBudget[]): BudgetRow[] {
return budgets.map((budget) => ({
key: String(budget.budgetId),
budgetId: budget.budgetId,
amount: String(budget.amount),
unit: budget.unit,
period: budget.period
}));
}
function comboKey(unit: AiBudgetUnit, period: AiBudgetPeriod): string {
return `${unit}:${period}`;
}
function nextAvailableCombo(rows: BudgetRow[]): {
unit: AiBudgetUnit;
period: AiBudgetPeriod;
} {
const used = new Set(rows.map((row) => comboKey(row.unit, row.period)));
for (const unit of AI_BUDGET_UNITS) {
for (const period of AI_BUDGET_PERIODS) {
if (!used.has(comboKey(unit, period))) {
return { unit, period };
}
}
}
return { unit: "usd", period: "monthly" };
}
function newRowKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export function newBudgetRow(rows: BudgetRow[]): BudgetRow {
const combo = nextAvailableCombo(rows);
return {
key: newRowKey(),
amount: "",
unit: combo.unit,
period: combo.period
};
}
export function getBudgetRowsErrors(rows: BudgetRow[]): {
conflictingKeys: Set<string>;
invalidAmountKeys: Set<string>;
} {
const counts = new Map<string, number>();
for (const row of rows) {
const key = comboKey(row.unit, row.period);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const conflictingKeys = new Set<string>();
for (const row of rows) {
const key = comboKey(row.unit, row.period);
if ((counts.get(key) ?? 0) > 1) {
conflictingKeys.add(row.key);
}
}
const invalidAmountKeys = new Set<string>();
for (const row of rows) {
const amount = Number(row.amount);
if (!row.amount.trim() || !Number.isFinite(amount) || amount <= 0) {
invalidAmountKeys.add(row.key);
}
}
return { conflictingKeys, invalidAmountKeys };
}
type BudgetRowFieldProps = {
row: BudgetRow;
disabled: boolean;
showInvalidAmount: boolean;
showConflict: boolean;
unitLabels: Record<AiBudgetUnit, string>;
periodLabels: Record<AiBudgetPeriod, string>;
amountPlaceholder: string;
onUpdate: (patch: Partial<BudgetRow>) => void;
};
function BudgetRowAmountInput({
row,
disabled,
showInvalidAmount,
amountPlaceholder,
onUpdate,
className
}: Pick<
BudgetRowFieldProps,
"row" | "disabled" | "showInvalidAmount" | "amountPlaceholder" | "onUpdate"
> & { className?: string }) {
return (
<Input
type="number"
min="0"
step="any"
placeholder={amountPlaceholder}
value={row.amount}
aria-invalid={showInvalidAmount}
disabled={disabled}
onChange={(e) => onUpdate({ amount: e.target.value })}
className={cn("w-full min-w-0", className)}
/>
);
}
function BudgetRowUnitSelect({
row,
disabled,
showConflict,
unitLabels,
onUpdate,
className
}: Pick<
BudgetRowFieldProps,
"row" | "disabled" | "showConflict" | "unitLabels" | "onUpdate"
> & { className?: string }) {
return (
<Select
value={row.unit}
onValueChange={(value) => onUpdate({ unit: value as AiBudgetUnit })}
disabled={disabled}
>
<SelectTrigger
className={cn("w-full min-w-0", className)}
aria-invalid={showConflict}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_BUDGET_UNITS.map((unit) => (
<SelectItem key={unit} value={unit}>
{unitLabels[unit]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function BudgetRowPeriodSelect({
row,
disabled,
showConflict,
periodLabels,
onUpdate,
className
}: Pick<
BudgetRowFieldProps,
"row" | "disabled" | "showConflict" | "periodLabels" | "onUpdate"
> & { className?: string }) {
return (
<Select
value={row.period}
onValueChange={(value) =>
onUpdate({ period: value as AiBudgetPeriod })
}
disabled={disabled}
>
<SelectTrigger
className={cn("w-full min-w-0", className)}
aria-invalid={showConflict}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_BUDGET_PERIODS.map((period) => (
<SelectItem key={period} value={period}>
{periodLabels[period]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function BudgetRowsFields({
rows,
onChange,
disabled = false,
attemptedSave = false
}: {
rows: BudgetRow[];
onChange: (rows: BudgetRow[]) => void;
disabled?: boolean;
attemptedSave?: boolean;
}) {
const t = useTranslations();
const { conflictingKeys, invalidAmountKeys } = useMemo(
() => getBudgetRowsErrors(rows),
[rows]
);
function addRow() {
onChange([...rows, newBudgetRow(rows)]);
}
function removeRow(key: string) {
onChange(rows.filter((row) => row.key !== key));
}
function updateRow(key: string, patch: Partial<BudgetRow>) {
onChange(
rows.map((row) => (row.key === key ? { ...row, ...patch } : row))
);
}
const periodLabels: Record<AiBudgetPeriod, string> = {
hourly: t("aiBudgetPeriodHourly"),
daily: t("aiBudgetPeriodDaily"),
weekly: t("aiBudgetPeriodWeekly"),
monthly: t("aiBudgetPeriodMonthly"),
yearly: t("aiBudgetPeriodYearly"),
lifetime: t("aiBudgetPeriodLifetime")
};
const unitLabels: Record<AiBudgetUnit, string> = {
usd: t("aiBudgetUnitUsd"),
tokens: t("aiBudgetUnitTokens")
};
const amountPlaceholder = t("aiBudgetAmountPlaceholder");
const addRowButton = (
<Button
type="button"
variant="outline"
onClick={addRow}
disabled={disabled}
>
<Plus className="h-4 w-4 mr-2" />
{t("aiBudgetAdd")}
</Button>
);
const errorMessage =
conflictingKeys.size > 0 ||
(attemptedSave && invalidAmountKeys.size > 0) ? (
<p className="text-destructive text-sm">
{conflictingKeys.size > 0
? t("aiBudgetConflictError")
: t("aiBudgetInvalidAmountError")}
</p>
) : null;
function rowFieldProps(row: BudgetRow): BudgetRowFieldProps {
return {
row,
disabled,
showInvalidAmount: attemptedSave && invalidAmountKeys.has(row.key),
showConflict: conflictingKeys.has(row.key),
unitLabels,
periodLabels,
amountPlaceholder,
onUpdate: (patch) => updateRow(row.key, patch)
};
}
return (
<div className="space-y-3">
{rows.length === 0 ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{t("aiBudgetEmpty")}
</p>
{addRowButton}
</div>
) : (
<div className="space-y-2">
{rows.map((row) => {
const fields = rowFieldProps(row);
return (
<div
key={row.key}
className="flex items-center gap-1 sm:gap-2"
>
<div
className={cn(
"flex h-9 min-w-0 flex-1 overflow-hidden rounded-md border border-input",
"focus-within:border-ring",
(fields.showInvalidAmount ||
fields.showConflict) &&
"border-destructive"
)}
>
<BudgetRowUnitSelect
{...fields}
className="h-full w-20 min-w-20 shrink-0 rounded-none border-0 px-2 shadow-none focus-visible:ring-0 sm:w-28 sm:min-w-28 max-sm:[&_svg]:hidden"
/>
<div
className="w-px shrink-0 bg-border"
aria-hidden
/>
<BudgetRowAmountInput
{...fields}
className="h-full min-w-0 flex-1 rounded-none border-0 text-sm shadow-none focus-visible:border-transparent focus-visible:ring-0"
/>
</div>
<BudgetRowPeriodSelect
{...fields}
className="h-9 w-24 min-w-24 shrink-0 sm:w-32 sm:min-w-32 max-sm:[&_svg]:hidden"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
disabled={disabled}
onClick={() => removeRow(row.key)}
aria-label={t("delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
)}
{errorMessage}
{rows.length > 0 && addRowButton}
</div>
);
}
export async function saveBudgetRows({
api,
orgId,
scope,
existingBudgets,
rows
}: {
api: AxiosInstance;
orgId: string;
scope: AiBudgetScope;
existingBudgets: AiBudget[];
rows: Pick<BudgetRow, "budgetId" | "amount" | "unit" | "period">[];
}): Promise<void> {
const existingById = new Map(
existingBudgets.map((budget) => [budget.budgetId, budget])
);
const currentBudgetIds = new Set(
rows
.filter((row) => row.budgetId !== undefined)
.map((row) => row.budgetId as number)
);
const bodyField = getAiBudgetScopeBodyField(scope);
const toDelete = existingBudgets.filter(
(budget) => !currentBudgetIds.has(budget.budgetId)
);
const toCreate = rows.filter((row) => row.budgetId === undefined);
const toUpdate = rows.filter((row) => {
if (row.budgetId === undefined) return false;
const existingBudget = existingById.get(row.budgetId);
if (!existingBudget) return false;
return (
existingBudget.amount !== Number(row.amount) ||
existingBudget.unit !== row.unit ||
existingBudget.period !== row.period
);
});
await Promise.all([
...toDelete.map((budget) =>
api.delete(`/ai-budget/${budget.budgetId}`)
),
...toCreate.map((row) =>
api.put(`/org/${orgId}/ai-budget`, {
[bodyField]: scope.id,
amount: Number(row.amount),
unit: row.unit,
period: row.period
})
),
...toUpdate.map((row) =>
api.post(`/ai-budget/${row.budgetId}`, {
amount: Number(row.amount),
unit: row.unit,
period: row.period
})
)
]);
}
export function BudgetsEditor({
scope,
orgId,
title,
description,
hideCardHeader = false
}: {
scope: AiBudgetScope;
orgId: string;
title: string;
description: string;
hideCardHeader?: boolean;
}) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const t = useTranslations();
const [rows, setRows] = useState<BudgetRow[]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [attemptedSave, setAttemptedSave] = useState(false);
const budgetsQuery = useQuery(aiBudgetQueries.scoped({ scope }));
useEffect(() => {
if (!budgetsQuery.data) return;
setRows(rowsFromBudgets(budgetsQuery.data));
setAttemptedSave(false);
}, [budgetsQuery.data]);
const { conflictingKeys, invalidAmountKeys } = useMemo(
() => getBudgetRowsErrors(rows),
[rows]
);
const hasErrors = conflictingKeys.size > 0 || invalidAmountKeys.size > 0;
async function onSave() {
setAttemptedSave(true);
if (hasErrors) {
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: conflictingKeys.size
? t("aiBudgetConflictError")
: t("aiBudgetInvalidAmountError")
});
return;
}
setSaveLoading(true);
try {
await saveBudgetRows({
api,
orgId,
scope,
existingBudgets: budgetsQuery.data ?? [],
rows
});
await queryClient.invalidateQueries(
aiBudgetQueries.scoped({ scope })
);
toast({
title: t("success"),
description: t("aiBudgetUpdated")
});
} catch (e) {
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: formatAxiosError(e, t("aiBudgetErrorSave"))
});
} finally {
setSaveLoading(false);
}
}
const body = (
<>
<SettingsSectionBody>
<SettingsFormGrid>
<SettingsFormCell span="half">
<BudgetRowsFields
rows={rows}
onChange={setRows}
disabled={budgetsQuery.isLoading}
attemptedSave={attemptedSave}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="button"
loading={saveLoading}
disabled={saveLoading || budgetsQuery.isLoading}
onClick={onSave}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</>
);
if (hideCardHeader) {
return <div className="space-y-4">{body}</div>;
}
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{title}</SettingsSectionTitle>
<SettingsSectionDescription>
{description}
</SettingsSectionDescription>
</SettingsSectionHeader>
{body}
</SettingsSection>
);
}
+3 -6
View File
@@ -5,6 +5,7 @@ import { FileBadge, RotateCw } from "lucide-react";
import { useCertificate } from "@app/hooks/useCertificate";
import type { GetCertificateResponse } from "@server/routers/certificates/types";
import { useTranslations } from "next-intl";
import { durationToMs } from "@app/lib/durationToMs";
export type CertificateStatusContentProps = {
cert: GetCertificateResponse | null;
@@ -32,8 +33,7 @@ export function CertificateStatusContent({
const labelClass =
"inline-flex shrink-0 items-center self-center text-sm font-medium leading-normal";
const valueClass =
"inline-flex items-center gap-2 text-sm leading-normal";
const valueClass = "inline-flex items-center gap-2 text-sm leading-normal";
const handleRefresh = async () => {
await refreshCert();
@@ -187,7 +187,6 @@ type CertificateStatusProps = {
orgId: string;
domainId: string;
fullDomain: string;
autoFetch?: boolean;
showLabel?: boolean;
className?: string;
onRefresh?: () => void;
@@ -199,18 +198,16 @@ export default function CertificateStatus({
orgId,
domainId,
fullDomain,
autoFetch = true,
showLabel = true,
className = "",
onRefresh,
polling = false,
pollingInterval = 5000
pollingInterval = durationToMs(5, "seconds")
}: CertificateStatusProps) {
const hook = useCertificate({
orgId,
domainId,
fullDomain,
autoFetch,
polling,
pollingInterval
});
+13 -11
View File
@@ -24,19 +24,21 @@ export function ContactSalesBanner() {
<ExternalLink className="size-3.5 shrink-0" />
</Link>
{" " + t("contactSalesOr") + " "}
<Link
href="https://pangolin.net/contact"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-black-600 underline"
>
{t("contactSalesContactUs")}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
.
<span className="whitespace-nowrap">
<Link
href="https://pangolin.net/contact"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-black-600 underline"
>
{t("contactSalesContactUs")}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
.
</span>
</span>
</div>
</div>
</div>
);
}
}
+24 -10
View File
@@ -8,29 +8,40 @@ import { useTranslations } from "next-intl";
type CopyTextBoxProps = {
text?: string;
displayText?: string;
getCopyText?: () => Promise<string>;
wrapText?: boolean;
outline?: boolean;
centered?: boolean;
};
export default function CopyTextBox({
text = "",
displayText,
getCopyText,
wrapText = false,
outline = true
outline = true,
centered = false
}: CopyTextBoxProps) {
const [isCopied, setIsCopied] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const textRef = useRef<HTMLPreElement>(null);
const t = useTranslations();
const copyToClipboard = async () => {
if (textRef.current) {
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error(t("copyTextFailed"), err);
}
if (!textRef.current || isCopying) {
return;
}
setIsCopying(true);
try {
const value = getCopyText ? await getCopyText() : text;
await navigator.clipboard.writeText(value);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error(t("copyTextFailed"), err);
} finally {
setIsCopying(false);
}
};
@@ -40,7 +51,9 @@ export default function CopyTextBox({
>
<pre
ref={textRef}
className={`p-4 pr-16 text-sm w-full ${
className={`py-4 text-sm w-full ${
centered ? "px-16 text-center" : "pl-4 pr-16"
} ${
wrapText
? "whitespace-pre-wrap break-words"
: "overflow-x-auto"
@@ -54,6 +67,7 @@ export default function CopyTextBox({
type="button"
className="absolute top-0.5 right-0 z-10 bg-card"
onClick={copyToClipboard}
loading={isCopying}
aria-label={t("copyTextClipboard")}
>
{isCopied ? (
+27 -10
View File
@@ -1,5 +1,5 @@
import { cn } from "@app/lib/cn";
import { Check, Copy } from "lucide-react";
import { Check, Copy, Loader2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslations } from "next-intl";
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
type CopyToClipboardProps = {
text: string;
displayText?: string;
getCopyText?: () => Promise<string>;
isLink?: boolean;
className?: string;
};
@@ -14,18 +15,31 @@ type CopyToClipboardProps = {
const CopyToClipboard = ({
text,
displayText,
getCopyText,
isLink,
className
}: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
const [copying, setCopying] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
const handleCopy = async () => {
if (copying) {
return;
}
setTimeout(() => {
setCopied(false);
}, 2000);
setCopying(true);
try {
const value = getCopyText ? await getCopyText() : text;
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
} catch {
// Fetch errors are toasted by the caller; clipboard failures stay silent.
} finally {
setCopying(false);
}
};
const displayValue = displayText ?? text;
@@ -38,11 +52,14 @@ const CopyToClipboard = ({
type="button"
className="h-4 w-4 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy}
disabled={copying}
>
{!copied ? (
<Copy className="h-4 w-4" />
) : (
{copying ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : copied ? (
<Check className="text-green-500 h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
<span className="sr-only">{t("copyText")}</span>
</button>
+28 -2
View File
@@ -52,7 +52,7 @@ export default function CreateRoleForm({
requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh
};
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
if (isPaidUser(tierMatrix.roleBasedSSHControls)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
@@ -80,13 +80,39 @@ export default function CreateRoleForm({
});
if (res && res.status === 201) {
const createdRole = res.data.data;
const pendingBudgets = (values.budgets ?? []).filter(
(budget) => budget.amount.trim() !== ""
);
if (pendingBudgets.length > 0) {
try {
await Promise.all(
pendingBudgets.map((budget) =>
api.put(`/org/${org?.org.orgId}/ai-budget`, {
roleId: createdRole.roleId,
amount: Number(budget.amount),
unit: budget.unit,
period: budget.period
})
)
);
} catch (e) {
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: formatAxiosError(e, t("aiBudgetErrorSave"))
});
}
}
toast({
variant: "default",
title: t("accessRoleCreated"),
description: t("accessRoleCreatedDescription")
});
if (open) setOpen(false);
afterCreate?.(res.data.data);
afterCreate?.(createdRole);
}
}
+99 -4
View File
@@ -52,7 +52,6 @@ import { ChevronsUpDown } from "lucide-react";
import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks";
import { ShareLinkRow } from "@app/components/ShareLinksTable";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import {
Collapsible,
@@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage";
import { useTranslations } from "next-intl";
import { toUnicode } from "punycode";
import { ResourceSelector, type SelectedResource } from "./resource-selector";
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
type CreatedShareLink = {
accessTokenId: string;
resourceId: number;
resourceName: string;
resourceNiceId: string;
title: string | null;
createdAt: number;
expiresAt: number | null;
userId?: string | null;
userName?: string | null;
username?: string | null;
userEmail?: string | null;
};
type FormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreated?: (result: ShareLinkRow) => void;
onCreated?: (result: CreatedShareLink) => void;
};
export default function CreateShareLinkForm({
@@ -85,6 +99,8 @@ export default function CreateShareLinkForm({
const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [neverExpire, setNeverExpire] = useState(false);
const [persistSession, setPersistSession] = useState(false);
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations();
@@ -175,7 +191,9 @@ export default function CreateShareLinkForm({
values.resourceName ||
"Resource" + values.resourceId
}),
path: values.path
path: values.path,
persistSession,
userId: selectedUser?.id
}
)
.catch((e) => {
@@ -205,7 +223,11 @@ export default function CreateShareLinkForm({
resourceNiceId: selectedResource ? selectedResource.niceId : "",
title: token.title,
createdAt: token.createdAt,
expiresAt: token.expiresAt
expiresAt: token.expiresAt,
userId: token.userId,
userName: selectedUser?.text ?? null,
username: null,
userEmail: null
});
}
@@ -220,6 +242,9 @@ export default function CreateShareLinkForm({
setOpen(val);
setLink(null);
setLoading(false);
setNeverExpire(false);
setPersistSession(false);
setSelectedUser(null);
form.reset();
}}
>
@@ -344,6 +369,48 @@ export default function CreateShareLinkForm({
)}
/>
<div className="space-y-2">
<FormLabel>
{t(
"shareAssociateUserOptional"
)}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!selectedUser &&
"text-muted-foreground"
)}
>
{selectedUser?.text
? selectedUser.text
: t("userSelect")}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<UserSelector
orgId={org.org.orgId}
selectedUser={
selectedUser
}
onSelectUser={
setSelectedUser
}
/>
</PopoverContent>
</Popover>
<p className="text-sm text-muted-foreground">
{t(
"shareAssociateUserDescription"
)}
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<FormLabel>
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
</label>
</div>
<div className="flex items-start space-x-2">
<Checkbox
id="persist-session"
checked={persistSession}
onCheckedChange={(val) =>
setPersistSession(
val as boolean
)
}
className="mt-0.5"
/>
<div className="space-y-1">
<label
htmlFor="persist-session"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
"sharePersistSession"
)}
</label>
<p className="text-sm text-muted-foreground">
{t(
"sharePersistSessionDescription"
)}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground">
{t("shareExpireDescription")}
</p>
+569
View File
@@ -0,0 +1,569 @@
"use client";
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 { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { formatAxiosError, createApiClient } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { Checkbox } from "@app/components/ui/checkbox";
import { useTranslations } from "next-intl";
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
import {
MultiResourcesSelector,
formatMultiResourcesSelectorLabel
} from "@app/components/multi-resource-selector";
import type { SelectedResource } from "@app/components/resource-selector";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import {
BudgetRowsFields,
getBudgetRowsErrors,
type BudgetRow
} from "@app/components/BudgetsEditor";
import VirtualApiKeyEmailSection from "@app/components/VirtualApiKeyEmailSection";
import type { Tag } from "@app/components/tags/tag-input";
export type CreatedVirtualApiKey = {
virtualApiKeyId: string;
orgId: string;
kind: "manual" | "user";
userId: string | null;
name: string | null;
description: string | null;
lastChars: string;
allResources: boolean;
expiresAt: number | null;
lastUsedAt: number | null;
createdAt: number;
createdByUserId: string | null;
resourceIds: number[];
userName?: string | null;
username?: string | null;
userEmail?: string | null;
resourceNames: string;
resources: { resourceId: number; name: string; niceId: string }[];
};
type FormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreated?: (result: CreatedVirtualApiKey) => void;
};
export default function CreateVirtualApiKeyForm({
open,
setOpen,
onCreated
}: FormProps) {
const { org } = useOrgContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const [credential, setCredential] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [allResources, setAllResources] = useState(false);
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
const [selectedResources, setSelectedResources] = useState<
SelectedResource[]
>([]);
const [pendingBudgetRows, setPendingBudgetRows] = useState<BudgetRow[]>([]);
const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false);
const [sendEmail, setSendEmail] = useState(false);
const [sendToAttributedUser, setSendToAttributedUser] = useState(false);
const [emailTags, setEmailTags] = useState<Tag[]>([]);
const formSchema = z.object({
name: z.string().min(1),
description: z.string().optional()
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: ""
}
});
function resetLocalState() {
setCredential(null);
setLoading(false);
setAllResources(false);
setSelectedUser(null);
setSelectedResources([]);
setPendingBudgetRows([]);
setAttemptedBudgetsSave(false);
setSendEmail(false);
setSendToAttributedUser(false);
setEmailTags([]);
form.reset();
}
function handleFormSubmit(values: z.infer<typeof formSchema>) {
const { conflictingKeys, invalidAmountKeys } =
getBudgetRowsErrors(pendingBudgetRows);
if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) {
setAttemptedBudgetsSave(true);
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: conflictingKeys.size
? t("aiBudgetConflictError")
: t("aiBudgetInvalidAmountError")
});
return;
}
if (
env.email.emailEnabled &&
sendEmail &&
!sendToAttributedUser &&
emailTags.length === 0
) {
toast({
variant: "destructive",
title: t("virtualApiKeysEmailRecipientsRequired"),
description: t("virtualApiKeysEmailRecipientsRequired")
});
return;
}
return onSubmit(values);
}
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.put<AxiosResponse<CreateOrEditVirtualApiKeyResponse>>(
`/org/${org.org.orgId}/virtual-api-key`,
{
name: values.name,
description: values.description || null,
userId: selectedUser?.id ?? null,
allResources,
resourceIds: allResources
? []
: selectedResources.map((r) => r.resourceId),
sendEmail: env.email.emailEnabled && sendEmail,
sendToAttributedUser:
env.email.emailEnabled &&
sendEmail &&
sendToAttributedUser,
emails:
env.email.emailEnabled && sendEmail
? emailTags.map((tag) => tag.text)
: []
}
)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("virtualApiKeysErrorCreate"),
description: formatAxiosError(
e,
t("virtualApiKeysErrorCreateDescription")
)
});
});
if (res?.data.data.virtualApiKey) {
const key = res.data.data.virtualApiKey;
if (key.secret) {
setCredential(
formatVirtualApiKeyCredential(
key.virtualApiKeyId,
key.secret
)
);
}
const pendingBudgets = pendingBudgetRows.filter(
(budget) => budget.amount.trim() !== ""
);
if (pendingBudgets.length > 0) {
try {
await Promise.all(
pendingBudgets.map((budget) =>
api.put(`/org/${org.org.orgId}/ai-budget`, {
virtualApiKeyId: key.virtualApiKeyId,
amount: Number(budget.amount),
unit: budget.unit,
period: budget.period
})
)
);
} catch (e) {
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: formatAxiosError(e, t("aiBudgetErrorSave"))
});
}
}
const resourceLookup = new Map(
selectedResources.map((r) => [
r.resourceId,
{ name: r.name, niceId: r.niceId }
])
);
const resourceNames = key.allResources
? t("virtualApiKeysAllResources")
: key.resourceIds
.map((id) => resourceLookup.get(id)?.name)
.filter(Boolean)
.join(", ") || t("virtualApiKeysNoResources");
onCreated?.({
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: selectedUser?.text ?? null,
username: null,
userEmail: null,
resourceNames,
resources: key.resourceIds.map((id) => ({
resourceId: id,
name: resourceLookup.get(id)?.name ?? String(id),
niceId: resourceLookup.get(id)?.niceId ?? ""
}))
});
}
setLoading(false);
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
resetLocalState();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("virtualApiKeysCreate")}</CredenzaTitle>
<CredenzaDescription>
{t("virtualApiKeysCreateDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="flex flex-col gap-y-4 px-1">
{!credential && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(
handleFormSubmit
)}
className="space-y-4"
id="virtual-api-key-form"
>
<HorizontalTabs
clientSide={true}
defaultTab={0}
items={[
{ title: t("general"), href: "#" },
{
title: t(
"virtualApiKeysInferenceBudget"
),
href: "#"
}
]}
>
<div className="space-y-4 mt-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"virtualApiKeysName"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"virtualApiKeysDescriptionOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>
{t(
"virtualApiKeysAssociateUserOptional"
)}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!selectedUser &&
"text-muted-foreground"
)}
>
{selectedUser?.text
? selectedUser.text
: t(
"userSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<UserSelector
orgId={
org.org.orgId
}
selectedUser={
selectedUser
}
onSelectUser={
setSelectedUser
}
/>
</PopoverContent>
</Popover>
<p className="text-sm text-muted-foreground">
{t(
"virtualApiKeysAssociateUserDescription"
)}
</p>
</div>
<div className="space-y-3">
<div className="flex items-start space-x-2">
<Checkbox
id="all-resources"
checked={allResources}
onCheckedChange={(
val
) => {
setAllResources(
val as boolean
);
if (val) {
setSelectedResources(
[]
);
}
}}
className="mt-0.5"
/>
<div className="space-y-1">
<label
htmlFor="all-resources"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
"virtualApiKeysAllResources"
)}
</label>
<p className="text-sm text-muted-foreground">
{t(
"virtualApiKeysAllResourcesDescription"
)}
</p>
</div>
</div>
{!allResources && (
<div className="space-y-2">
<FormLabel>
{t(
"virtualApiKeysSelectResources"
)}
</FormLabel>
<Popover>
<PopoverTrigger
asChild
>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selectedResources.length ===
0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatMultiResourcesSelectorLabel(
selectedResources,
t,
"virtualApiKeysSelectResourcesPlaceholder"
)}
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<MultiResourcesSelector
orgId={
org.org
.orgId
}
selectedResources={
selectedResources
}
onSelectionChange={
setSelectedResources
}
protocol="inference"
showClear={
selectedResources.length >
0
}
onClear={() =>
setSelectedResources(
[]
)
}
/>
</PopoverContent>
</Popover>
<FormDescription>
{t(
"virtualApiKeysSelectResourcesDescription"
)}
</FormDescription>
</div>
)}
</div>
<VirtualApiKeyEmailSection
emailEnabled={
env.email.emailEnabled
}
mode="create"
sendEmail={sendEmail}
onSendEmailChange={setSendEmail}
sendToAttributedUser={
sendToAttributedUser
}
onSendToAttributedUserChange={
setSendToAttributedUser
}
hasAssociatedUser={
!!selectedUser
}
emailTags={emailTags}
onEmailTagsChange={setEmailTags}
/>
</div>
<div className="space-y-4 mt-4">
<BudgetRowsFields
rows={pendingBudgetRows}
onChange={setPendingBudgetRows}
attemptedSave={
attemptedBudgetsSave
}
/>
</div>
</HorizontalTabs>
</form>
</Form>
)}
{credential && (
<div className="space-y-4">
<p>{t("virtualApiKeysCopyKey")}</p>
<CopyTextBox
text={credential}
wrapText={false}
/>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="button"
onClick={form.handleSubmit(handleFormSubmit)}
loading={loading}
disabled={credential !== null || loading}
>
{t("virtualApiKeysCreateButton")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useState } from "react";
export type DescribedSelectOption<TValue extends string> = {
value: TValue;
title: string;
description: string;
};
type DescribedSelectProps<TValue extends string> = {
options: ReadonlyArray<DescribedSelectOption<TValue>>;
value: TValue;
onChange: (value: TValue) => void;
searchPlaceholder: string;
emptyMessage: string;
placeholder?: string;
disabled?: boolean;
className?: string;
};
export function DescribedSelect<TValue extends string>({
options,
value,
onChange,
searchPlaceholder,
emptyMessage,
placeholder,
disabled,
className
}: DescribedSelectProps<TValue>) {
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.value === value);
return (
<div className={cn("w-full", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"h-9 w-full justify-between font-normal",
!selected && "text-muted-foreground"
)}
>
<span className="truncate text-left">
{selected?.title ?? placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={`${option.value} ${option.title} ${option.description}`}
onSelect={() => {
onChange(option.value);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
option.value === value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
{option.title}
</span>
<span className="text-muted-foreground text-xs leading-snug">
{option.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
+150 -128
View File
@@ -1,6 +1,6 @@
"use client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -38,20 +38,19 @@ import { useQuery } from "@tanstack/react-query";
import { AxiosResponse } from "axios";
import {
AlertCircle,
Building2,
Check,
CheckCircle2,
CheckIcon,
ChevronsUpDown,
ExternalLink,
KeyRound,
Zap
Globe,
KeyRound
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@/hooks/usePaidStatus";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { toUnicode } from "punycode";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
type AvailableOption = {
@@ -164,8 +163,19 @@ export default function DomainPicker({
const [selectedProvidedDomain, setSelectedProvidedDomain] =
useState<AvailableOption | null>(null);
// Only run the initial base-domain selection once the domains have
// loaded. This must not re-run on later `defaultDomainId`/`defaultSubdomain`
// changes, because selecting a provided (namespace) domain calls
// onDomainChange(null), which the parent form echoes back as
// defaultDomainId/defaultSubdomain becoming undefined — re-running this
// effect on that change would immediately snap the selector back to the
// organization domain, making provided domains unselectable whenever one
// was already set.
const didSelectInitialDomainRef = useRef(false);
useEffect(() => {
if (!loadingDomains) {
if (!loadingDomains && !didSelectInitialDomainRef.current) {
didSelectInitialDomainRef.current = true;
let domainOptionToSelect: DomainOption | null = null;
if (organizationDomains.length > 0) {
// Select the first organization domain or the one provided from props
@@ -494,6 +504,30 @@ export default function DomainPicker({
const hasMoreProvided =
sortedAvailableOptions.length > providedDomainsShown;
const noDomainsAvailable =
!loadingDomains &&
organizationDomains.length === 0 &&
(build === "oss" || hideFreeDomain || requiresPaywall);
if (noDomainsAvailable) {
return (
<Alert>
<Globe className="h-4 w-4" />
<AlertTitle>
{t("domainPickerNoDomainsAvailableTitle")}
</AlertTitle>
<AlertDescription className="space-y-3">
<p>{t("domainPickerNoDomainsAvailableDescription")}</p>
<Button asChild size="sm" variant="outline">
<Link href={`/${orgId}/settings/domains`}>
{t("domainPickerNoDomainsAvailableAction")}
</Link>
</Button>
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -557,7 +591,6 @@ export default function DomainPicker({
)}
</p>
<PaidFeaturesAlert
showBookADemo={false}
tiers={
tierMatrix[
TierFeature.WildcardSubdomain
@@ -573,61 +606,72 @@ export default function DomainPicker({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedBaseDomain ? (
<div className="flex items-center gap-x-2 min-w-0 flex-1">
{selectedBaseDomain.type ===
"organization" ? null : (
<Zap className="h-4 w-4 shrink-0" />
)}
<span className="truncate">
{selectedBaseDomain.domain}
</span>
{selectedBaseDomain.verified &&
selectedBaseDomain.domainType !==
"wildcard" && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)}
</div>
) : (
t("domainPickerSelectBaseDomain")
className={cn(
"h-9 w-full justify-between font-normal",
!selectedBaseDomain &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{selectedBaseDomain
? selectedBaseDomain.domain
: t("domainPickerSelectBaseDomain")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command className="rounded-lg">
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder={t("domainPickerSearchDomains")}
className="border-0 focus:ring-0"
/>
<CommandEmpty className="py-6 text-center">
<div className="text-muted-foreground text-sm">
<CommandList>
<CommandEmpty>
{t("domainPickerNoDomainsFound")}
</div>
</CommandEmpty>
{organizationDomains.length > 0 && (
<>
</CommandEmpty>
{organizationDomains.length > 0 && (
<CommandGroup
heading={t(
"domainPickerOrganizationDomains"
)}
className="py-2"
>
<CommandList>
{organizationDomains.map(
(orgDomain) => (
{organizationDomains.map(
(orgDomain) => {
const description =
orgDomain.type ===
"wildcard"
? t(
"domainPickerManual"
)
: `${orgDomain.type.toUpperCase()} · ${
orgDomain.verified
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)
}`;
const optionId = `org-${orgDomain.domainId}`;
return (
<CommandItem
key={`org-${orgDomain.domainId}`}
key={optionId}
value={`${orgDomain.baseDomain} ${description}`}
disabled={
!orgDomain.verified
}
onSelect={() =>
handleBaseDomainSelect(
{
id: `org-${orgDomain.domainId}`,
id: optionId,
domain: orgDomain.baseDomain,
type: "organization",
verified:
@@ -639,80 +683,63 @@ export default function DomainPicker({
}
)
}
className="mx-2 rounded-md"
disabled={
!orgDomain.verified
}
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Building2 className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{
orgDomain.baseDomain
}
</span>
<span className="text-xs text-muted-foreground">
{orgDomain.type ===
"wildcard" ? (
t(
"domainPickerManual"
)
) : (
<>
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)}
</>
)}
</span>
</div>
<Check
<CheckIcon
className={cn(
"h-4 w-4 text-primary",
"mr-2 h-4 w-4 shrink-0",
selectedBaseDomain?.id ===
`org-${orgDomain.domainId}`
optionId
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
{
orgDomain.baseDomain
}
</span>
<span className="text-muted-foreground text-xs leading-snug">
{
description
}
</span>
</div>
</CommandItem>
)
)}
</CommandList>
</CommandGroup>
{(build === "saas" ||
build === "enterprise") &&
!hideFreeDomain && (
<CommandSeparator className="my-2" />
);
}
)}
</>
)}
{(build === "saas" || build === "enterprise") &&
!hideFreeDomain && (
<CommandGroup
heading={
build === "enterprise"
? t(
"domainPickerProvidedDomains"
)
: t(
"domainPickerFreeDomains"
)
}
className="py-2"
>
<CommandList>
</CommandGroup>
)}
{organizationDomains.length > 0 &&
(build === "saas" ||
build === "enterprise") &&
!hideFreeDomain && <CommandSeparator />}
{(build === "saas" ||
build === "enterprise") &&
!hideFreeDomain && (
<CommandGroup
heading={
build === "enterprise"
? t(
"domainPickerProvidedDomains"
)
: t(
"domainPickerFreeDomains"
)
}
>
<CommandItem
key="provided-search"
value={`${
build === "enterprise"
? t(
"domainPickerProvidedDomain"
)
: t(
"domainPickerFreeProvidedDomain"
)
} ${t("domainPickerSearchForAvailableDomains")}`}
disabled={requiresPaywall}
onSelect={() =>
handleBaseDomainSelect({
id: "provided-search",
@@ -728,14 +755,18 @@ export default function DomainPicker({
type: "provided-search"
})
}
className="mx-2 rounded-md"
disabled={requiresPaywall}
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Zap className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
selectedBaseDomain?.id ===
"provided-search"
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
{build ===
"enterprise"
? t(
@@ -745,25 +776,16 @@ export default function DomainPicker({
"domainPickerFreeProvidedDomain"
)}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs leading-snug">
{t(
"domainPickerSearchForAvailableDomains"
)}
</span>
</div>
<Check
className={cn(
"h-4 w-4 text-primary",
selectedBaseDomain?.id ===
"provided-search"
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
</CommandList>
</CommandGroup>
)}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
+30 -1
View File
@@ -15,6 +15,8 @@ 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 { aiBudgetQueries } from "@app/lib/queries";
import { useQueryClient } from "@tanstack/react-query";
import type { Role } from "@server/db";
import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
@@ -26,6 +28,7 @@ import {
RoleForm,
type RoleFormValues
} from "./RoleForm";
import { saveBudgetRows } from "./BudgetsEditor";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type EditRoleFormProps = {
@@ -44,6 +47,7 @@ export default function EditRoleForm({
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const queryClient = useQueryClient();
const [loading, startTransition] = useTransition();
async function onSubmit(values: RoleFormValues) {
@@ -55,7 +59,7 @@ export default function EditRoleForm({
payload.name = values.name;
payload.description = values.description || undefined;
}
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
if (isPaidUser(tierMatrix.roleBasedSSHControls)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
@@ -83,6 +87,31 @@ export default function EditRoleForm({
});
if (res && res.status === 200) {
if (values.budgets) {
try {
const scope = { type: "role" as const, id: role.roleId };
const existingBudgets = await queryClient.fetchQuery(
aiBudgetQueries.scoped({ scope })
);
await saveBudgetRows({
api,
orgId: role.orgId,
scope,
existingBudgets,
rows: values.budgets
});
await queryClient.invalidateQueries(
aiBudgetQueries.scoped({ scope })
);
} catch (e) {
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: formatAxiosError(e, t("aiBudgetErrorSave"))
});
}
}
toast({
variant: "default",
title: t("accessRoleUpdated"),
+604
View File
@@ -0,0 +1,604 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage
} from "@app/components/ui/form";
import { Label } from "@app/components/ui/label";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { formatAxiosError, createApiClient } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { Checkbox } from "@app/components/ui/checkbox";
import { useTranslations } from "next-intl";
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
import {
MultiResourcesSelector,
formatMultiResourcesSelectorLabel
} from "@app/components/multi-resource-selector";
import type { SelectedResource } from "@app/components/resource-selector";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import CopyTextBox from "@app/components/CopyTextBox";
import type { CreatedVirtualApiKey } from "@app/components/CreateVirtualApiKeyForm";
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import {
BudgetRowsFields,
getBudgetRowsErrors,
rowsFromBudgets,
saveBudgetRows,
type BudgetRow
} from "@app/components/BudgetsEditor";
import { aiBudgetQueries } from "@app/lib/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import VirtualApiKeyEmailSection from "@app/components/VirtualApiKeyEmailSection";
import type { Tag } from "@app/components/tags/tag-input";
type FormProps = {
open: boolean;
setOpen: (open: boolean) => void;
virtualApiKey: CreatedVirtualApiKey | null;
onUpdated?: (result: CreatedVirtualApiKey) => void;
};
function resourcesFromRow(key: CreatedVirtualApiKey): SelectedResource[] {
return key.resources.map((r) => ({
resourceId: r.resourceId,
name: r.name,
niceId: r.niceId,
fullDomain: null,
ssl: false,
wildcard: false
}));
}
function userFromRow(key: CreatedVirtualApiKey): SelectedUser | null {
if (!key.userId) {
return null;
}
return {
id: key.userId,
text: getUserDisplayName({
email: key.userEmail,
name: key.userName,
username: key.username
})
};
}
export default function EditVirtualApiKeyForm({
open,
setOpen,
virtualApiKey,
onUpdated
}: FormProps) {
const { org } = useOrgContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const queryClient = useQueryClient();
const [loading, setLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
const [selectedResources, setSelectedResources] = useState<
SelectedResource[]
>([]);
const [credential, setCredential] = useState<string | null>(null);
const [credentialLoading, setCredentialLoading] = useState(false);
const [pendingBudgetRows, setPendingBudgetRows] = useState<BudgetRow[]>([]);
const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false);
const [sendEmail, setSendEmail] = useState(false);
const [sendToAttributedUser, setSendToAttributedUser] = useState(false);
const [emailTags, setEmailTags] = useState<Tag[]>([]);
const budgetScope = {
type: "virtualApiKey" as const,
id: virtualApiKey?.virtualApiKeyId ?? ""
};
const budgetsQuery = useQuery({
...aiBudgetQueries.scoped({ scope: budgetScope }),
enabled: open && !!virtualApiKey
});
const formSchema = z
.object({
allResources: z.boolean()
})
.superRefine((data, ctx) => {
if (!data.allResources && selectedResources.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("virtualApiKeysSelectResourcesRequired"),
path: ["allResources"]
});
}
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
allResources: false
}
});
const allResources = form.watch("allResources");
useEffect(() => {
if (!open || !virtualApiKey) {
return;
}
setLoading(false);
setSelectedUser(userFromRow(virtualApiKey));
setSelectedResources(
virtualApiKey.allResources ? [] : resourcesFromRow(virtualApiKey)
);
setSendEmail(false);
setSendToAttributedUser(false);
setEmailTags([]);
form.reset({
allResources: virtualApiKey.allResources
});
let cancelled = false;
setCredentialLoading(true);
setCredential(null);
api.get<AxiosResponse<GetVirtualApiKeyResponse>>(
`/virtual-api-key/${virtualApiKey.virtualApiKeyId}`
)
.then((res) => {
if (cancelled) {
return;
}
const secret = res.data.data.virtualApiKey.secret;
if (secret) {
setCredential(
formatVirtualApiKeyCredential(
virtualApiKey.virtualApiKeyId,
secret
)
);
} else {
toast({
variant: "destructive",
title: t("virtualApiKeysErrorFetchSecret"),
description: t(
"virtualApiKeysErrorFetchSecretDescription"
)
});
}
})
.catch((e) => {
if (cancelled) {
return;
}
toast({
variant: "destructive",
title: t("virtualApiKeysErrorFetchSecret"),
description: formatAxiosError(
e,
t("virtualApiKeysErrorFetchSecretDescription")
)
});
})
.finally(() => {
if (!cancelled) {
setCredentialLoading(false);
}
});
return () => {
cancelled = true;
};
}, [open, virtualApiKey, form]);
useEffect(() => {
if (!open || !budgetsQuery.data) {
return;
}
setPendingBudgetRows(rowsFromBudgets(budgetsQuery.data));
setAttemptedBudgetsSave(false);
}, [open, budgetsQuery.data]);
function handleFormSubmit(values: z.infer<typeof formSchema>) {
const { conflictingKeys, invalidAmountKeys } =
getBudgetRowsErrors(pendingBudgetRows);
if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) {
setAttemptedBudgetsSave(true);
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: conflictingKeys.size
? t("aiBudgetConflictError")
: t("aiBudgetInvalidAmountError")
});
return;
}
if (
env.email.emailEnabled &&
sendEmail &&
!sendToAttributedUser &&
emailTags.length === 0
) {
toast({
variant: "destructive",
title: t("virtualApiKeysEmailRecipientsRequired"),
description: t("virtualApiKeysEmailRecipientsRequired")
});
return;
}
return onSubmit(values);
}
async function onSubmit(values: z.infer<typeof formSchema>) {
if (!virtualApiKey) {
return;
}
setLoading(true);
const res = await api
.post<AxiosResponse<CreateOrEditVirtualApiKeyResponse>>(
`/virtual-api-key/${virtualApiKey.virtualApiKeyId}`,
{
userId: selectedUser?.id ?? null,
allResources: values.allResources,
resourceIds: values.allResources
? []
: selectedResources.map((r) => r.resourceId),
sendEmail: env.email.emailEnabled && sendEmail,
sendToAttributedUser:
env.email.emailEnabled &&
sendEmail &&
sendToAttributedUser,
emails:
env.email.emailEnabled && sendEmail
? emailTags.map((tag) => tag.text)
: []
}
)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("virtualApiKeysErrorUpdate"),
description: formatAxiosError(
e,
t("virtualApiKeysErrorUpdateDescription")
)
});
});
if (res?.data.data.virtualApiKey) {
const key = res.data.data.virtualApiKey;
try {
await saveBudgetRows({
api,
orgId: virtualApiKey.orgId,
scope: budgetScope,
existingBudgets: budgetsQuery.data ?? [],
rows: pendingBudgetRows
});
await queryClient.invalidateQueries(
aiBudgetQueries.scoped({ scope: budgetScope })
);
} catch (e) {
toast({
variant: "destructive",
title: t("aiBudgetErrorSave"),
description: formatAxiosError(e, t("aiBudgetErrorSave"))
});
}
const resourceLookup = new Map(
selectedResources.map((r) => [
r.resourceId,
{ name: r.name, niceId: r.niceId }
])
);
const resourceNames = key.allResources
? t("virtualApiKeysAllResources")
: key.resourceIds
.map((id) => resourceLookup.get(id)?.name)
.filter(Boolean)
.join(", ") || t("virtualApiKeysNoResources");
onUpdated?.({
...virtualApiKey,
userId: key.userId,
allResources: key.allResources,
resourceIds: key.resourceIds,
userName: selectedUser?.text ?? null,
username: null,
userEmail: null,
resourceNames,
resources: key.resourceIds.map((id) => ({
resourceId: id,
name: resourceLookup.get(id)?.name ?? String(id),
niceId: resourceLookup.get(id)?.niceId ?? ""
}))
});
toast({
title: t("virtualApiKeysUpdated"),
description: t("virtualApiKeysUpdatedDescription")
});
setOpen(false);
}
setLoading(false);
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("virtualApiKeysEdit")}</CredenzaTitle>
<CredenzaDescription>
{t("virtualApiKeysEditDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="flex flex-col gap-y-4 px-1">
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-4"
id="edit-virtual-api-key-form"
>
<HorizontalTabs
clientSide={true}
defaultTab={0}
items={[
{ title: t("general"), href: "#" },
{
title: t(
"virtualApiKeysInferenceBudget"
),
href: "#"
}
]}
>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>
{t(
"virtualApiKeysAssociateUserOptional"
)}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!selectedUser &&
"text-muted-foreground"
)}
>
{selectedUser?.text
? selectedUser.text
: t("userSelect")}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<UserSelector
orgId={org.org.orgId}
selectedUser={
selectedUser
}
onSelectUser={
setSelectedUser
}
/>
</PopoverContent>
</Popover>
<p className="text-sm text-muted-foreground">
{t(
"virtualApiKeysAssociateUserDescription"
)}
</p>
</div>
<div className="space-y-3">
<FormField
control={form.control}
name="allResources"
render={({ field }) => (
<FormItem>
<div className="flex items-start space-x-2">
<FormControl>
<Checkbox
id="edit-all-resources"
checked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(
val as boolean
);
if (
val
) {
setSelectedResources(
[]
);
}
}}
className="mt-0.5"
/>
</FormControl>
<div className="space-y-1">
<label
htmlFor="edit-all-resources"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
"virtualApiKeysAllResources"
)}
</label>
<p className="text-sm text-muted-foreground">
{t(
"virtualApiKeysAllResourcesDescription"
)}
</p>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
{!allResources && (
<div className="space-y-2">
<Label>
{t(
"virtualApiKeysSelectResources"
)}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selectedResources.length ===
0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatMultiResourcesSelectorLabel(
selectedResources,
t,
"virtualApiKeysSelectResourcesPlaceholder"
)}
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<MultiResourcesSelector
orgId={
org.org
.orgId
}
selectedResources={
selectedResources
}
onSelectionChange={
setSelectedResources
}
protocol="inference"
showClear={
selectedResources.length >
0
}
onClear={() =>
setSelectedResources(
[]
)
}
/>
</PopoverContent>
</Popover>
<FormDescription>
{t(
"virtualApiKeysSelectResourcesRequired"
)}
</FormDescription>
</div>
)}
</div>
<VirtualApiKeyEmailSection
emailEnabled={
env.email.emailEnabled
}
mode="edit"
sendEmail={sendEmail}
onSendEmailChange={setSendEmail}
sendToAttributedUser={
sendToAttributedUser
}
onSendToAttributedUserChange={
setSendToAttributedUser
}
hasAssociatedUser={!!selectedUser}
emailTags={emailTags}
onEmailTagsChange={setEmailTags}
/>
</div>
<div className="space-y-4 mt-4">
<BudgetRowsFields
rows={pendingBudgetRows}
onChange={setPendingBudgetRows}
disabled={budgetsQuery.isLoading}
attemptedSave={attemptedBudgetsSave}
/>
</div>
</HorizontalTabs>
</form>
</Form>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="edit-virtual-api-key-form"
loading={loading}
disabled={loading || !virtualApiKey}
>
{t("virtualApiKeysSaveButton")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Label } from "@app/components/ui/label";
import {
RolesSelector,
type SelectedRole
} from "@app/components/roles-selector";
import {
UsersSelector,
type SelectedUser
} from "@app/components/users-selector";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { EmailIdentityKeysResponse } from "@server/routers/virtualApiKey/types";
import { AxiosResponse } from "axios";
import { useState } from "react";
import { useTranslations } from "next-intl";
type EmailIdentityKeysFormProps = {
orgId: string;
open: boolean;
setOpen: (open: boolean) => void;
};
export default function EmailIdentityKeysForm({
orgId,
open,
setOpen
}: EmailIdentityKeysFormProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [sendToAll, setSendToAll] = useState(false);
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
const [selectedRoles, setSelectedRoles] = useState<SelectedRole[]>([]);
const [loading, setLoading] = useState(false);
function resetState() {
setSendToAll(false);
setSelectedUsers([]);
setSelectedRoles([]);
setLoading(false);
}
async function onSubmit() {
if (
!sendToAll &&
selectedUsers.length === 0 &&
selectedRoles.length === 0
) {
toast({
variant: "destructive",
title: t("virtualApiKeysEmailIdentityRecipientsRequired"),
description: t("virtualApiKeysEmailIdentityRecipientsRequired")
});
return;
}
setLoading(true);
try {
const res = await api.post<
AxiosResponse<EmailIdentityKeysResponse>
>(`/org/${orgId}/virtual-api-keys/email-identity-keys`, {
sendToAll,
userIds: sendToAll ? [] : selectedUsers.map((user) => user.id),
roleIds: sendToAll
? []
: selectedRoles.map((role) => Number(role.id))
});
const { sent, skipped } = res.data.data;
toast({
title: t("virtualApiKeysEmailIdentitySuccess"),
description:
skipped > 0
? `${t("virtualApiKeysEmailIdentitySuccessDescription", { sent })} ${t("virtualApiKeysEmailIdentitySkipped", { skipped })}`
: t("virtualApiKeysEmailIdentitySuccessDescription", {
sent
})
});
setOpen(false);
resetState();
} catch (e) {
toast({
variant: "destructive",
title: t("virtualApiKeysEmailIdentityError"),
description: formatAxiosError(
e,
t("virtualApiKeysEmailIdentityErrorDescription")
)
});
}
setLoading(false);
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
resetState();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("virtualApiKeysEmailIdentity")}
</CredenzaTitle>
<CredenzaDescription>
{t("virtualApiKeysEmailIdentityDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<div className="flex items-start space-x-2">
<Checkbox
id="email-identity-send-all"
checked={sendToAll}
onCheckedChange={(val) =>
setSendToAll(val === true)
}
className="mt-0.5"
/>
<div className="space-y-1">
<label
htmlFor="email-identity-send-all"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("virtualApiKeysEmailIdentitySendAll")}
</label>
<p className="text-sm text-muted-foreground">
{t(
"virtualApiKeysEmailIdentitySendAllDescription"
)}
</p>
</div>
</div>
<div className="space-y-2">
<Label>
{t("virtualApiKeysEmailIdentitySelectUsers")}
</Label>
<UsersSelector
orgId={orgId}
selectedUsers={selectedUsers}
onSelectUsers={setSelectedUsers}
disabled={sendToAll}
/>
</div>
<div className="space-y-2">
<Label>
{t("virtualApiKeysEmailIdentitySelectRoles")}
</Label>
<RolesSelector
orgId={orgId}
selectedRoles={selectedRoles}
onSelectRoles={setSelectedRoles}
disabled={sendToAll}
/>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="button"
onClick={onSubmit}
loading={loading}
disabled={loading}
>
{t("virtualApiKeysEmailIdentitySubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+26 -4
View File
@@ -1,6 +1,6 @@
"use client";
import UptimeMiniBar from "@app/components/UptimeMiniBar";
import { UptimeMiniBar } from "@app/components/UptimeMiniBar";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import HealthCheckCredenza, {
@@ -51,6 +51,8 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
type StandaloneHealthChecksTableProps = {
orgId: string;
@@ -81,6 +83,8 @@ function formatTarget(row: HealthCheckRow): string {
return `${scheme}://${host}${port}${path}`;
}
const HEALTH_CHECK_STATUS_HISTORY_DAYS = 30;
export default function HealthChecksTable({
orgId,
healthChecks,
@@ -157,6 +161,20 @@ export default function HealthChecksTable({
const rows = healthChecks;
const healthCheckIds = useMemo(
() => rows.map((r) => r.targetHealthCheckId),
[rows]
);
const statusHistoryQuery = useQuery({
...orgQueries.batchedHealthCheckStatusHistory({
orgId,
healthCheckIds,
days: HEALTH_CHECK_STATUS_HISTORY_DAYS
}),
enabled: healthCheckIds.length > 0
});
function refreshList() {
startRefresh(() => {
router.refresh();
@@ -547,9 +565,13 @@ export default function HealthChecksTable({
cell: ({ row }) => {
return (
<UptimeMiniBar
orgId={orgId}
healthCheckId={row.original.targetHealthCheckId}
days={30}
isLoading={statusHistoryQuery.isLoading}
data={
statusHistoryQuery.data?.[
row.original.targetHealthCheckId
]
}
days={HEALTH_CHECK_STATUS_HISTORY_DAYS}
/>
);
}
+1
View File
@@ -47,6 +47,7 @@ export function HorizontalTabs({
.replace("{userId}", params.userId as string)
.replace("{clientId}", params.clientId as string)
.replace("{apiKeyId}", params.apiKeyId as string)
.replace("{providerId}", params.providerId as string)
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import EmailIdentityKeysForm from "@app/components/EmailIdentityKeysForm";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
import { ArrowRight, ExternalLink, Globe, KeyRound, Mail } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useState } from "react";
const EXAMPLE_IDENTITY_KEY = formatVirtualApiKeyCredential(
"k7m2n9qx",
"a8f3c1e0b5d24791"
);
type IdentityKeysSplashProps = {
orgId: string;
};
export default function IdentityKeysSplash({ orgId }: IdentityKeysSplashProps) {
const t = useTranslations();
const { env } = useEnvContext();
const [emailOpen, setEmailOpen] = useState(false);
const emailEnabled = env.email.emailEnabled;
const dashboardUrl = env.app.dashboardUrl?.replace(/\/$/, "") ?? "";
const keysPath = `/${orgId}/keys`;
const keysUrl = dashboardUrl ? `${dashboardUrl}${keysPath}` : keysPath;
return (
<>
<SettingsSection>
<SettingsSectionBody>
<div className="flex flex-col items-center text-center py-6 md:py-10 px-2">
<KeyRound className="h-8 w-8 text-primary" />
<h2 className="mt-4 text-2xl font-semibold tracking-tight max-w-xl">
{t("virtualApiKeysIdentitySplashTitle")}
</h2>
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
{t("virtualApiKeysIdentitySplashDescription")}
</p>
<div className="mt-8 w-full max-w-lg text-left space-y-3">
<p className="text-sm font-medium text-center">
{t("virtualApiKeysIdentitySplashRetrieveTitle")}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<Globe className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t(
"virtualApiKeysIdentitySplashRetrieveResource"
)}
</span>
</li>
<li className="flex items-start gap-2">
<ExternalLink className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t.rich(
"virtualApiKeysIdentitySplashRetrievePage",
{
url: () => (
<Link
href={keysPath}
className="font-medium text-foreground underline underline-offset-4 break-all"
>
{keysUrl}
</Link>
)
}
)}
</span>
</li>
</ul>
</div>
<p className="mt-8 text-sm text-muted-foreground max-w-lg">
{t("virtualApiKeysIdentitySplashManual")}
</p>
{!emailEnabled && (
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
{t(
"virtualApiKeysEmailSmtpRequiredDescription"
)}
</p>
)}
</div>
</SettingsSectionBody>
<SettingsSectionFooter className="justify-center md:justify-center">
<Button
disabled={!emailEnabled}
onClick={() => setEmailOpen(true)}
>
{t("virtualApiKeysEmailIdentity")}
</Button>
<Button asChild variant="outline">
<Link href={`/${orgId}/settings/virtual-api-keys/keys`}>
{t("virtualApiKeysIdentitySplashGoToVirtual")}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</SettingsSectionFooter>
</SettingsSection>
<EmailIdentityKeysForm
orgId={orgId}
open={emailOpen}
setOpen={setEmailOpen}
/>
</>
);
}
+62 -40
View File
@@ -1,41 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import { generateOidcUrlProxy } from "@app/actions/server";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import {
generateOidcUrlProxy,
type GenerateOidcUrlResponse
} from "@app/actions/server";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import { setClientCookie } from "@app/lib/setClientCookie";
import { useTranslations } from "next-intl";
import {
redirect as redirectTo,
useParams,
useRouter,
useSearchParams
} from "next/navigation";
import { useRouter } from "next/navigation";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useEffect, useState, useTransition } from "react";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
lastUsed?: boolean;
};
type IdpLoginButtonsProps = {
idps: LoginFormIDP[];
redirect?: string;
orgId?: string;
passOrgIdToOidcUrl?: boolean;
};
export default function IdpLoginButtons({
idps,
redirect,
orgId
orgId,
passOrgIdToOidcUrl = true
}: IdpLoginButtonsProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const t = useTranslations();
const params = useSearchParams();
@@ -52,23 +52,35 @@ export default function IdpLoginButtons({
}
}, []);
const [loading, startTransition] = useTransition();
async function loginWithIdp(idpId: number) {
setLoading(true);
setError(null);
setClientCookie(
LAST_USED_IDP_COOKIE_NAME,
JSON.stringify({
orgId,
idpId
}),
{
sameSite: "Lax"
}
);
let redirectToUrl: string | undefined;
try {
console.log("generating", idpId, redirect || "/", orgId);
const oidcOrgId = passOrgIdToOidcUrl ? orgId : undefined;
console.log("generating", idpId, redirect || "/", oidcOrgId);
const safeRedirect = cleanRedirect(redirect || "/");
const response = await generateOidcUrlProxy(
idpId,
safeRedirect,
orgId
oidcOrgId
);
if (response.error) {
setError(response.message);
setLoading(false);
return;
}
@@ -84,7 +96,6 @@ export default function IdpLoginButtons({
"An unexpected error occurred. Please try again."
})
);
setLoading(false);
}
if (redirectToUrl) {
@@ -106,41 +117,52 @@ export default function IdpLoginButtons({
<div className="space-y-4">
{params.get("gotoapp") ? (
<>
<Button
type="button"
className="w-full"
onClick={() => {
goToApp();
}}
>
{t("continueToApplication")}
</Button>
</>
<Button
type="button"
className="w-full"
onClick={() => {
goToApp();
}}
>
{t("continueToApplication")}
</Button>
) : (
<>
{idps.map((idp) => {
const effectiveType =
idp.variant || idp.name.toLowerCase();
idps.map((idp) => {
const effectiveType =
idp.variant || idp.name.toLowerCase();
return (
return (
<div className="w-full relative" key={idp.idpId}>
<Button
key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
loginWithIdp(idp.idpId);
startTransition(() =>
loginWithIdp(idp.idpId)
);
}}
disabled={loading}
loading={loading}
>
<IdpTypeIcon type={effectiveType} size={16} />
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</Button>
);
})}
</>
{idp.lastUsed && (
<div className="absolute inset-0">
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
{t("idpLastUsed")}
</span>
</div>
)}
</div>
);
})
)}
</div>
</div>
+2
View File
@@ -23,6 +23,8 @@ export default function IdpTypeIcon({
}: Props) {
const effectiveType = (variant || type || "").toLowerCase();
console.log(`[IdpTypeIcon]`, { effectiveType, variant, type });
let src: string | null = null;
let defaultAlt = "";
+39 -8
View File
@@ -44,6 +44,7 @@ export default function InviteStatusCard({
| "user_does_not_exist"
| "not_logged_in"
| "user_limit_exceeded"
| "oidc_not_allowed"
>("rejected");
useEffect(() => {
@@ -69,6 +70,12 @@ export default function InviteStatusCard({
function cardType() {
if (error.includes("Invite is not for this user")) {
return "wrong_user";
} else if (
error.includes(
"Invites can only be accepted by internal users."
)
) {
return "oidc_not_allowed";
} else if (
error.includes(
"User does not exist. Please create an account first."
@@ -93,14 +100,20 @@ export default function InviteStatusCard({
setType(type);
if (!user && type === "user_does_not_exist") {
const inviteRedirect = encodeURIComponent(
`/invite?token=${tokenParam}`
);
const redirectUrl = email
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}`
: `/auth/signup?redirect=${inviteRedirect}`;
router.push(redirectUrl);
} else if (!user && type === "not_logged_in") {
const inviteRedirect = encodeURIComponent(
`/invite?token=${tokenParam}`
);
const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}`
: `/auth/login?redirect=${inviteRedirect}`;
router.push(redirectUrl);
} else {
setLoading(false);
@@ -112,17 +125,23 @@ export default function InviteStatusCard({
async function goToLogin() {
await api.post("/auth/logout", {});
const inviteRedirect = encodeURIComponent(
`/invite?token=${tokenParam}`
);
const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}`
: `/auth/login?redirect=${inviteRedirect}`;
router.push(redirectUrl);
}
async function goToSignup() {
await api.post("/auth/logout", {});
const inviteRedirect = encodeURIComponent(
`/invite?token=${tokenParam}`
);
const redirectUrl = email
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}`
: `/auth/signup?redirect=${inviteRedirect}`;
router.push(redirectUrl);
}
@@ -154,6 +173,14 @@ export default function InviteStatusCard({
<p className="text-center">{t("inviteCreateUser")}</p>
</div>
);
} else if (type === "oidc_not_allowed") {
return (
<div>
<p className="text-center mb-4">
{t("inviteErrorOidcNotAllowed")}
</p>
</div>
);
} else if (type === "user_limit_exceeded") {
return (
<div>
@@ -187,6 +214,10 @@ export default function InviteStatusCard({
);
} else if (type === "user_does_not_exist") {
return <Button onClick={goToSignup}>{t("createAnAccount")}</Button>;
} else if (type === "oidc_not_allowed") {
return (
<Button onClick={goToLogin}>{t("inviteLogInOtherUser")}</Button>
);
} else if (type === "user_limit_exceeded") {
return (
<Button
+11 -3
View File
@@ -1,10 +1,12 @@
import React from "react";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import type {
CommandBarNavSection,
SidebarNavSection
import {
orgLangingNavItems,
type CommandBarNavSection,
type SidebarNavSection
} from "@app/app/navigation";
import type { SidebarNavItem } from "@app/components/SidebarNav";
import { LayoutSidebar } from "@app/components/LayoutSidebar";
import { LayoutHeader } from "@app/components/LayoutHeader";
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
@@ -46,6 +48,10 @@ export async function Layout({
sidebarStateCookie === "collapsed" ||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
const launcherNavItems: SidebarNavItem[] = launcherMode
? orgLangingNavItems
: [];
return (
<CommandPaletteProvider
orgId={orgId}
@@ -77,6 +83,7 @@ export async function Layout({
orgId={orgId}
orgs={orgs}
navItems={navItems}
launcherNavItems={launcherNavItems}
showSidebar={showSidebar}
showTopBar={showTopBar}
launcherMode={launcherMode}
@@ -92,6 +99,7 @@ export async function Layout({
orgId={orgId}
orgs={orgs}
showViewAsAdmin={showViewAsAdmin}
launcherNavItems={launcherNavItems}
/>
)}
+43 -1
View File
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
@@ -13,6 +14,8 @@ import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherO
import { Button } from "@app/components/ui/button";
import { useTranslations } from "next-intl";
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
import type { SidebarNavItem } from "@app/components/SidebarNav";
import { cn } from "@app/lib/cn";
type LayoutHeaderProps = {
showTopBar: boolean;
@@ -20,6 +23,7 @@ type LayoutHeaderProps = {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
showViewAsAdmin?: boolean;
launcherNavItems?: SidebarNavItem[];
};
export function LayoutHeader({
@@ -27,13 +31,15 @@ export function LayoutHeader({
launcherMode = false,
orgId,
orgs,
showViewAsAdmin = false
showViewAsAdmin = false,
launcherNavItems = []
}: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const pathname = usePathname();
const logoWidth = isUnlocked()
? env.branding.logo?.navbar?.width || 98
@@ -85,6 +91,42 @@ export function LayoutHeader({
orgId={orgId}
orgs={orgs}
/>
{orgId
? launcherNavItems
.filter((item) => item.href)
.map((item) => {
const href =
item.href!.replace(
"{orgId}",
orgId
);
const isActive =
href === `/${orgId}`
? pathname === href
: pathname === href ||
pathname?.startsWith(
`${href}/`
);
return (
<Button
key={href}
variant="text"
size="sm"
className={cn(
"p-0",
isActive &&
"text-foreground font-medium underline-offset-4 underline"
)}
asChild
>
<Link href={href}>
{t(item.title)}
</Link>
</Button>
);
})
: null}
{showViewAsAdmin && orgId ? (
<Button
variant="text"
+55 -70
View File
@@ -4,7 +4,7 @@ import type { SidebarNavSection } from "@app/app/navigation";
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
import { OrgSelector } from "@app/components/OrgSelector";
import ProfileIcon from "@app/components/ProfileIcon";
import { SidebarNav } from "@app/components/SidebarNav";
import { SidebarNav, type SidebarNavItem } from "@app/components/SidebarNav";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Button } from "@app/components/ui/button";
import {
@@ -14,19 +14,18 @@ import {
SheetTitle,
SheetTrigger
} from "@app/components/ui/sheet";
import { useUserContext } from "@app/hooks/useUserContext";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Menu, Server, Settings, LayoutGrid } from "lucide-react";
import { Menu, Settings } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
interface LayoutMobileMenuProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
launcherNavItems?: SidebarNavItem[];
showSidebar: boolean;
showTopBar: boolean;
launcherMode?: boolean;
@@ -37,24 +36,15 @@ export function LayoutMobileMenu({
orgId,
orgs,
navItems,
launcherNavItems = [],
showSidebar,
showTopBar,
launcherMode = false,
showViewAsAdmin = false
}: LayoutMobileMenuProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const t = useTranslations();
const showMobileNav = showSidebar || launcherMode;
const currentOrg = orgs?.find((org) => org.orgId === orgId);
const isSettingsPage = Boolean(
orgId && pathname?.includes(`/${orgId}/settings`)
);
const canViewResourceLauncher = Boolean(
currentOrg?.isAdmin || currentOrg?.isOwner
);
const mobileNavLinkClassName = cn(
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
@@ -95,8 +85,55 @@ export function LayoutMobileMenu({
/>
</div>
</div>
{showViewAsAdmin && orgId ? (
<div className="px-3">
<div className="px-3">
{orgId
? launcherNavItems
.filter(
(item) =>
item.href
)
.map((item) => {
const href =
item.href!.replace(
"{orgId}",
orgId
);
return (
<div
key={href}
className="mb-1"
>
<Link
href={
href
}
className={
mobileNavLinkClassName
}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
{item.icon ? (
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
{
item.icon
}
</span>
) : null}
<span className="flex-1">
{t(
item.title
)}
</span>
</Link>
</div>
);
})
: null}
{showViewAsAdmin && orgId ? (
<div className="mb-1">
<Link
href={`/${orgId}/settings`}
@@ -119,8 +156,8 @@ export function LayoutMobileMenu({
</span>
</Link>
</div>
</div>
) : null}
) : null}
</div>
</>
) : (
<>
@@ -134,58 +171,6 @@ export function LayoutMobileMenu({
</div>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage &&
isSettingsPage &&
canViewResourceLauncher &&
orgId && (
<div className="mb-1">
<Link
href={`/${orgId}`}
className={
mobileNavLinkClassName
}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
<LayoutGrid className="h-4 w-4" />
</span>
<span className="flex-1">
{t(
"resourceSidebarLauncherTitle"
)}
</span>
</Link>
</div>
)}
{!isAdminPage &&
user.serverAdmin && (
<div className="mb-1">
<Link
href="/admin"
className={
mobileNavLinkClassName
}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
<Server className="h-4 w-4" />
</span>
<span className="flex-1">
{t(
"serverAdmin"
)}
</span>
</Link>
</div>
)}
<SidebarNav
sections={navItems}
onItemClick={() =>
+4 -117
View File
@@ -18,13 +18,7 @@ import { approvalQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { useQuery } from "@tanstack/react-query";
import { ListUserOrgsResponse } from "@server/routers/org";
import {
ArrowRight,
ExternalLink,
LayoutGrid,
PanelRightOpen,
Server
} from "lucide-react";
import { ExternalLink, PanelRightOpen } from "lucide-react";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -136,13 +130,6 @@ export function LayoutSidebar({
const showTrial =
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
const isSettingsPage = Boolean(
orgId && pathname?.includes(`/${orgId}/settings`)
);
const canViewResourceLauncher = Boolean(
currentOrg?.isAdmin || currentOrg?.isOwner
);
return (
<div
className={cn(
@@ -165,107 +152,6 @@ export function LayoutSidebar({
/>
<div className="flex-1 overflow-y-auto relative">
<div className="px-2 pt-3">
{!isAdminPage &&
isSettingsPage &&
canViewResourceLauncher &&
orgId && (
<div
className={cn(
"shrink-0",
isSidebarCollapsed ? "mb-4" : "mb-1"
)}
>
{isSidebarCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/${orgId}`}
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
)}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
>
<p>
{t(
"resourceSidebarLauncherTitle"
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link
href={`/${orgId}`}
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
)}
>
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
<LayoutGrid className="h-4 w-4" />
</span>
<span className="flex-1">
{t("resourceSidebarLauncherTitle")}
</span>
</Link>
)}
</div>
)}
{!isAdminPage && user.serverAdmin && (
<div
className={cn(
"shrink-0",
isSidebarCollapsed ? "mb-4" : "mb-1"
)}
>
{isSidebarCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
)}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
<Server className="h-4 w-4" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
>
<p>{t("serverAdmin")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
)}
>
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
<Server className="h-4 w-4" />
</span>
<span className="flex-1">
{t("serverAdmin")}
</span>
</Link>
)}
</div>
)}
<SidebarNav
sections={navItems}
isCollapsed={isSidebarCollapsed}
@@ -304,8 +190,9 @@ export function LayoutSidebar({
<div
className={cn(
"pt-1 flex flex-col shrink-0 gap-2 w-full border-t border-border",
isSidebarCollapsed && "pb-2"
"pt-1 flex flex-col shrink-0 gap-2 w-full",
!isSidebarCollapsed && "border-t border-border",
isSidebarCollapsed && "pb-4"
)}
>
{canShowProductUpdates ? (
+40
View File
@@ -0,0 +1,40 @@
"use client";
import ActionBanner from "@app/components/ActionBanner";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ShieldAlert } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
type LogRetentionWarningProps = {
orgId: string;
logTypeLabel: string;
};
export function LogRetentionWarning({
orgId,
logTypeLabel
}: LogRetentionWarningProps) {
const t = useTranslations();
return (
<ActionBanner
variant="warning"
title={t("logRetentionDisabledWarningTitle")}
titleIcon={<ShieldAlert className="w-5 h-5" />}
description={t("logRetentionDisabledWarningDescription", {
logType: logTypeLabel
})}
actions={
<Link href={`/${orgId}/settings/general/security`}>
<Button variant="outline" className="gap-2">
{t("logRetentionDisabledWarningButton")}
<ArrowRight className="size-4" />
</Button>
</Link>
}
/>
);
}
export default LogRetentionWarning;
+38 -19
View File
@@ -30,10 +30,7 @@ import Link from "next/link";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
import { useTranslations } from "next-intl";
import {
generateOidcUrlProxy,
loginProxy
} from "@app/actions/server";
import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { loadReoScript } from "reodotdev";
import { build } from "@server/build";
import MfaInputForm from "@app/components/MfaInputForm";
import { useLocalStorage } from "@app/hooks/useLocalStorage";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
lastUsed?: boolean;
};
type LoginFormProps = {
@@ -105,7 +104,6 @@ export default function LoginForm({
}
}, []);
const formSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") })
@@ -130,11 +128,16 @@ export default function LoginForm({
}
});
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
"login:last-used-idp",
null
);
async function onSubmit(values: any) {
const { email, password } = form.getValues();
const { code } = mfaForm.getValues();
setLastUsedIdpId(null);
setLoading(true);
setError(null);
@@ -179,8 +182,7 @@ export default function LoginForm({
if (data.useSecurityKey) {
setError(
t("securityKeyRequired", {
defaultValue:
"Please use your security key to sign in."
defaultValue: "Please use your security key to sign in."
})
);
return;
@@ -242,6 +244,8 @@ export default function LoginForm({
async function loginWithIdp(idpId: number) {
let redirectUrl: string | undefined;
setLastUsedIdpId(idpId.toString());
try {
const data = await generateOidcUrlProxy(
idpId,
@@ -356,7 +360,6 @@ export default function LoginForm({
)}
<div className="space-y-4">
{!mfaRequested && (
<>
<SecurityKeyAuthButton
@@ -385,25 +388,41 @@ export default function LoginForm({
idp.variant || idp.name.toLowerCase();
return (
<Button
<div
className="w-full relative"
key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
>
<IdpTypeIcon type={effectiveType} size={16} />
<span>{idp.name}</span>
</Button>
<Button
key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
loginWithIdp(idp.idpId);
}}
>
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</Button>
{lastUsedIdpId ===
idp.idpId.toString() && (
<div className="absolute inset-0">
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
{t("idpLastUsed")}
</span>
</div>
)}
</div>
);
})}
</>
)}
</>
)}
</div>
</div>
);

Some files were not shown because too many files have changed in this diff Show More