diff --git a/messages/en-US.json b/messages/en-US.json index abb6bf405..7943138d9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1667,7 +1667,6 @@ "virtualApiKeysNoResources": "No resources", "virtualApiKeysSecret": "Key", "virtualApiKeysCopyKey": "Copy this key. You can view it again later from the table or when editing.", - "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", @@ -1692,6 +1691,24 @@ "virtualApiKeysErrorFetchSecret": "Error loading secret", "virtualApiKeysErrorFetchSecretDescription": "Failed to load the virtual API key secret", "virtualApiKeysFilterUnassigned": "Unassigned", + "myVirtualApiKeysTitle": "Your API Keys", + "myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization", + "myVirtualApiKeysResourceTitle": "Your API Keys for This Resource", + "myVirtualApiKeysResourceDescription": "View your identity key and virtual API keys attributed to you that can access this resource", + "myVirtualApiKeysIdentityTitle": "Identity Key", + "myVirtualApiKeysIdentityHeadline": "Your Personal API Key", + "myVirtualApiKeysIdentityDescription": "Your personal key for this organization. It is unique to your account and used to identify you when calling AI Gateway resources.", + "myVirtualApiKeysIdentityResourceHeadline": "Your Personal API Key for This Resource", + "myVirtualApiKeysIdentityResourceDescription": "Your personal key for this organization. Use it to call this AI Gateway resource.", + "myVirtualApiKeysManualTitle": "Attributed Keys", + "myVirtualApiKeysManualDescription": "Manual virtual API keys an admin associated with your account", + "myVirtualApiKeysManualResourceDescription": "Manual virtual API keys associated with your account that can access this resource", + "myVirtualApiKeysManualEmpty": "No attributed keys yet", + "myVirtualApiKeysKindUser": "Identity", + "myVirtualApiKeysKindManual": "Manual", + "myVirtualApiKeysUnnamed": "Unnamed key", + "myVirtualApiKeysRevealSecret": "Reveal Secret", + "myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources", "aiProvidersTitle": "AI Providers", "aiProvidersDescription": "Connect model providers for AI workloads in this organization", "aiProvidersAdd": "Add Provider", diff --git a/server/routers/external.ts b/server/routers/external.ts index 1eb32ec0f..141660f0f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1650,6 +1650,18 @@ authenticated.get( virtualApiKey.listVirtualApiKeys ); +authenticated.get( + "/org/:orgId/my-virtual-api-keys", + verifyOrgAccess, + virtualApiKey.listMyVirtualApiKeys +); + +authenticated.get( + "/org/:orgId/my-virtual-api-keys/:virtualApiKeyId", + verifyOrgAccess, + virtualApiKey.getMyVirtualApiKey +); + authenticated.get( "/virtual-api-key/:virtualApiKeyId", verifyVirtualApiKeyAccess, diff --git a/server/routers/virtualApiKey/getMyVirtualApiKey.ts b/server/routers/virtualApiKey/getMyVirtualApiKey.ts new file mode 100644 index 000000000..2c8887f55 --- /dev/null +++ b/server/routers/virtualApiKey/getMyVirtualApiKey.ts @@ -0,0 +1,113 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, virtualApiKeyResources, virtualApiKeys } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { toPublicVirtualApiKey } from "@server/lib/virtualApiKey"; +import type { GetMyVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + virtualApiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/my-virtual-api-keys/{virtualApiKeyId}", + description: + "Get a virtual API key owned by the signed-in user, including the decrypted secret.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getMyVirtualApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, virtualApiKeyId } = parsedParams.data; + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const [key] = await db + .select() + .from(virtualApiKeys) + .where( + and( + eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId), + eq(virtualApiKeys.orgId, orgId), + eq(virtualApiKeys.userId, userId) + ) + ) + .limit(1); + + if (!key) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Virtual API key with ID ${virtualApiKeyId} not found` + ) + ); + } + + const resourceRows = await db + .select({ resourceId: virtualApiKeyResources.resourceId }) + .from(virtualApiKeyResources) + .where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId)); + + return response(res, { + data: { + virtualApiKey: { + ...toPublicVirtualApiKey(key, { includeSecret: true }), + resourceIds: resourceRows.map((row) => row.resourceId) + } + }, + success: true, + error: false, + message: "Virtual API key retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/index.ts b/server/routers/virtualApiKey/index.ts index b80fba765..2cb06a5ca 100644 --- a/server/routers/virtualApiKey/index.ts +++ b/server/routers/virtualApiKey/index.ts @@ -1,6 +1,8 @@ export * from "./createVirtualApiKey"; export * from "./listVirtualApiKeys"; +export * from "./listMyVirtualApiKeys"; export * from "./getVirtualApiKey"; +export * from "./getMyVirtualApiKey"; export * from "./updateVirtualApiKey"; export * from "./deleteVirtualApiKey"; export * from "./types"; diff --git a/server/routers/virtualApiKey/listMyVirtualApiKeys.ts b/server/routers/virtualApiKey/listMyVirtualApiKeys.ts new file mode 100644 index 000000000..244377ef1 --- /dev/null +++ b/server/routers/virtualApiKey/listMyVirtualApiKeys.ts @@ -0,0 +1,219 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + resources, + virtualApiKeyResources, + virtualApiKeys, + type VirtualApiKey +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq, exists, inArray, or } from "drizzle-orm"; +import { + getOrCreateUserVirtualApiKey, + toPublicVirtualApiKey +} from "@server/lib/virtualApiKey"; +import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.object({ + resourceGuid: z.string().nonempty().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/my-virtual-api-keys", + description: + "List the signed-in user's identity virtual API key and manual keys attributed to them.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +async function resourceIdsForKeys( + keyIds: string[] +): Promise> { + const resourceIdsByKey = new Map(); + if (keyIds.length === 0) { + return resourceIdsByKey; + } + + const resourceRows = await db + .select() + .from(virtualApiKeyResources) + .where(inArray(virtualApiKeyResources.virtualApiKeyId, keyIds)); + + for (const row of resourceRows) { + const existing = resourceIdsByKey.get(row.virtualApiKeyId) ?? []; + existing.push(row.resourceId); + resourceIdsByKey.set(row.virtualApiKeyId, existing); + } + + return resourceIdsByKey; +} + +function toKeyWithResources( + row: VirtualApiKey, + resourceIdsByKey: Map +) { + return { + ...toPublicVirtualApiKey(row), + resourceIds: resourceIdsByKey.get(row.virtualApiKeyId) ?? [] + }; +} + +export async function listMyVirtualApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { resourceGuid } = parsedQuery.data; + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + let resourceId: number | undefined; + if (resourceGuid) { + const [resource] = await db + .select({ + resourceId: resources.resourceId + }) + .from(resources) + .where( + and( + eq(resources.resourceGuid, resourceGuid), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with GUID ${resourceGuid} not found` + ) + ); + } + + resourceId = resource.resourceId; + } + + const { key: userKeyRow } = await getOrCreateUserVirtualApiKey({ + orgId, + userId, + createdByUserId: userId + }); + + const manualConditions = [ + eq(virtualApiKeys.orgId, orgId), + eq(virtualApiKeys.userId, userId), + eq(virtualApiKeys.kind, "manual") + ]; + + if (resourceId !== undefined) { + manualConditions.push( + or( + eq(virtualApiKeys.allResources, true), + exists( + db + .select() + .from(virtualApiKeyResources) + .where( + and( + eq( + virtualApiKeyResources.virtualApiKeyId, + virtualApiKeys.virtualApiKeyId + ), + eq( + virtualApiKeyResources.resourceId, + resourceId + ) + ) + ) + ) + )! + ); + } + + const manualRows = await db + .select() + .from(virtualApiKeys) + .where(and(...manualConditions)) + .orderBy(asc(virtualApiKeys.name), asc(virtualApiKeys.createdAt)); + + const allKeyIds = [ + userKeyRow.virtualApiKeyId, + ...manualRows.map((row) => row.virtualApiKeyId) + ]; + const resourceIdsByKey = await resourceIdsForKeys(allKeyIds); + + return response(res, { + data: { + userKey: toKeyWithResources(userKeyRow, resourceIdsByKey), + manualKeys: manualRows.map((row) => + toKeyWithResources(row, resourceIdsByKey) + ) + }, + success: true, + error: false, + message: "Virtual API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/virtualApiKey/types.ts b/server/routers/virtualApiKey/types.ts index 28823577f..73e475632 100644 --- a/server/routers/virtualApiKey/types.ts +++ b/server/routers/virtualApiKey/types.ts @@ -3,14 +3,27 @@ import type { PaginatedResponse } from "@server/types/Pagination"; export type { PublicVirtualApiKey }; +export type VirtualApiKeyWithResources = PublicVirtualApiKey & { + resourceIds: number[]; +}; + export type ListVirtualApiKeysResponse = PaginatedResponse<{ - virtualApiKeys: (PublicVirtualApiKey & { resourceIds: number[] })[]; + virtualApiKeys: VirtualApiKeyWithResources[]; }>; export type GetVirtualApiKeyResponse = { - virtualApiKey: PublicVirtualApiKey & { resourceIds: number[] }; + virtualApiKey: VirtualApiKeyWithResources; }; export type CreateOrEditVirtualApiKeyResponse = { - virtualApiKey: PublicVirtualApiKey & { resourceIds: number[] }; + virtualApiKey: VirtualApiKeyWithResources; +}; + +export type ListMyVirtualApiKeysResponse = { + userKey: VirtualApiKeyWithResources; + manualKeys: VirtualApiKeyWithResources[]; +}; + +export type GetMyVirtualApiKeyResponse = { + virtualApiKey: VirtualApiKeyWithResources; }; diff --git a/src/app/[orgId]/keys/page.tsx b/src/app/[orgId]/keys/page.tsx new file mode 100644 index 000000000..007ed2869 --- /dev/null +++ b/src/app/[orgId]/keys/page.tsx @@ -0,0 +1,113 @@ +import { Layout } from "@app/components/Layout"; +import UserVirtualApiKeys from "@app/components/UserVirtualApiKeys"; +import { commandBarNavSections } from "@app/app/navigation"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { pullEnv } from "@app/lib/pullEnv"; +import UserProvider from "@app/providers/UserProvider"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; +import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; +import { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { + title: t("myVirtualApiKeysTitle") + }; +} + +type KeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function KeysPage(props: KeysPageProps) { + const params = await props.params; + const orgId = params.orgId; + + if (!orgId) { + redirect(`/`); + } + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect("/"); + } + + const cookieHeader = await authCookieHeader(); + + let overview: GetOrgOverviewResponse | undefined; + try { + const res = await internal.get>( + `/org/${orgId}/overview`, + cookieHeader + ); + overview = res.data.data; + } catch { + // leave undefined + } + + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + cookieHeader + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch { + // leave empty + } + + if (!orgs.some((org) => org.orgId === orgId)) { + redirect("/"); + } + + let keysData: ListMyVirtualApiKeysResponse | null = null; + try { + const res = await internal.get< + AxiosResponse + >(`/org/${orgId}/my-virtual-api-keys`, cookieHeader); + keysData = res.data.data; + } catch { + redirect(`/${orgId}`); + } + + if (!keysData) { + redirect(`/${orgId}`); + } + + const env = pullEnv(); + const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg; + const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); + + return ( + + + + + + ); +} diff --git a/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx new file mode 100644 index 000000000..e85bff9fb --- /dev/null +++ b/src/app/[orgId]/resource/[resourceGuid]/keys/page.tsx @@ -0,0 +1,121 @@ +import { Layout } from "@app/components/Layout"; +import UserVirtualApiKeys from "@app/components/UserVirtualApiKeys"; +import { commandBarNavSections } from "@app/app/navigation"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { pullEnv } from "@app/lib/pullEnv"; +import UserProvider from "@app/providers/UserProvider"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; +import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types"; +import { AxiosResponse } from "axios"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { + title: t("myVirtualApiKeysResourceTitle") + }; +} + +type ResourceKeysPageProps = { + params: Promise<{ orgId: string; resourceGuid: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ResourceKeysPage(props: ResourceKeysPageProps) { + const params = await props.params; + const orgId = params.orgId; + const resourceGuid = params.resourceGuid; + + if (!orgId || !resourceGuid) { + redirect(`/`); + } + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect("/"); + } + + const cookieHeader = await authCookieHeader(); + + let overview: GetOrgOverviewResponse | undefined; + try { + const res = await internal.get>( + `/org/${orgId}/overview`, + cookieHeader + ); + overview = res.data.data; + } catch { + // leave undefined + } + + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + cookieHeader + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch { + // leave empty + } + + if (!orgs.some((org) => org.orgId === orgId)) { + redirect("/"); + } + + let keysData: ListMyVirtualApiKeysResponse | null = null; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${orgId}/my-virtual-api-keys?resourceGuid=${encodeURIComponent(resourceGuid)}`, + cookieHeader + ); + keysData = res.data.data; + } catch { + redirect(`/${orgId}/keys`); + } + + if (!keysData) { + redirect(`/${orgId}/keys`); + } + + const env = pullEnv(); + const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg; + const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); + + return ( + + + + + + ); +} diff --git a/src/components/UserVirtualApiKeys.tsx b/src/components/UserVirtualApiKeys.tsx new file mode 100644 index 000000000..5513b59e4 --- /dev/null +++ b/src/components/UserVirtualApiKeys.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { AxiosResponse } from "axios"; +import moment from "moment"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import CopyTextBox from "@app/components/CopyTextBox"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle as SectionTitle +} from "@app/components/Settings"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import type { + GetMyVirtualApiKeyResponse, + ListMyVirtualApiKeysResponse, + VirtualApiKeyWithResources +} from "@server/routers/virtualApiKey/types"; + +type UserVirtualApiKeysProps = { + orgId: string; + resourceGuid?: string; + initialData: ListMyVirtualApiKeysResponse; +}; + +function keyPreview(virtualApiKeyId: string, lastChars: string): string { + return `vk-${virtualApiKeyId}••••${lastChars}`; +} + +function useRevealSecret(orgId: string, virtualApiKeyId: string) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [credential, setCredential] = useState(null); + const [loading, setLoading] = useState(false); + + const revealSecret = () => { + if (credential || loading) { + return; + } + + setLoading(true); + api.get>( + `/org/${orgId}/my-virtual-api-keys/${virtualApiKeyId}` + ) + .then((res) => { + const secret = res.data.data.virtualApiKey.secret; + if (secret) { + setCredential(`vk-${virtualApiKeyId}.${secret}`); + } else { + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorFetchSecret"), + description: t( + "virtualApiKeysErrorFetchSecretDescription" + ) + }); + } + }) + .catch((e) => { + toast({ + variant: "destructive", + title: t("virtualApiKeysErrorFetchSecret"), + description: formatAxiosError( + e, + t("virtualApiKeysErrorFetchSecretDescription") + ) + }); + }) + .finally(() => { + setLoading(false); + }); + }; + + return { credential, loading, revealSecret }; +} + +function OwnedKeySecret({ + orgId, + virtualApiKeyId, + lastChars +}: { + orgId: string; + virtualApiKeyId: string; + lastChars: string; +}) { + const t = useTranslations(); + const preview = keyPreview(virtualApiKeyId, lastChars); + const { credential, loading, revealSecret } = useRevealSecret( + orgId, + virtualApiKeyId + ); + const displayValue = credential ?? preview; + + return ( +
+
+ +
+ {!credential ? ( + + ) : null} +
+ ); +} + +function IdentityKeyCenterpiece({ + orgId, + virtualApiKeyId, + lastChars, + resourceGuid +}: { + orgId: string; + virtualApiKeyId: string; + lastChars: string; + resourceGuid?: string; +}) { + const t = useTranslations(); + const preview = keyPreview(virtualApiKeyId, lastChars); + const { credential, loading, revealSecret } = useRevealSecret( + orgId, + virtualApiKeyId + ); + const displayValue = credential ?? preview; + const headline = resourceGuid + ? t("myVirtualApiKeysIdentityResourceHeadline") + : t("myVirtualApiKeysIdentityHeadline"); + const description = resourceGuid + ? t("myVirtualApiKeysIdentityResourceDescription") + : t("myVirtualApiKeysIdentityDescription"); + + return ( +
+

+ {headline} +

+

+ {description} +

+
+
+ +
+ {!credential ? ( +
+ +
+ ) : null} +
+
+ ); +} + +function ManualKeyRow({ + orgId, + keyRow +}: { + orgId: string; + keyRow: VirtualApiKeyWithResources; +}) { + const t = useTranslations(); + + return ( +
+
+
+

+ {keyRow.name || t("myVirtualApiKeysUnnamed")} +

+
+ {keyRow.description ? ( +

+ {keyRow.description} +

+ ) : null} +
+ +
+

+ {t("created")} {moment(keyRow.createdAt).format("lll")} +

+
+
+ ); +} + +export default function UserVirtualApiKeys({ + orgId, + resourceGuid, + initialData +}: UserVirtualApiKeysProps) { + const t = useTranslations(); + + const title = resourceGuid + ? t("myVirtualApiKeysResourceTitle") + : t("myVirtualApiKeysTitle"); + const description = resourceGuid + ? t("myVirtualApiKeysResourceDescription") + : t("myVirtualApiKeysDescription"); + + return ( + <> + + + + {initialData.manualKeys.length > 0 ? ( + + + + {t("myVirtualApiKeysManualTitle")} + + + {resourceGuid + ? t( + "myVirtualApiKeysManualResourceDescription" + ) + : t("myVirtualApiKeysManualDescription")} + + + + + {initialData.manualKeys.map((keyRow) => ( + + + + ))} + + + + ) : null} + + + ); +}