mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 15:30:53 +02:00
add manual virtual api key create ui
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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("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"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
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}`}
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user