mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-02 01:09:03 +02:00
Merge branch 'dev' into feat/ip-filtering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"> </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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user