add manual virtual api key create ui

This commit is contained in:
miloschwartz
2026-08-10 21:44:22 -04:00
parent 4c1f7f6243
commit 6d45486bb5
16 changed files with 1974 additions and 18 deletions
+54
View File
@@ -1436,6 +1436,11 @@
"actionGetAiModel": "Get AI Model", "actionGetAiModel": "Get AI Model",
"actionListAiModels": "List AI Models", "actionListAiModels": "List AI Models",
"actionUpdateAiModel": "Update AI Model", "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", "actionApplyBlueprint": "Apply Blueprint",
"actionListBlueprints": "List Blueprints", "actionListBlueprints": "List Blueprints",
"actionGetBlueprint": "Get Blueprint", "actionGetBlueprint": "Get Blueprint",
@@ -1637,6 +1642,53 @@
"sidebarAiGateway": "AI Gateway", "sidebarAiGateway": "AI Gateway",
"sidebarAiProviders": "Providers", "sidebarAiProviders": "Providers",
"commandAiProviders": "AI 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", "aiProvidersTitle": "AI Providers",
"aiProvidersDescription": "Connect model providers for AI workloads in this organization", "aiProvidersDescription": "Connect model providers for AI workloads in this organization",
"aiProvidersAdd": "Add Provider", "aiProvidersAdd": "Add Provider",
@@ -2430,6 +2482,8 @@
"subnetPlaceholder": "Subnet", "subnetPlaceholder": "Subnet",
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
"selectSites": "Select sites", "selectSites": "Select sites",
"selectResources": "Select resources",
"multiResourcesSelectorResourcesCount": "{count, plural, one {# resource} other {# resources}}",
"selectLabels": "Select labels", "selectLabels": "Select labels",
"sitesDescription": "The client will have connectivity to the selected sites", "sitesDescription": "The client will have connectivity to the selected sites",
"clientInstallOlm": "Install Machine Client", "clientInstallOlm": "Install Machine Client",
+12 -2
View File
@@ -62,10 +62,18 @@ export async function assertManualKeyResourcesInOrg(params: {
}): Promise<{ ok: true } | { ok: false; message: string }> { }): Promise<{ ok: true } | { ok: false; message: string }> {
const { allResources, resourceIds, orgId } = params; const { allResources, resourceIds, orgId } = params;
if (allResources || resourceIds.length === 0) { if (allResources) {
return { ok: true }; 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 uniqueIds = [...new Set(resourceIds)];
const rows = await db const rows = await db
.select({ resourceId: resources.resourceId }) .select({ resourceId: resources.resourceId })
@@ -73,6 +81,7 @@ export async function assertManualKeyResourcesInOrg(params: {
.where( .where(
and( and(
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.mode, "inference"),
inArray(resources.resourceId, uniqueIds) inArray(resources.resourceId, uniqueIds)
) )
); );
@@ -80,7 +89,8 @@ export async function assertManualKeyResourcesInOrg(params: {
if (rows.length !== uniqueIds.length) { if (rows.length !== uniqueIds.length) {
return { return {
ok: false, 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"
}; };
} }
+11 -2
View File
@@ -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." "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 protocol: z
.enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"]) .enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc", "inference"])
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
type: "string", type: "string",
enum: ["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"], enum: [
"http",
"https",
"tcp",
"udp",
"ssh",
"rdp",
"vnc",
"inference"
],
description: description:
"Filter resources by protocol. `http` and `https` match HTTP resources without and with SSL respectively." "Filter resources by protocol. `http` and `https` match HTTP resources without and with SSL respectively."
}), }),
+11 -2
View File
@@ -4,14 +4,23 @@ export const virtualApiKeyResourceIdsSchema = z
.array(z.coerce.number().int().positive()) .array(z.coerce.number().int().positive())
.optional(); .optional();
export const createVirtualApiKeyBodySchema = z.strictObject({ export const createVirtualApiKeyBodySchema = z
.strictObject({
name: z.string().nonempty(), name: z.string().nonempty(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
userId: z.string().optional().nullable(), userId: z.string().optional().nullable(),
allResources: z.boolean().optional().default(false), allResources: z.boolean().optional().default(false),
resourceIds: virtualApiKeyResourceIdsSchema, resourceIds: virtualApiKeyResourceIdsSchema,
validForSeconds: z.int().positive().optional() 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({ export const updateVirtualApiKeyBodySchema = z.strictObject({
name: z.string().nonempty().optional(), name: z.string().nonempty().optional(),
@@ -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<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 (
<>
<SettingsSectionTitle
title={t("virtualApiKeysTitle")}
description={t("virtualApiKeysDescription")}
/>
<OrgProvider org={org}>
<VirtualApiKeysTable
virtualApiKeys={rows}
orgId={params.orgId}
/>
</OrgProvider>
</>
);
}
+10
View File
@@ -195,6 +195,11 @@ export const orgNavSections = (
title: "sidebarAiProviders", title: "sidebarAiProviders",
href: "/{orgId}/settings/ai-providers", href: "/{orgId}/settings/ai-providers",
icon: <Sparkles className="size-4 flex-none" /> icon: <Sparkles className="size-4 flex-none" />
},
{
title: "sidebarVirtualApiKeys",
href: "/{orgId}/settings/virtual-api-keys",
icon: <KeyRound className="size-4 flex-none" />
} }
] ]
}, },
@@ -490,6 +495,11 @@ export const commandBarNavSections = (
title: "commandAiProviders", title: "commandAiProviders",
href: "/{orgId}/settings/ai-providers", href: "/{orgId}/settings/ai-providers",
icon: <Sparkles className="size-4 flex-none" /> icon: <Sparkles className="size-4 flex-none" />
},
{
title: "commandVirtualApiKeys",
href: "/{orgId}/settings/virtual-api-keys",
icon: <KeyRound className="size-4 flex-none" />
} }
] ]
}, },
+419
View File
@@ -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<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 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([]);
form.reset();
}
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)
}
)
.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 (
<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(onSubmit)}
className="space-y-4"
id="virtual-api-key-form"
>
<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>
</form>
</Form>
)}
{credential && (
<div className="space-y-4">
<p>{t("virtualApiKeysSeeOnce")}</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(onSubmit)}
loading={loading}
disabled={credential !== null || loading}
>
{t("virtualApiKeysCreateButton")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+396
View File
@@ -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<SelectedUser | null>(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<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)
);
form.reset({
allResources: virtualApiKey.allResources
});
}, [open, virtualApiKey, form]);
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)
}
)
.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 (
<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(onSubmit)}
className="space-y-4"
id="edit-virtual-api-key-form"
>
<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">
<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">
<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(
"virtualApiKeysSelectResourcesRequired"
)}
</FormDescription>
</div>
)}
</div>
</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>
);
}
+8
View File
@@ -166,6 +166,14 @@ function getActionsCategories(root: boolean) {
[t("actionGetAiModel")]: "getAiModel", [t("actionGetAiModel")]: "getAiModel",
[t("actionListAiModels")]: "listAiModels", [t("actionListAiModels")]: "listAiModels",
[t("actionUpdateAiModel")]: "updateAiModel" [t("actionUpdateAiModel")]: "updateAiModel"
},
"Virtual API Key": {
[t("actionCreateVirtualApiKey")]: "createVirtualApiKey",
[t("actionDeleteVirtualApiKey")]: "deleteVirtualApiKey",
[t("actionGetVirtualApiKey")]: "getVirtualApiKey",
[t("actionListVirtualApiKeys")]: "listVirtualApiKeys",
[t("actionUpdateVirtualApiKey")]: "updateVirtualApiKey"
} }
}; };
+134
View File
@@ -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<string | null>(null);
useEffect(() => {
if (!open || !virtualApiKeyId) {
return;
}
let cancelled = false;
setLoading(true);
setCredential(null);
api.get<AxiosResponse<GetVirtualApiKeyResponse>>(
`/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 (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setCredential(null);
setLoading(false);
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("virtualApiKeysViewSecretTitle")}
</CredenzaTitle>
<CredenzaDescription>
{name ? name : t("virtualApiKeysViewSecretDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4 px-1">
{loading && (
<p className="text-sm text-muted-foreground">
{t("loading")}
</p>
)}
{!loading && credential && (
<CopyTextBox text={credential} wrapText={false} />
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
@@ -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<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createVirtualApiKey?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
};
export function VirtualApiKeysDataTable<TData, TValue>({
columns,
data,
createVirtualApiKey,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="virtualApiKeys-table"
title={t("virtualApiKeys")}
searchPlaceholder={t("virtualApiKeysSearch")}
searchColumn="name"
onAdd={createVirtualApiKey}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t("virtualApiKeysCreate")}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}
+516
View File
@@ -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<VirtualApiKeyRow | null>(
null
);
const [rows, setRows] = useState<VirtualApiKeyRow[]>(virtualApiKeys);
const [isRefreshing, setIsRefreshing] = useState(false);
const [userFilterOpen, setUserFilterOpen] = useState(false);
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
const [selectedResource, setSelectedResource] =
useState<SelectedResource | null>(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<VirtualApiKeyRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("virtualApiKeysName"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("virtualApiKeysName")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => row.original.name || "-"
},
{
id: "resources",
accessorFn: (row) => row.resourceNames,
friendlyName: t("resource"),
header: () => (
<Popover
open={resourceFilterOpen}
onOpenChange={setResourceFilterOpen}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedResource && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("resource")}
<Funnel className="size-4 flex-none" />
{selectedResource && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedResource.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<ResourceSelector
orgId={orgId}
selectedResource={selectedResource}
showClear={!!selectedResource}
onClear={clearResourceFilter}
protocol="inference"
onSelectResource={(resource) => {
setSelectedResource(resource);
setResourceFilterOpen(false);
}}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const r = row.original;
if (r.allResources) {
return t("virtualApiKeysAllResources");
}
if (r.resources.length === 0) {
return <span>{t("virtualApiKeysNoResources")}</span>;
}
if (r.resources.length === 1) {
const resource = r.resources[0];
if (!resource.niceId) {
return resource.name;
}
return (
<Link
href={`/${orgId}/settings/resources/public/${resource.niceId}`}
>
<Button variant="outline" size="sm">
{resource.name}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
return r.resourceNames;
}
},
{
accessorKey: "userId",
friendlyName: t("user"),
header: () => (
<Popover open={userFilterOpen} onOpenChange={setUserFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedUser &&
!unassignedOnly &&
"text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("user")}
<Funnel className="size-4 flex-none" />
{(selectedUser || unassignedOnly) && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{unassignedOnly
? t(
"virtualApiKeysFilterUnassigned"
)
: selectedUser?.text}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<UserSelector
orgId={orgId}
selectedUser={selectedUser}
allowClear={false}
showClear={!!selectedUser || unassignedOnly}
onClear={clearUserFilter}
unassignedOption={{
label: t("virtualApiKeysFilterUnassigned"),
selected: unassignedOnly,
onSelect: () => {
setSelectedUser(null);
setUnassignedOnly(true);
setUserFilterOpen(false);
}
}}
onSelectUser={(user) => {
setSelectedUser(user);
setUnassignedOnly(false);
setUserFilterOpen(false);
}}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const r = row.original;
if (!r.userId) {
return <span>-</span>;
}
return (
<Link href={`/${orgId}/settings/access/users/${r.userId}`}>
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
name: r.userName,
username: r.username
})}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
{
accessorKey: "lastChars",
friendlyName: t("virtualApiKeysSecret"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("virtualApiKeysSecret")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) =>
`vk-${row.original.virtualApiKeyId}.••••${row.original.lastChars}`
},
{
accessorKey: "createdAt",
friendlyName: t("created"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("created")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => moment(row.original.createdAt).format("lll")
},
{
accessorKey: "expiresAt",
friendlyName: t("expires"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("expires")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const expiresAt = row.original.expiresAt;
if (expiresAt) {
return moment(expiresAt).format("lll");
}
return t("never");
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const keyRow = row.original;
return (
<div className="flex items-center 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
onClick={() => {
setSelectedKey(keyRow);
setIsViewSecretOpen(true);
}}
>
{t("virtualApiKeysViewSecret")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedKey(keyRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
onClick={() => {
setSelectedKey(keyRow);
setIsEditModalOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
];
return (
<>
{selectedKey && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) setSelectedKey(null);
}}
dialog={
<div className="space-y-2">
<p>{t("virtualApiKeysQuestionRemove")}</p>
<p>{t("virtualApiKeysMessageRemove")}</p>
</div>
}
buttonText={t("virtualApiKeysDeleteConfirm")}
onConfirm={async () =>
deleteKey(selectedKey.virtualApiKeyId)
}
string={selectedKey.name || selectedKey.virtualApiKeyId}
title={t("virtualApiKeysDelete")}
/>
)}
<ViewVirtualApiKeySecret
open={isViewSecretOpen}
setOpen={(val) => {
setIsViewSecretOpen(val);
if (!val) setSelectedKey(null);
}}
virtualApiKeyId={selectedKey?.virtualApiKeyId ?? null}
name={selectedKey?.name}
/>
<CreateVirtualApiKeyForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreated={(val) => {
setRows([val, ...rows]);
}}
/>
<EditVirtualApiKeyForm
open={isEditModalOpen}
setOpen={(val) => {
setIsEditModalOpen(val);
if (!val) setSelectedKey(null);
}}
virtualApiKey={selectedKey}
onUpdated={(val) => {
setRows((prev) =>
prev.map((row) =>
row.virtualApiKeyId === val.virtualApiKeyId
? val
: row
)
);
}}
/>
<VirtualApiKeysDataTable
columns={columns}
data={filteredRows}
createVirtualApiKey={() => {
setIsCreateModalOpen(true);
}}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);
}
+142
View File
@@ -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 (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("resourceSearch")}
value={resourceSearchQuery}
onValueChange={(v) => setResourceSearchQuery(v)}
/>
<CommandList>
<CommandEmpty>{t("resourcesNotFound")}</CommandEmpty>
<CommandGroup>
{showClear && onClear && (
<CommandItem
onSelect={onClear}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{resourcesShown.map((resource) => (
<CommandItem
key={resource.resourceId}
value={`${resource.resourceId}:${resource.name}`}
onSelect={() => {
toggleResource(resource);
}}
>
<Checkbox
className="pointer-events-none shrink-0"
checked={selectedIds.has(resource.resourceId)}
onCheckedChange={() => {}}
aria-hidden
tabIndex={-1}
/>
<span className="min-w-0 flex-1 truncate">
{resource.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
+17 -2
View File
@@ -25,13 +25,19 @@ export type ResourceSelectorProps = {
selectedResource?: SelectedResource | null; selectedResource?: SelectedResource | null;
onSelectResource: (resource: SelectedResource) => void; onSelectResource: (resource: SelectedResource) => void;
excludeWildcard?: boolean; excludeWildcard?: boolean;
showClear?: boolean;
onClear?: () => void;
protocol?: string;
}; };
export function ResourceSelector({ export function ResourceSelector({
orgId, orgId,
selectedResource, selectedResource,
onSelectResource, onSelectResource,
excludeWildcard = false excludeWildcard = false,
showClear = false,
onClear,
protocol
}: ResourceSelectorProps) { }: ResourceSelectorProps) {
const t = useTranslations(); const t = useTranslations();
const [resourceSearchQuery, setResourceSearchQuery] = useState(""); const [resourceSearchQuery, setResourceSearchQuery] = useState("");
@@ -42,7 +48,8 @@ export function ResourceSelector({
orgQueries.proxyResources({ orgQueries.proxyResources({
orgId: orgId, orgId: orgId,
query: debouncedSearchQuery, query: debouncedSearchQuery,
perPage: 10 perPage: 10,
protocol
}) })
); );
@@ -75,6 +82,14 @@ export function ResourceSelector({
<CommandList> <CommandList>
<CommandEmpty>{t("resourcesNotFound")}</CommandEmpty> <CommandEmpty>{t("resourcesNotFound")}</CommandEmpty>
<CommandGroup> <CommandGroup>
{showClear && onClear && (
<CommandItem
onSelect={onClear}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{resourcesShown.map((r) => ( {resourcesShown.map((r) => (
<CommandItem <CommandItem
value={`${r.name}:${r.resourceId}`} value={`${r.name}:${r.resourceId}`}
+36 -2
View File
@@ -23,20 +23,30 @@ export type UserSelectorProps = {
selectedUser?: SelectedUser | null; selectedUser?: SelectedUser | null;
onSelectUser: (user: SelectedUser | null) => void; onSelectUser: (user: SelectedUser | null) => void;
allowClear?: boolean; allowClear?: boolean;
showClear?: boolean;
onClear?: () => void;
unassignedOption?: {
label: string;
selected: boolean;
onSelect: () => void;
};
}; };
export function UserSelector({ export function UserSelector({
orgId, orgId,
selectedUser, selectedUser,
onSelectUser, onSelectUser,
allowClear = true allowClear = true,
showClear = false,
onClear,
unassignedOption
}: UserSelectorProps) { }: UserSelectorProps) {
const t = useTranslations(); const t = useTranslations();
const [userSearchQuery, setUserSearchQuery] = useState(""); const [userSearchQuery, setUserSearchQuery] = useState("");
const [debouncedValue] = useDebounce(userSearchQuery, 150); const [debouncedValue] = useDebounce(userSearchQuery, 150);
const { data: users = [] } = useQuery( const { data: users = [] } = useQuery(
orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) orgQueries.users({ orgId, perPage: 10, term: debouncedValue })
); );
const usersShown = useMemo(() => { const usersShown = useMemo(() => {
@@ -64,6 +74,14 @@ export function UserSelector({
<CommandList> <CommandList>
<CommandEmpty>{t("usersNotFound")}</CommandEmpty> <CommandEmpty>{t("usersNotFound")}</CommandEmpty>
<CommandGroup> <CommandGroup>
{showClear && onClear && (
<CommandItem
onSelect={onClear}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{allowClear && ( {allowClear && (
<CommandItem <CommandItem
value="__none__" value="__none__"
@@ -80,6 +98,22 @@ export function UserSelector({
{t("none")} {t("none")}
</CommandItem> </CommandItem>
)} )}
{unassignedOption && (
<CommandItem
value="__unassigned__"
onSelect={unassignedOption.onSelect}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
unassignedOption.selected
? "opacity-100"
: "opacity-0"
)}
/>
{unassignedOption.label}
</CommandItem>
)}
{usersShown.map((user) => ( {usersShown.map((user) => (
<CommandItem <CommandItem
value={`${user.text}:${user.id}`} value={`${user.text}:${user.id}`}
+8 -2
View File
@@ -382,18 +382,20 @@ export const orgQueries = {
proxyResources: ({ proxyResources: ({
orgId, orgId,
query, query,
perPage = 10_000 perPage = 10_000,
protocol
}: { }: {
orgId: string; orgId: string;
query?: string; query?: string;
perPage?: number; perPage?: number;
protocol?: string;
}) => }) =>
queryOptions({ queryOptions({
queryKey: [ queryKey: [
"ORG", "ORG",
orgId, orgId,
"PROXY_RESOURCES", "PROXY_RESOURCES",
{ query, perPage } { query, perPage, protocol }
] as const, ] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
@@ -404,6 +406,10 @@ export const orgQueries = {
sp.set("query", query); sp.set("query", query);
} }
if (protocol) {
sp.set("protocol", protocol);
}
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListResourcesResponse> AxiosResponse<ListResourcesResponse>
>(`/org/${orgId}/resources?${sp.toString()}`, { signal }); >(`/org/${orgId}/resources?${sp.toString()}`, { signal });