From 5d37f44241e38ae765fd04ce8ec76bce735a7b15 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 09:43:03 -0400 Subject: [PATCH] Add niceid to ui and api for providers --- .../verifyApiKeyAiProviderAccess.ts | 46 +++++++---- server/middlewares/verifyAiProviderAccess.ts | 46 +++++++---- server/routers/aiProvider/getAiProvider.ts | 78 +++++++++++++++---- server/routers/external.ts | 7 ++ server/routers/integration.ts | 7 ++ .../authentication/page.tsx | 0 .../budget/page.tsx | 0 .../[niceId]/configuration/page.tsx | 12 +++ .../general/page.tsx | 35 +++++++++ .../{[providerId] => [niceId]}/layout.tsx | 16 ++-- .../models/page.tsx | 0 .../network/page.tsx | 0 .../settings/ai-providers/[niceId]/page.tsx | 10 +++ .../[providerId]/configuration/page.tsx | 12 --- .../ai-providers/[providerId]/page.tsx | 10 --- .../settings/ai-providers/create/page.tsx | 7 +- .../[orgId]/settings/ai-providers/page.tsx | 1 + src/components/AiProvidersTable.tsx | 7 +- 18 files changed, 219 insertions(+), 75 deletions(-) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/authentication/page.tsx (100%) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/budget/page.tsx (100%) create mode 100644 src/app/[orgId]/settings/ai-providers/[niceId]/configuration/page.tsx rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/general/page.tsx (85%) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/layout.tsx (83%) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/models/page.tsx (100%) rename src/app/[orgId]/settings/ai-providers/{[providerId] => [niceId]}/network/page.tsx (100%) create mode 100644 src/app/[orgId]/settings/ai-providers/[niceId]/page.tsx delete mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx delete mode 100644 src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx diff --git a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts index 1668b5727..91cfa47e4 100644 --- a/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts +++ b/server/middlewares/integration/verifyApiKeyAiProviderAccess.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { aiProviders, apiKeyOrg, db } from "@server/db"; +import { AiProvider, aiProviders, apiKeyOrg, db } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -13,7 +13,8 @@ export async function verifyApiKeyAiProviderAccess( try { const apiKey = req.apiKey; const providerIdRaw = getFirstString(req.params.providerId); - const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const niceId = getFirstString(req.params.niceId); + const orgIdParam = getFirstString(req.params.orgId); if (!apiKey) { return next( @@ -21,23 +22,42 @@ export async function verifyApiKeyAiProviderAccess( ); } - if (Number.isNaN(providerId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") - ); - } + let provider: AiProvider | undefined; - const [provider] = await db - .select() - .from(aiProviders) - .where(eq(aiProviders.providerId, providerId)) - .limit(1); + if (niceId && orgIdParam) { + const [providerRes] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.niceId, niceId), + eq(aiProviders.orgId, orgIdParam) + ) + ) + .limit(1); + provider = providerRes; + } else { + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + const [providerRes] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); + provider = providerRes; + } if (!provider) { return next( createHttpError( HttpCode.NOT_FOUND, - `AI provider with ID ${providerId} not found` + `AI provider with ID ${providerIdRaw || niceId} not found` ) ); } diff --git a/server/middlewares/verifyAiProviderAccess.ts b/server/middlewares/verifyAiProviderAccess.ts index 22edd6f86..057497cc3 100644 --- a/server/middlewares/verifyAiProviderAccess.ts +++ b/server/middlewares/verifyAiProviderAccess.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { aiProviders, db, userOrgs } from "@server/db"; +import { AiProvider, aiProviders, db, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -15,7 +15,8 @@ export async function verifyAiProviderAccess( try { const userId = req.user!.userId; const providerIdRaw = getFirstString(req.params.providerId); - const providerId = Number.parseInt(providerIdRaw ?? "", 10); + const niceId = getFirstString(req.params.niceId); + const orgIdParam = getFirstString(req.params.orgId); if (!userId) { return next( @@ -23,23 +24,42 @@ export async function verifyAiProviderAccess( ); } - if (Number.isNaN(providerId)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") - ); - } + let provider: AiProvider | undefined; - const [provider] = await db - .select() - .from(aiProviders) - .where(eq(aiProviders.providerId, providerId)) - .limit(1); + if (niceId && orgIdParam) { + const [providerRes] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.niceId, niceId), + eq(aiProviders.orgId, orgIdParam) + ) + ) + .limit(1); + provider = providerRes; + } else { + const providerId = Number.parseInt(providerIdRaw ?? "", 10); + + if (Number.isNaN(providerId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid provider ID") + ); + } + + const [providerRes] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); + provider = providerRes; + } if (!provider) { return next( createHttpError( HttpCode.NOT_FOUND, - `AI provider with ID ${providerId} not found` + `AI provider with ID ${providerIdRaw || niceId} not found` ) ); } diff --git a/server/routers/aiProvider/getAiProvider.ts b/server/routers/aiProvider/getAiProvider.ts index 28a0152e1..53a3e099a 100644 --- a/server/routers/aiProvider/getAiProvider.ts +++ b/server/routers/aiProvider/getAiProvider.ts @@ -2,26 +2,78 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { aiProviders, db } from "@server/db"; import response from "@server/lib/response"; +import stoi from "@server/lib/stoi"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import type { GetAiProviderResponse } from "@server/routers/aiProvider/types"; import { toPublicAiProvider } from "@server/routers/aiProvider/types"; const paramsSchema = z.strictObject({ - providerId: z.coerce.number().int().positive() + providerId: z + .string() + .optional() + .transform(stoi) + .pipe(z.int().positive().optional()) + .optional(), + niceId: z.string().optional(), + orgId: z.string().optional() }); +async function query(providerId?: number, niceId?: string, orgId?: string) { + if (providerId) { + const [res] = await db + .select() + .from(aiProviders) + .where(eq(aiProviders.providerId, providerId)) + .limit(1); + return res; + } else if (niceId && orgId) { + const [res] = await db + .select() + .from(aiProviders) + .where( + and( + eq(aiProviders.niceId, niceId), + eq(aiProviders.orgId, orgId) + ) + ) + .limit(1); + return res; + } +} + registry.registerPath({ method: "get", path: "/ai-provider/{providerId}", description: "Get an AI provider by ID.", tags: [OpenAPITags.AiProvider], request: { - params: paramsSchema + params: z.object({ + providerId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/ai-provider/{niceId}", + description: + "Get an AI provider by orgId and niceId. NiceId is a readable ID for the provider and unique on a per org basis.", + tags: [OpenAPITags.AiProvider], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) }, responses: { 200: { @@ -46,22 +98,22 @@ export async function getAiProvider( ); } - const { providerId } = parsedParams.data; + const { providerId, niceId, orgId } = parsedParams.data; - const [provider] = - req.aiProvider && req.aiProvider.providerId === providerId - ? [req.aiProvider] - : await db - .select() - .from(aiProviders) - .where(eq(aiProviders.providerId, providerId)) - .limit(1); + const provider = + req.aiProvider && + (req.aiProvider.providerId === providerId || + (niceId && + req.aiProvider.niceId === niceId && + req.aiProvider.orgId === orgId)) + ? req.aiProvider + : await query(providerId, niceId, orgId); if (!provider) { return next( createHttpError( HttpCode.NOT_FOUND, - `AI provider with ID ${providerId} not found` + `AI provider with ID ${providerId || niceId} not found` ) ); } diff --git a/server/routers/external.ts b/server/routers/external.ts index 17cf0d921..e182d2b44 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1589,6 +1589,13 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getAiProvider), aiProvider.getAiProvider ); +authenticated.get( + "/org/:orgId/ai-provider/:niceId", + verifyOrgAccess, + verifyAiProviderAccess, + verifyUserHasAction(ActionsEnum.getAiProvider), + aiProvider.getAiProvider +); authenticated.put( "/ai-provider/:providerId/target", diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 5dc9315e9..f609443d4 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1624,6 +1624,13 @@ authenticated.get( verifyApiKeyHasAction(ActionsEnum.getAiProvider), aiProvider.getAiProvider ); +authenticated.get( + "/org/:orgId/ai-provider/:niceId", + verifyApiKeyOrgAccess, + verifyApiKeyAiProviderAccess, + verifyApiKeyHasAction(ActionsEnum.getAiProvider), + aiProvider.getAiProvider +); authenticated.put( "/ai-provider/:providerId/target", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/authentication/page.tsx similarity index 100% rename from src/app/[orgId]/settings/ai-providers/[providerId]/authentication/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/authentication/page.tsx diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/budget/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/budget/page.tsx similarity index 100% rename from src/app/[orgId]/settings/ai-providers/[providerId]/budget/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/budget/page.tsx diff --git a/src/app/[orgId]/settings/ai-providers/[niceId]/configuration/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/configuration/page.tsx new file mode 100644 index 000000000..ee3f676ae --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[niceId]/configuration/page.tsx @@ -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`); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/general/page.tsx similarity index 85% rename from src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/general/page.tsx index 9e41ff9c3..b65b1984a 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/general/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[niceId]/general/page.tsx @@ -55,6 +55,7 @@ export default function AiProviderGeneralPage() { .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() }) @@ -76,6 +77,7 @@ export default function AiProviderGeneralPage() { resolver: zodResolver(generalSchema), defaultValues: { name: provider.name, + niceId: provider.niceId, enabled: provider.enabled, capabilities: provider.capabilities ?? [] } @@ -86,10 +88,12 @@ export default function AiProviderGeneralPage() { try { const body: { name: string; + niceId?: string; enabled: boolean; capabilities?: AiCapability[]; } = { name: values.name.trim(), + niceId: values.niceId, enabled: values.enabled, capabilities: values.capabilities ?? [] }; @@ -101,6 +105,7 @@ export default function AiProviderGeneralPage() { updateProvider(updated); form.reset({ name: updated.name, + niceId: updated.niceId, enabled: updated.enabled, capabilities: updated.capabilities ?? [] }); @@ -108,6 +113,13 @@ export default function AiProviderGeneralPage() { 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({ @@ -190,6 +202,29 @@ export default function AiProviderGeneralPage() { /> + + ( + + + {t("identifier")} + + + + + + + )} + /> + + ; + params: Promise<{ orgId: string; niceId: string }>; }; export default async function AiProviderLayout({ children, params }: Props) { - const { orgId, providerId } = await params; + const { orgId, niceId } = await params; const t = await getTranslations(); let provider = null; try { const res = await internal.get>( - `/ai-provider/${providerId}`, + `/org/${orgId}/ai-provider/${niceId}`, await authCookieHeader() ); provider = res.data.data.provider; @@ -63,23 +63,23 @@ export default async function AiProviderLayout({ children, params }: Props) { const navItems = [ { title: t("general"), - href: "/{orgId}/settings/ai-providers/{providerId}/general" + href: "/{orgId}/settings/ai-providers/{niceId}/general" }, { title: t("aiProviderNetworkSettings"), - href: "/{orgId}/settings/ai-providers/{providerId}/network" + href: "/{orgId}/settings/ai-providers/{niceId}/network" }, { title: t("aiProviderModels"), - href: "/{orgId}/settings/ai-providers/{providerId}/models" + href: "/{orgId}/settings/ai-providers/{niceId}/models" }, { title: t("aiProviderAuthSettings"), - href: "/{orgId}/settings/ai-providers/{providerId}/authentication" + href: "/{orgId}/settings/ai-providers/{niceId}/authentication" }, { title: t("aiProviderBudgetSettings"), - href: "/{orgId}/settings/ai-providers/{providerId}/budget" + href: "/{orgId}/settings/ai-providers/{niceId}/budget" } ]; diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/models/page.tsx similarity index 100% rename from src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/models/page.tsx diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/network/page.tsx similarity index 100% rename from src/app/[orgId]/settings/ai-providers/[providerId]/network/page.tsx rename to src/app/[orgId]/settings/ai-providers/[niceId]/network/page.tsx diff --git a/src/app/[orgId]/settings/ai-providers/[niceId]/page.tsx b/src/app/[orgId]/settings/ai-providers/[niceId]/page.tsx new file mode 100644 index 000000000..664127d17 --- /dev/null +++ b/src/app/[orgId]/settings/ai-providers/[niceId]/page.tsx @@ -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`); +} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx deleted file mode 100644 index bf003b96e..000000000 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/configuration/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { redirect } from "next/navigation"; - -type Props = { - params: Promise<{ orgId: string; providerId: string }>; -}; - -export default async function AiProviderConfigurationRedirect({ - params -}: Props) { - const { orgId, providerId } = await params; - redirect(`/${orgId}/settings/ai-providers/${providerId}/network`); -} diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx deleted file mode 100644 index 2daca1227..000000000 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { redirect } from "next/navigation"; - -type Props = { - params: Promise<{ orgId: string; providerId: string }>; -}; - -export default async function AiProviderPage({ params }: Props) { - const { orgId, providerId } = await params; - redirect(`/${orgId}/settings/ai-providers/${providerId}/general`); -} diff --git a/src/app/[orgId]/settings/ai-providers/create/page.tsx b/src/app/[orgId]/settings/ai-providers/create/page.tsx index 7684b2d48..29f005e8a 100644 --- a/src/app/[orgId]/settings/ai-providers/create/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/create/page.tsx @@ -222,6 +222,7 @@ export default function CreateAiProviderPage() { >(`/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 { @@ -236,7 +237,7 @@ export default function CreateAiProviderPage() { ) }); router.push( - `/${orgId}/settings/ai-providers/${providerId}/network` + `/${orgId}/settings/ai-providers/${niceId}/network` ); return; } @@ -255,7 +256,7 @@ export default function CreateAiProviderPage() { ) }); router.push( - `/${orgId}/settings/ai-providers/${providerId}/models` + `/${orgId}/settings/ai-providers/${niceId}/models` ); return; } @@ -266,7 +267,7 @@ export default function CreateAiProviderPage() { description: t("aiProviderCreated") }); - router.push(`/${orgId}/settings/ai-providers/${providerId}`); + router.push(`/${orgId}/settings/ai-providers/${niceId}`); } catch (e) { toast({ variant: "destructive", diff --git a/src/app/[orgId]/settings/ai-providers/page.tsx b/src/app/[orgId]/settings/ai-providers/page.tsx index 9858641e2..024170590 100644 --- a/src/app/[orgId]/settings/ai-providers/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/page.tsx @@ -53,6 +53,7 @@ export default async function AiProvidersPage({ params, searchParams }: Props) { orgId={orgId} providers={providers.map((provider) => ({ providerId: provider.providerId, + niceId: provider.niceId, name: provider.name, type: provider.type, routingMode: provider.routingMode, diff --git a/src/components/AiProvidersTable.tsx b/src/components/AiProvidersTable.tsx index ba7fbb00c..82bf23cfe 100644 --- a/src/components/AiProvidersTable.tsx +++ b/src/components/AiProvidersTable.tsx @@ -27,6 +27,7 @@ import { useDebouncedCallback } from "use-debounce"; export type AiProviderRow = { providerId: number; + niceId: string; name: string; type: string; routingMode: string; @@ -176,7 +177,7 @@ export default function AiProvidersTable({ header: () => {t("name")}, cell: ({ row }) => ( {row.original.name} @@ -238,7 +239,7 @@ export default function AiProvidersTable({ {t("edit")} @@ -256,7 +257,7 @@ export default function AiProvidersTable({