mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 07:20:43 +02:00
add manual virtual api key create ui
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -195,6 +195,11 @@ export const orgNavSections = (
|
||||
title: "sidebarAiProviders",
|
||||
href: "/{orgId}/settings/ai-providers",
|
||||
icon: <Sparkles className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarVirtualApiKeys",
|
||||
href: "/{orgId}/settings/virtual-api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -490,6 +495,11 @@ export const commandBarNavSections = (
|
||||
title: "commandAiProviders",
|
||||
href: "/{orgId}/settings/ai-providers",
|
||||
icon: <Sparkles className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "commandVirtualApiKeys",
|
||||
href: "/{orgId}/settings/virtual-api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("resourcesNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showClear && onClear && (
|
||||
<CommandItem
|
||||
onSelect={onClear}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{resourcesShown.map((r) => (
|
||||
<CommandItem
|
||||
value={`${r.name}:${r.resourceId}`}
|
||||
|
||||
@@ -23,20 +23,30 @@ export type UserSelectorProps = {
|
||||
selectedUser?: SelectedUser | null;
|
||||
onSelectUser: (user: SelectedUser | null) => 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({
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("usersNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showClear && onClear && (
|
||||
<CommandItem
|
||||
onSelect={onClear}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{allowClear && (
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -80,6 +98,22 @@ export function UserSelector({
|
||||
{t("none")}
|
||||
</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) => (
|
||||
<CommandItem
|
||||
value={`${user.text}:${user.id}`}
|
||||
|
||||
+8
-2
@@ -382,18 +382,20 @@ export const orgQueries = {
|
||||
proxyResources: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 10_000
|
||||
perPage = 10_000,
|
||||
protocol
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
protocol?: string;
|
||||
}) =>
|
||||
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<ListResourcesResponse>
|
||||
>(`/org/${orgId}/resources?${sp.toString()}`, { signal });
|
||||
|
||||
Reference in New Issue
Block a user