diff --git a/messages/en-US.json b/messages/en-US.json index 4ff3f9da2..b614bfd38 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1436,6 +1436,11 @@ "actionGetAiModel": "Get AI Model", "actionListAiModels": "List AI Models", "actionUpdateAiModel": "Update AI Model", + "actionCreateVirtualApiKey": "Create Virtual API Key", + "actionDeleteVirtualApiKey": "Delete Virtual API Key", + "actionGetVirtualApiKey": "Get Virtual API Key", + "actionListVirtualApiKeys": "List Virtual API Keys", + "actionUpdateVirtualApiKey": "Update Virtual API Key", "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", @@ -1637,6 +1642,53 @@ "sidebarAiGateway": "AI Gateway", "sidebarAiProviders": "Providers", "commandAiProviders": "AI Providers", + "sidebarVirtualApiKeys": "Virtual API Keys", + "commandVirtualApiKeys": "Virtual API Keys", + "virtualApiKeysTitle": "Manage Virtual API Keys", + "virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public inference resources", + "virtualApiKeys": "Virtual API Keys", + "virtualApiKeysSearch": "Search keys...", + "virtualApiKeysCreate": "Create Virtual API Key", + "virtualApiKeysCreateDescription": "Mint a manual key that can call public inference resources in this organization", + "virtualApiKeysCreateButton": "Create Key", + "virtualApiKeysEmpty": "No virtual API keys yet", + "virtualApiKeysName": "Name", + "virtualApiKeysDescriptionOptional": "Description (optional)", + "virtualApiKeysAssociateUserOptional": "Associate User (optional)", + "virtualApiKeysAssociateUserDescription": "Attribution only. Does not grant access by itself.", + "virtualApiKeysAllResources": "All public inference resources", + "virtualApiKeysAllResourcesDescription": "Allow this key to access every public inference resource in the organization", + "virtualApiKeysSelectResources": "Public Inference Resources", + "virtualApiKeysSelectResourcesPlaceholder": "Select resources", + "virtualApiKeysSelectResourcesDescription": "Choose which public inference resources this key can access", + "virtualApiKeysNoResources": "No resources", + "virtualApiKeysSecret": "Key", + "virtualApiKeysSeeOnce": "Copy this key now. You can also view it again later from the table.", + "virtualApiKeysSecretHint": "Use this value as a Bearer token: vk-[id].[secret]", + "virtualApiKeysViewSecret": "View Secret", + "virtualApiKeysViewSecretTitle": "Virtual API Key Secret", + "virtualApiKeysViewSecretDescription": "This secret grants access to the public inference resources assigned to this key", + "virtualApiKeysEdit": "Edit Virtual API Key", + "virtualApiKeysEditDescription": "Update the associated user and public inference resource access for this key", + "virtualApiKeysSaveButton": "Save Changes", + "virtualApiKeysSelectResourcesRequired": "Select at least one public inference resource, or enable all public inference resources", + "virtualApiKeysUpdated": "Virtual API key updated", + "virtualApiKeysUpdatedDescription": "The virtual API key has been updated", + "virtualApiKeysErrorUpdate": "Error updating virtual API key", + "virtualApiKeysErrorUpdateDescription": "Failed to update virtual API key", + "virtualApiKeysErrorCreate": "Error creating virtual API key", + "virtualApiKeysErrorCreateDescription": "Failed to create virtual API key", + "virtualApiKeysErrorDelete": "Error deleting virtual API key", + "virtualApiKeysErrorDeleteMessage": "Failed to delete virtual API key", + "virtualApiKeysDeleted": "Virtual API key deleted", + "virtualApiKeysDeletedDescription": "The virtual API key has been deleted", + "virtualApiKeysDelete": "Delete Virtual API Key", + "virtualApiKeysDeleteConfirm": "Delete Key", + "virtualApiKeysQuestionRemove": "Are you sure you want to delete this virtual API key?", + "virtualApiKeysMessageRemove": "Clients using this key will lose access immediately.", + "virtualApiKeysErrorFetchSecret": "Error loading secret", + "virtualApiKeysErrorFetchSecretDescription": "Failed to load the virtual API key secret", + "virtualApiKeysFilterUnassigned": "Unassigned", "aiProvidersTitle": "AI Providers", "aiProvidersDescription": "Connect model providers for AI workloads in this organization", "aiProvidersAdd": "Add Provider", @@ -2430,6 +2482,8 @@ "subnetPlaceholder": "Subnet", "addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "selectSites": "Select sites", + "selectResources": "Select resources", + "multiResourcesSelectorResourcesCount": "{count, plural, one {# resource} other {# resources}}", "selectLabels": "Select labels", "sitesDescription": "The client will have connectivity to the selected sites", "clientInstallOlm": "Install Machine Client", diff --git a/server/lib/virtualApiKey.ts b/server/lib/virtualApiKey.ts index 15f9b52ff..66ebb4b5a 100644 --- a/server/lib/virtualApiKey.ts +++ b/server/lib/virtualApiKey.ts @@ -62,10 +62,18 @@ export async function assertManualKeyResourcesInOrg(params: { }): Promise<{ ok: true } | { ok: false; message: string }> { const { allResources, resourceIds, orgId } = params; - if (allResources || resourceIds.length === 0) { + if (allResources) { return { ok: true }; } + if (resourceIds.length === 0) { + return { + ok: false, + message: + "Select at least one public inference resource, or enable all public inference resources" + }; + } + const uniqueIds = [...new Set(resourceIds)]; const rows = await db .select({ resourceId: resources.resourceId }) @@ -73,6 +81,7 @@ export async function assertManualKeyResourcesInOrg(params: { .where( and( eq(resources.orgId, orgId), + eq(resources.mode, "inference"), inArray(resources.resourceId, uniqueIds) ) ); @@ -80,7 +89,8 @@ export async function assertManualKeyResourcesInOrg(params: { if (rows.length !== uniqueIds.length) { return { ok: false, - message: "One or more resources are invalid for this organization" + message: + "One or more resources are invalid public inference resources for this organization" }; } diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index e5408f630..124c480c2 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -124,12 +124,21 @@ const listResourcesSchema = z.strictObject({ "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status." }), protocol: z - .enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"]) + .enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc", "inference"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"], + enum: [ + "http", + "https", + "tcp", + "udp", + "ssh", + "rdp", + "vnc", + "inference" + ], description: "Filter resources by protocol. `http` and `https` match HTTP resources without and with SSL respectively." }), diff --git a/server/routers/virtualApiKey/validation.ts b/server/routers/virtualApiKey/validation.ts index a1c9ae671..42964b3ba 100644 --- a/server/routers/virtualApiKey/validation.ts +++ b/server/routers/virtualApiKey/validation.ts @@ -4,14 +4,23 @@ export const virtualApiKeyResourceIdsSchema = z .array(z.coerce.number().int().positive()) .optional(); -export const createVirtualApiKeyBodySchema = z.strictObject({ - name: z.string().nonempty(), - description: z.string().optional().nullable(), - userId: z.string().optional().nullable(), - allResources: z.boolean().optional().default(false), - resourceIds: virtualApiKeyResourceIdsSchema, - validForSeconds: z.int().positive().optional() -}); +export const createVirtualApiKeyBodySchema = z + .strictObject({ + name: z.string().nonempty(), + description: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + allResources: z.boolean().optional().default(false), + resourceIds: virtualApiKeyResourceIdsSchema, + validForSeconds: z.int().positive().optional() + }) + .refine( + (data) => data.allResources || (data.resourceIds?.length ?? 0) > 0, + { + message: + "Select at least one public inference resource, or enable all public inference resources", + path: ["resourceIds"] + } + ); export const updateVirtualApiKeyBodySchema = z.strictObject({ name: z.string().nonempty().optional(), diff --git a/src/app/[orgId]/settings/virtual-api-keys/page.tsx b/src/app/[orgId]/settings/virtual-api-keys/page.tsx new file mode 100644 index 000000000..ba8f4ea01 --- /dev/null +++ b/src/app/[orgId]/settings/virtual-api-keys/page.tsx @@ -0,0 +1,153 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +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 API Keys" +}; + +type VirtualApiKeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function VirtualApiKeysPage( + props: VirtualApiKeysPageProps +) { + 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>( + `/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`, + cookieHeader + ), + internal.get>( + `/org/${params.orgId}/users?page=1&pageSize=1000`, + cookieHeader + ), + internal.get>( + `/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>( + `/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 ( + <> + + + + + + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index e6971bc94..20af6d05d 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -195,6 +195,11 @@ export const orgNavSections = ( title: "sidebarAiProviders", href: "/{orgId}/settings/ai-providers", icon: + }, + { + title: "sidebarVirtualApiKeys", + href: "/{orgId}/settings/virtual-api-keys", + icon: } ] }, @@ -490,6 +495,11 @@ export const commandBarNavSections = ( title: "commandAiProviders", href: "/{orgId}/settings/ai-providers", icon: + }, + { + title: "commandVirtualApiKeys", + href: "/{orgId}/settings/virtual-api-keys", + icon: } ] }, diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx new file mode 100644 index 000000000..32a3877aa --- /dev/null +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -0,0 +1,419 @@ +"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 { + MultiResourcesSelector, + formatMultiResourcesSelectorLabel +} from "@app/components/multi-resource-selector"; +import type { SelectedResource } from "@app/components/resource-selector"; + +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(null); + const [loading, setLoading] = useState(false); + const [allResources, setAllResources] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedResources, setSelectedResources] = useState< + SelectedResource[] + >([]); + + const formSchema = z.object({ + name: z.string().min(1), + description: z.string().optional() + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + description: "" + } + }); + + function resetLocalState() { + setCredential(null); + setLoading(false); + setAllResources(false); + setSelectedUser(null); + setSelectedResources([]); + form.reset(); + } + + async function onSubmit(values: z.infer) { + setLoading(true); + + const res = await api + .put>( + `/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) + } + ) + .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(`vk-${key.virtualApiKeyId}.${key.secret}`); + } + + 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 ( + { + setOpen(val); + if (!val) { + resetLocalState(); + } + }} + > + + + {t("virtualApiKeysCreate")} + + {t("virtualApiKeysCreateDescription")} + + + +
+ {!credential && ( +
+ + ( + + + {t("virtualApiKeysName")} + + + + + + + )} + /> + + ( + + + {t( + "virtualApiKeysDescriptionOptional" + )} + + + + + + + )} + /> + +
+ + {t( + "virtualApiKeysAssociateUserOptional" + )} + + + + + + + + + +

+ {t( + "virtualApiKeysAssociateUserDescription" + )} +

+
+ +
+
+ { + setAllResources( + val as boolean + ); + if (val) { + setSelectedResources( + [] + ); + } + }} + className="mt-0.5" + /> +
+ +

+ {t( + "virtualApiKeysAllResourcesDescription" + )} +

+
+
+ + {!allResources && ( +
+ + {t( + "virtualApiKeysSelectResources" + )} + + + + + + + + 0 + } + onClear={() => + setSelectedResources( + [] + ) + } + /> + + + + {t( + "virtualApiKeysSelectResourcesDescription" + )} + +
+ )} +
+ + + )} + {credential && ( +
+

{t("virtualApiKeysSeeOnce")}

+ +
+ )} +
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/EditVirtualApiKeyForm.tsx b/src/components/EditVirtualApiKeyForm.tsx new file mode 100644 index 000000000..22a479df3 --- /dev/null +++ b/src/components/EditVirtualApiKeyForm.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +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 { + MultiResourcesSelector, + formatMultiResourcesSelectorLabel +} from "@app/components/multi-resource-selector"; +import type { SelectedResource } from "@app/components/resource-selector"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import type { CreatedVirtualApiKey } from "@app/components/CreateVirtualApiKeyForm"; + +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 [loading, setLoading] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedResources, setSelectedResources] = useState< + SelectedResource[] + >([]); + + 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>({ + 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) + ); + form.reset({ + allResources: virtualApiKey.allResources + }); + }, [open, virtualApiKey, form]); + + async function onSubmit(values: z.infer) { + if (!virtualApiKey) { + return; + } + + setLoading(true); + + const res = await api + .post>( + `/virtual-api-key/${virtualApiKey.virtualApiKeyId}`, + { + userId: selectedUser?.id ?? null, + allResources: values.allResources, + resourceIds: values.allResources + ? [] + : selectedResources.map((r) => r.resourceId) + } + ) + .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; + 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 ( + { + setOpen(val); + }} + > + + + {t("virtualApiKeysEdit")} + + {t("virtualApiKeysEditDescription")} + + + +
+
+ +
+ + {t( + "virtualApiKeysAssociateUserOptional" + )} + + + + + + + + + +

+ {t( + "virtualApiKeysAssociateUserDescription" + )} +

+
+ +
+ ( + +
+ + { + field.onChange( + val as boolean + ); + if (val) { + setSelectedResources( + [] + ); + } + }} + className="mt-0.5" + /> + +
+ +

+ {t( + "virtualApiKeysAllResourcesDescription" + )} +

+
+
+ +
+ )} + /> + + {!allResources && ( +
+ + {t( + "virtualApiKeysSelectResources" + )} + + + + + + + + 0 + } + onClear={() => + setSelectedResources( + [] + ) + } + /> + + + + {t( + "virtualApiKeysSelectResourcesRequired" + )} + +
+ )} +
+
+ +
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index fe33337d4..ef78d4c71 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -166,6 +166,14 @@ function getActionsCategories(root: boolean) { [t("actionGetAiModel")]: "getAiModel", [t("actionListAiModels")]: "listAiModels", [t("actionUpdateAiModel")]: "updateAiModel" + }, + + "Virtual API Key": { + [t("actionCreateVirtualApiKey")]: "createVirtualApiKey", + [t("actionDeleteVirtualApiKey")]: "deleteVirtualApiKey", + [t("actionGetVirtualApiKey")]: "getVirtualApiKey", + [t("actionListVirtualApiKeys")]: "listVirtualApiKeys", + [t("actionUpdateVirtualApiKey")]: "updateVirtualApiKey" } }; diff --git a/src/components/ViewVirtualApiKeySecret.tsx b/src/components/ViewVirtualApiKeySecret.tsx new file mode 100644 index 000000000..800fb3484 --- /dev/null +++ b/src/components/ViewVirtualApiKeySecret.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { AxiosResponse } from "axios"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; + +type ViewVirtualApiKeySecretProps = { + open: boolean; + setOpen: (open: boolean) => void; + virtualApiKeyId: string | null; + name?: string | null; +}; + +export default function ViewVirtualApiKeySecret({ + open, + setOpen, + virtualApiKeyId, + name +}: ViewVirtualApiKeySecretProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + const [credential, setCredential] = useState(null); + + useEffect(() => { + if (!open || !virtualApiKeyId) { + return; + } + + let cancelled = false; + setLoading(true); + setCredential(null); + + api.get>( + `/virtual-api-key/${virtualApiKeyId}` + ) + .then((res) => { + if (cancelled) { + return; + } + const key = res.data.data.virtualApiKey; + if (key.secret) { + setCredential(`vk-${key.virtualApiKeyId}.${key.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) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [open, virtualApiKeyId]); + + return ( + { + setOpen(val); + if (!val) { + setCredential(null); + setLoading(false); + } + }} + > + + + + {t("virtualApiKeysViewSecretTitle")} + + + {name ? name : t("virtualApiKeysViewSecretDescription")} + + + +
+ {loading && ( +

+ {t("loading")} +

+ )} + {!loading && credential && ( + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/VirtualApiKeysDataTable.tsx b/src/components/VirtualApiKeysDataTable.tsx new file mode 100644 index 000000000..d691102eb --- /dev/null +++ b/src/components/VirtualApiKeysDataTable.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +type DataTableProps = { + columns: ColumnDef[]; + data: TData[]; + createVirtualApiKey?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +}; + +export function VirtualApiKeysDataTable({ + columns, + data, + createVirtualApiKey, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/VirtualApiKeysTable.tsx b/src/components/VirtualApiKeysTable.tsx new file mode 100644 index 000000000..34b670328 --- /dev/null +++ b/src/components/VirtualApiKeysTable.tsx @@ -0,0 +1,516 @@ +"use client"; + +import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { VirtualApiKeysDataTable } from "@app/components/VirtualApiKeysDataTable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { Badge } from "@app/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + ArrowRight, + ArrowUpDown, + ArrowUpRight, + Funnel, + MoreHorizontal +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { formatAxiosError, createApiClient } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import moment from "moment"; +import CreateVirtualApiKeyForm, { + type CreatedVirtualApiKey +} from "@app/components/CreateVirtualApiKeyForm"; +import EditVirtualApiKeyForm from "@app/components/EditVirtualApiKeyForm"; +import ViewVirtualApiKeySecret from "@app/components/ViewVirtualApiKeySecret"; +import { useTranslations } from "next-intl"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { UserSelector, type SelectedUser } from "@app/components/user-selector"; +import { + ResourceSelector, + type SelectedResource +} from "@app/components/resource-selector"; +import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; + +export type VirtualApiKeyRow = CreatedVirtualApiKey; + +type VirtualApiKeysTableProps = { + virtualApiKeys: VirtualApiKeyRow[]; + orgId: string; +}; + +export default function VirtualApiKeysTable({ + virtualApiKeys, + orgId +}: VirtualApiKeysTableProps) { + const router = useRouter(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isViewSecretOpen, setIsViewSecretOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [selectedKey, setSelectedKey] = useState( + null + ); + const [rows, setRows] = useState(virtualApiKeys); + const [isRefreshing, setIsRefreshing] = useState(false); + + const [userFilterOpen, setUserFilterOpen] = useState(false); + const [resourceFilterOpen, setResourceFilterOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [selectedResource, setSelectedResource] = + useState(null); + const [unassignedOnly, setUnassignedOnly] = useState(false); + + useEffect(() => { + setRows(virtualApiKeys); + }, [virtualApiKeys]); + + const filteredRows = useMemo(() => { + return rows.filter((row) => { + if (unassignedOnly && row.userId) { + return false; + } + if (selectedUser && row.userId !== selectedUser.id) { + return false; + } + if (selectedResource) { + if ( + !row.allResources && + !row.resourceIds.includes(selectedResource.resourceId) + ) { + return false; + } + } + return true; + }); + }, [rows, selectedUser, selectedResource, unassignedOnly]); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + async function deleteKey(id: string) { + await api.delete(`/virtual-api-key/${id}`).catch((e) => { + toast({ + title: t("virtualApiKeysErrorDelete"), + description: formatAxiosError( + e, + t("virtualApiKeysErrorDeleteMessage") + ) + }); + throw e; + }); + + setRows((prev) => prev.filter((r) => r.virtualApiKeyId !== id)); + + toast({ + title: t("virtualApiKeysDeleted"), + description: t("virtualApiKeysDeletedDescription") + }); + } + + const clearUserFilter = () => { + setSelectedUser(null); + setUnassignedOnly(false); + setUserFilterOpen(false); + }; + + const clearResourceFilter = () => { + setSelectedResource(null); + setResourceFilterOpen(false); + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("virtualApiKeysName"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => row.original.name || "-" + }, + { + id: "resources", + accessorFn: (row) => row.resourceNames, + friendlyName: t("resource"), + header: () => ( + + + + + + { + setSelectedResource(resource); + setResourceFilterOpen(false); + }} + /> + + + ), + cell: ({ row }) => { + const r = row.original; + if (r.allResources) { + return t("virtualApiKeysAllResources"); + } + if (r.resources.length === 0) { + return {t("virtualApiKeysNoResources")}; + } + if (r.resources.length === 1) { + const resource = r.resources[0]; + if (!resource.niceId) { + return resource.name; + } + return ( + + + + ); + } + return r.resourceNames; + } + }, + { + accessorKey: "userId", + friendlyName: t("user"), + header: () => ( + + + + + + { + setSelectedUser(null); + setUnassignedOnly(true); + setUserFilterOpen(false); + } + }} + onSelectUser={(user) => { + setSelectedUser(user); + setUnassignedOnly(false); + setUserFilterOpen(false); + }} + /> + + + ), + cell: ({ row }) => { + const r = row.original; + if (!r.userId) { + return -; + } + return ( + + + + ); + } + }, + { + accessorKey: "lastChars", + friendlyName: t("virtualApiKeysSecret"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => + `vk-${row.original.virtualApiKeyId}.••••${row.original.lastChars}` + }, + { + accessorKey: "createdAt", + friendlyName: t("created"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => moment(row.original.createdAt).format("lll") + }, + { + accessorKey: "expiresAt", + friendlyName: t("expires"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const expiresAt = row.original.expiresAt; + if (expiresAt) { + return moment(expiresAt).format("lll"); + } + return t("never"); + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const keyRow = row.original; + return ( +
+ + + + + + { + setSelectedKey(keyRow); + setIsViewSecretOpen(true); + }} + > + {t("virtualApiKeysViewSecret")} + + { + setSelectedKey(keyRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + +
+ ); + } + } + ]; + + return ( + <> + {selectedKey && ( + { + setIsDeleteModalOpen(val); + if (!val) setSelectedKey(null); + }} + dialog={ +
+

{t("virtualApiKeysQuestionRemove")}

+

{t("virtualApiKeysMessageRemove")}

+
+ } + buttonText={t("virtualApiKeysDeleteConfirm")} + onConfirm={async () => + deleteKey(selectedKey.virtualApiKeyId) + } + string={selectedKey.name || selectedKey.virtualApiKeyId} + title={t("virtualApiKeysDelete")} + /> + )} + + { + setIsViewSecretOpen(val); + if (!val) setSelectedKey(null); + }} + virtualApiKeyId={selectedKey?.virtualApiKeyId ?? null} + name={selectedKey?.name} + /> + + { + setRows([val, ...rows]); + }} + /> + + { + setIsEditModalOpen(val); + if (!val) setSelectedKey(null); + }} + virtualApiKey={selectedKey} + onUpdated={(val) => { + setRows((prev) => + prev.map((row) => + row.virtualApiKeyId === val.virtualApiKeyId + ? val + : row + ) + ); + }} + /> + + { + setIsCreateModalOpen(true); + }} + onRefresh={refreshData} + isRefreshing={isRefreshing} + /> + + ); +} diff --git a/src/components/multi-resource-selector.tsx b/src/components/multi-resource-selector.tsx new file mode 100644 index 000000000..dc2685fef --- /dev/null +++ b/src/components/multi-resource-selector.tsx @@ -0,0 +1,142 @@ +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { Checkbox } from "./ui/checkbox"; +import { useTranslations } from "next-intl"; +import { useDebounce } from "use-debounce"; +import { type SelectedResource } from "./resource-selector"; + +export type MultiResourcesSelectorProps = { + orgId: string; + selectedResources: SelectedResource[]; + onSelectionChange: (resources: SelectedResource[]) => void; + excludeWildcard?: boolean; + onClear?: () => void; + showClear?: boolean; + protocol?: string; +}; + +export function formatMultiResourcesSelectorLabel( + selectedResources: SelectedResource[], + t: (key: string, values?: { count: number }) => string, + emptyLabelKey = "selectResources" +): string { + if (selectedResources.length === 0) { + return t(emptyLabelKey); + } + if (selectedResources.length === 1) { + return selectedResources[0]!.name; + } + return t("multiResourcesSelectorResourcesCount", { + count: selectedResources.length + }); +} + +export function MultiResourcesSelector({ + orgId, + selectedResources, + onSelectionChange, + excludeWildcard = false, + onClear, + showClear = false, + protocol +}: MultiResourcesSelectorProps) { + const t = useTranslations(); + const [resourceSearchQuery, setResourceSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(resourceSearchQuery, 150); + + const { data: resources = [] } = useQuery( + orgQueries.proxyResources({ + orgId, + query: debouncedQuery, + perPage: 10, + protocol + }) + ); + + const resourcesShown = useMemo(() => { + const base: SelectedResource[] = excludeWildcard + ? resources.filter((r) => !r.wildcard) + : [...resources]; + if ( + debouncedQuery.trim().length === 0 && + selectedResources.length > 0 + ) { + const selectedNotInBase = selectedResources.filter( + (sel) => + !base.some((r) => r.resourceId === sel.resourceId) && + !(excludeWildcard && sel.wildcard) + ); + return [...selectedNotInBase, ...base]; + } + return base; + }, [debouncedQuery, resources, selectedResources, excludeWildcard]); + + const selectedIds = useMemo( + () => new Set(selectedResources.map((r) => r.resourceId)), + [selectedResources] + ); + + const toggleResource = (resource: SelectedResource) => { + if (selectedIds.has(resource.resourceId)) { + onSelectionChange( + selectedResources.filter( + (r) => r.resourceId !== resource.resourceId + ) + ); + } else { + onSelectionChange([...selectedResources, resource]); + } + }; + + return ( + + setResourceSearchQuery(v)} + /> + + {t("resourcesNotFound")} + + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} + {resourcesShown.map((resource) => ( + { + toggleResource(resource); + }} + > + {}} + aria-hidden + tabIndex={-1} + /> + + {resource.name} + + + ))} + + + + ); +} diff --git a/src/components/resource-selector.tsx b/src/components/resource-selector.tsx index 625bb2d64..5dad2bf0e 100644 --- a/src/components/resource-selector.tsx +++ b/src/components/resource-selector.tsx @@ -25,13 +25,19 @@ export type ResourceSelectorProps = { selectedResource?: SelectedResource | null; onSelectResource: (resource: SelectedResource) => void; excludeWildcard?: boolean; + showClear?: boolean; + onClear?: () => void; + protocol?: string; }; export function ResourceSelector({ orgId, selectedResource, onSelectResource, - excludeWildcard = false + excludeWildcard = false, + showClear = false, + onClear, + protocol }: ResourceSelectorProps) { const t = useTranslations(); const [resourceSearchQuery, setResourceSearchQuery] = useState(""); @@ -42,7 +48,8 @@ export function ResourceSelector({ orgQueries.proxyResources({ orgId: orgId, query: debouncedSearchQuery, - perPage: 10 + perPage: 10, + protocol }) ); @@ -75,6 +82,14 @@ export function ResourceSelector({ {t("resourcesNotFound")} + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} {resourcesShown.map((r) => ( void; allowClear?: boolean; + showClear?: boolean; + onClear?: () => void; + unassignedOption?: { + label: string; + selected: boolean; + onSelect: () => void; + }; }; export function UserSelector({ orgId, selectedUser, onSelectUser, - allowClear = true + allowClear = true, + showClear = false, + onClear, + unassignedOption }: UserSelectorProps) { const t = useTranslations(); const [userSearchQuery, setUserSearchQuery] = useState(""); const [debouncedValue] = useDebounce(userSearchQuery, 150); const { data: users = [] } = useQuery( - orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) + orgQueries.users({ orgId, perPage: 10, term: debouncedValue }) ); const usersShown = useMemo(() => { @@ -64,6 +74,14 @@ export function UserSelector({ {t("usersNotFound")} + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} {allowClear && ( )} + {unassignedOption && ( + + + {unassignedOption.label} + + )} {usersShown.map((user) => ( queryOptions({ queryKey: [ "ORG", orgId, "PROXY_RESOURCES", - { query, perPage } + { query, perPage, protocol } ] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ @@ -404,6 +406,10 @@ export const orgQueries = { sp.set("query", query); } + if (protocol) { + sp.set("protocol", protocol); + } + const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/resources?${sp.toString()}`, { signal });