From 53b1d8a9f34b595508716ad533618150d085ba54 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 14 Aug 2026 17:37:17 -0400 Subject: [PATCH] option to send identity keys in email --- messages/en-US.json | 23 ++ .../templates/IdentityApiKeyGenerated.tsx | 78 ++++++ .../templates/VirtualApiKeyGenerated.tsx | 5 +- server/emails/templates/components/Email.tsx | 2 +- server/lib/sendVirtualApiKeyEmail.ts | 53 +++- server/routers/external.ts | 9 + server/routers/integration.ts | 9 + .../virtualApiKey/emailIdentityKeys.ts | 264 ++++++++++++++++++ server/routers/virtualApiKey/index.ts | 1 + server/routers/virtualApiKey/types.ts | 5 + .../virtual-api-keys/(list)/identity/page.tsx | 16 ++ .../virtual-api-keys/(list)/keys/page.tsx | 142 ++++++++++ .../virtual-api-keys/(list)/layout.tsx | 37 +++ .../settings/virtual-api-keys/page.tsx | 149 +--------- src/components/EmailIdentityKeysForm.tsx | 192 +++++++++++++ src/components/IdentityKeysSplash.tsx | 117 ++++++++ src/components/VirtualApiKeysBanner.tsx | 45 --- 17 files changed, 942 insertions(+), 205 deletions(-) create mode 100644 server/emails/templates/IdentityApiKeyGenerated.tsx create mode 100644 server/routers/virtualApiKey/emailIdentityKeys.ts create mode 100644 src/app/[orgId]/settings/virtual-api-keys/(list)/identity/page.tsx create mode 100644 src/app/[orgId]/settings/virtual-api-keys/(list)/keys/page.tsx create mode 100644 src/app/[orgId]/settings/virtual-api-keys/(list)/layout.tsx create mode 100644 src/components/EmailIdentityKeysForm.tsx create mode 100644 src/components/IdentityKeysSplash.tsx delete mode 100644 src/components/VirtualApiKeysBanner.tsx diff --git a/messages/en-US.json b/messages/en-US.json index f918a51c3..ea227ebe2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1678,6 +1678,29 @@ "commandVirtualApiKeys": "Virtual API Keys", "virtualApiKeysTitle": "Manage Virtual API Keys", "virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public AI gateways", + "virtualApiKeysTabIdentity": "Identity Keys", + "virtualApiKeysTabVirtual": "Virtual Keys", + "virtualApiKeysIdentitySplashTitle": "Identity Keys for Every User", + "virtualApiKeysIdentitySplashDescription": "Every user already has a Pangolin identity key for this organization. It is unique to their account and authenticates them to AI gateways they can access.", + "virtualApiKeysIdentitySplashExample": "Example", + "virtualApiKeysIdentitySplashRetrieveTitle": "How Users Get Their Key", + "virtualApiKeysIdentitySplashRetrieveResource": "Visit a public AI gateway URL in the browser and log in with their account.", + "virtualApiKeysIdentitySplashRetrievePage": "Or go to while signed in.", + "virtualApiKeysIdentitySplashManual": "You can create additional virtual API keys on the Virtual Keys tab. Those keys can be scoped to specific public AI gateways and optionally associated with a user. Creating a key immediately grants access to the selected public AI gateways.", + "virtualApiKeysIdentitySplashGoToVirtual": "Manually Create a Key", + "virtualApiKeysEmailIdentity": "Email Identity Keys", + "virtualApiKeysEmailIdentityDescription": "Send each selected user or role their Pangolin identity key.", + "virtualApiKeysEmailIdentitySendAll": "Send to all users", + "virtualApiKeysEmailIdentitySendAllDescription": "Email every organization member who has an account email.", + "virtualApiKeysEmailIdentitySelectUsers": "Users", + "virtualApiKeysEmailIdentitySelectRoles": "Roles", + "virtualApiKeysEmailIdentitySubmit": "Send Emails", + "virtualApiKeysEmailIdentitySuccess": "Identity keys emailed", + "virtualApiKeysEmailIdentitySuccessDescription": "Sent {sent} emails.", + "virtualApiKeysEmailIdentitySkipped": "{skipped} users were skipped because they do not have an email address.", + "virtualApiKeysEmailIdentityRecipientsRequired": "Select at least one user or role, or send to all users.", + "virtualApiKeysEmailIdentityError": "Error sending identity keys", + "virtualApiKeysEmailIdentityErrorDescription": "Failed to email identity keys", "virtualApiKeysBannerTitle": "Identity Keys for Every User", "virtualApiKeysBannerDescription": "Every user already has an identity key available at {keysUrl}. You can also manually generate keys here that grant direct access to public AI gateways.", "virtualApiKeysBannerButtonText": "View Identity Keys", diff --git a/server/emails/templates/IdentityApiKeyGenerated.tsx b/server/emails/templates/IdentityApiKeyGenerated.tsx new file mode 100644 index 000000000..be49bb8ff --- /dev/null +++ b/server/emails/templates/IdentityApiKeyGenerated.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailInfoSection, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText +} from "./components/Email"; + +type IdentityApiKeyGeneratedProps = { + orgName: string; + accountLabel?: string | null; + credential: string; + resourceUrls: string[]; + hasMoreResources: boolean; +}; + +export const IdentityApiKeyGenerated = ({ + orgName, + accountLabel, + credential, + resourceUrls, + hasMoreResources +}: IdentityApiKeyGeneratedProps) => { + const previewText = `Your personal identity key for ${orgName}`; + + return ( + + + {previewText} + + + + + + Hi there, + + + This is your personal identity key for{" "} + {orgName}. It belongs to your + account and identifies you when you use public AI + gateways. + + + + Use it with resources your administrator has granted + you, or that your role has access to. Treat this key + like a password and do not share it. + + + + Your identity key: +
+
+ + {credential} + +
+
+
+ + + + +
+ +
+ + ); +}; + +export default IdentityApiKeyGenerated; diff --git a/server/emails/templates/VirtualApiKeyGenerated.tsx b/server/emails/templates/VirtualApiKeyGenerated.tsx index f156c1b6e..df7b700a8 100644 --- a/server/emails/templates/VirtualApiKeyGenerated.tsx +++ b/server/emails/templates/VirtualApiKeyGenerated.tsx @@ -42,8 +42,9 @@ export const VirtualApiKeyGenerated = ({ A virtual API key for {orgName} has - been shared with you. Treat this key like a password - and do not share it. + been shared with you. This key grants access to the + public AI gateways it was created for. Treat this + key like a password and do not share it. diff --git a/server/emails/templates/components/Email.tsx b/server/emails/templates/components/Email.tsx index f74046042..61e1b7116 100644 --- a/server/emails/templates/components/Email.tsx +++ b/server/emails/templates/components/Email.tsx @@ -18,7 +18,7 @@ export function EmailLetterHead() { Pangolin Logo diff --git a/server/lib/sendVirtualApiKeyEmail.ts b/server/lib/sendVirtualApiKeyEmail.ts index 48d7d0d6b..4eecd180f 100644 --- a/server/lib/sendVirtualApiKeyEmail.ts +++ b/server/lib/sendVirtualApiKeyEmail.ts @@ -2,6 +2,7 @@ import { db, resources, users, virtualApiKeyResources } from "@server/db"; import { and, asc, eq } from "drizzle-orm"; import config from "@server/lib/config"; import { sendEmail } from "@server/emails"; +import IdentityApiKeyGenerated from "@server/emails/templates/IdentityApiKeyGenerated"; import VirtualApiKeyGenerated from "@server/emails/templates/VirtualApiKeyGenerated"; import { formatVirtualApiKeyCredential } from "@server/lib/virtualApiKey"; @@ -60,6 +61,17 @@ async function listVirtualApiKeyGatewayUrls(params: { }; } +export async function listOrgInferenceGatewayUrls(orgId: string): Promise<{ + urls: string[]; + hasMore: boolean; +}> { + return listVirtualApiKeyGatewayUrls({ + orgId, + allResources: true, + virtualApiKeyId: "" + }); +} + export async function resolveVirtualApiKeyEmailRecipients(params: { sendEmail: boolean; sendToAttributedUser: boolean; @@ -125,6 +137,9 @@ export async function sendVirtualApiKeyEmails(params: { virtualApiKeyId: string; secret: string; allResources: boolean; + isIdentityKey?: boolean; + accountLabel?: string | null; + gatewayUrls?: { urls: string[]; hasMore: boolean }; }): Promise { if (params.recipients.length === 0) { return; @@ -134,23 +149,35 @@ export async function sendVirtualApiKeyEmails(params: { params.virtualApiKeyId, params.secret ); - const { urls, hasMore } = await listVirtualApiKeyGatewayUrls({ - orgId: params.orgId, - allResources: params.allResources, - virtualApiKeyId: params.virtualApiKeyId - }); + const { urls, hasMore } = + params.gatewayUrls ?? + (await listVirtualApiKeyGatewayUrls({ + orgId: params.orgId, + allResources: params.allResources, + virtualApiKeyId: params.virtualApiKeyId + })); const from = config.getNoReplyEmail(); - const subject = `Virtual API key for ${params.orgName}`; + const subject = params.isIdentityKey + ? `Your identity key for ${params.orgName}` + : `Virtual API key for ${params.orgName}`; for (const to of params.recipients) { await sendEmail( - VirtualApiKeyGenerated({ - orgName: params.orgName, - keyName: params.keyName, - credential, - resourceUrls: urls, - hasMoreResources: hasMore - }), + params.isIdentityKey + ? IdentityApiKeyGenerated({ + orgName: params.orgName, + accountLabel: params.accountLabel, + credential, + resourceUrls: urls, + hasMoreResources: hasMore + }) + : VirtualApiKeyGenerated({ + orgName: params.orgName, + keyName: params.keyName, + credential, + resourceUrls: urls, + hasMoreResources: hasMore + }), { to, from, diff --git a/server/routers/external.ts b/server/routers/external.ts index e182d2b44..b51fbf2de 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1735,6 +1735,15 @@ authenticated.get( virtualApiKey.listVirtualApiKeys ); +authenticated.post( + "/org/:orgId/virtual-api-keys/email-identity-keys", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getVirtualApiKey), + virtualApiKey.emailIdentityKeysRateLimit, + logActionAudit(ActionsEnum.getVirtualApiKey), + virtualApiKey.emailIdentityKeys +); + authenticated.get( "/org/:orgId/my-virtual-api-keys", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index f609443d4..214c1ff27 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -1769,6 +1769,15 @@ authenticated.get( virtualApiKey.listVirtualApiKeys ); +authenticated.post( + "/org/:orgId/virtual-api-keys/email-identity-keys", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getVirtualApiKey), + virtualApiKey.emailIdentityKeysRateLimit, + logActionAudit(ActionsEnum.getVirtualApiKey), + virtualApiKey.emailIdentityKeys +); + authenticated.get( "/virtual-api-key/:virtualApiKeyId", verifyApiKeyVirtualApiKeyAccess, diff --git a/server/routers/virtualApiKey/emailIdentityKeys.ts b/server/routers/virtualApiKey/emailIdentityKeys.ts new file mode 100644 index 000000000..4c4963751 --- /dev/null +++ b/server/routers/virtualApiKey/emailIdentityKeys.ts @@ -0,0 +1,264 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, orgs, roles, userOrgRoles, userOrgs, users } 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, inArray } from "drizzle-orm"; +import config from "@server/lib/config"; +import { getOrCreateUserVirtualApiKey } from "@server/lib/virtualApiKey"; +import { + sendVirtualApiKeyEmails, + listOrgInferenceGatewayUrls +} from "@server/lib/sendVirtualApiKeyEmail"; +import type { EmailIdentityKeysResponse } from "@server/routers/virtualApiKey/types"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; +import { createStore } from "#dynamic/lib/rateLimitStore"; + +const EMAIL_IDENTITY_KEYS_WINDOW_MINUTES = 15; +const EMAIL_IDENTITY_KEYS_MAX = 3; + +export const emailIdentityKeysRateLimit = rateLimit({ + windowMs: EMAIL_IDENTITY_KEYS_WINDOW_MINUTES * 60 * 1000, + max: EMAIL_IDENTITY_KEYS_MAX, + keyGenerator: (req) => { + const actor = + req.user?.userId || + req.apiKey?.apiKeyId || + ipKeyGenerator(req.ip || ""); + const orgId = + typeof req.params.orgId === "string" ? req.params.orgId : ""; + return `emailIdentityKeys:${actor}:${orgId}`; + }, + handler: (_req, _res, next) => { + const message = `You can only email identity keys ${EMAIL_IDENTITY_KEYS_MAX} times every ${EMAIL_IDENTITY_KEYS_WINDOW_MINUTES} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() +}); + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + sendToAll: z.boolean().optional().default(false), + userIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.number().int().positive()).optional().default([]) + }) + .superRefine((data, ctx) => { + if ( + !data.sendToAll && + data.userIds.length === 0 && + data.roleIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Select at least one user or role, or send to all users", + path: ["userIds"] + }); + } + }); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/virtual-api-keys/email-identity-keys", + description: + "Email identity virtual API keys to selected organization members and roles, or to all members.", + tags: [OpenAPITags.VirtualApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function emailIdentityKeys( + 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 parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + if (!config.getRawConfig().email) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email is not configured on this server" + ) + ); + } + + const { orgId } = parsedParams.data; + const { sendToAll, userIds, roleIds } = parsedBody.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const uniqueUserIds = [...new Set(userIds)]; + const uniqueRoleIds = [...new Set(roleIds)]; + + if (!sendToAll && uniqueRoleIds.length > 0) { + const orgRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + inArray(roles.roleId, uniqueRoleIds) + ) + ); + + if (orgRoles.length !== uniqueRoleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more roles are invalid for this organization" + ) + ); + } + } + + let targetUserIds: string[] | null = null; + if (!sendToAll) { + let roleUserIds: string[] = []; + if (uniqueRoleIds.length > 0) { + const roleMembers = await db + .select({ userId: userOrgRoles.userId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.roleId, uniqueRoleIds) + ) + ); + roleUserIds = roleMembers.map((row) => row.userId); + } + + targetUserIds = [...new Set([...uniqueUserIds, ...roleUserIds])]; + if (targetUserIds.length === 0) { + return response(res, { + data: { sent: 0, skipped: 0 }, + success: true, + error: false, + message: "Identity keys emailed successfully", + status: HttpCode.OK + }); + } + } + + const memberConditions = [eq(userOrgs.orgId, orgId)]; + if (targetUserIds) { + memberConditions.push(inArray(users.userId, targetUserIds)); + } + + const members = await db + .select({ user: users }) + .from(users) + .innerJoin(userOrgs, eq(userOrgs.userId, users.userId)) + .where(and(...memberConditions)); + + if (!sendToAll && uniqueUserIds.length > 0) { + const foundIds = new Set(members.map((row) => row.user.userId)); + if (uniqueUserIds.some((id) => !foundIds.has(id))) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more users are not members of this organization" + ) + ); + } + } + + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + const orgName = org?.name || orgId; + const gatewayUrls = await listOrgInferenceGatewayUrls(orgId); + let sent = 0; + let skipped = 0; + + for (const { user } of members) { + if (!user.email) { + skipped += 1; + continue; + } + + const { key, secret } = await getOrCreateUserVirtualApiKey({ + orgId, + user, + createdByUserId: req.user?.userId ?? null + }); + + await sendVirtualApiKeyEmails({ + recipients: [user.email], + orgName, + orgId, + keyName: key.name, + virtualApiKeyId: key.virtualApiKeyId, + secret, + allResources: true, + isIdentityKey: true, + accountLabel: user.email || user.name || user.username, + gatewayUrls + }); + sent += 1; + } + + return response(res, { + data: { sent, skipped }, + success: true, + error: false, + message: "Identity keys emailed 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 2cb06a5ca..0a6c16a06 100644 --- a/server/routers/virtualApiKey/index.ts +++ b/server/routers/virtualApiKey/index.ts @@ -5,4 +5,5 @@ export * from "./getVirtualApiKey"; export * from "./getMyVirtualApiKey"; export * from "./updateVirtualApiKey"; export * from "./deleteVirtualApiKey"; +export * from "./emailIdentityKeys"; export * from "./types"; diff --git a/server/routers/virtualApiKey/types.ts b/server/routers/virtualApiKey/types.ts index 91bf17c0a..e6a387446 100644 --- a/server/routers/virtualApiKey/types.ts +++ b/server/routers/virtualApiKey/types.ts @@ -28,3 +28,8 @@ export type ListMyVirtualApiKeysResponse = { export type GetMyVirtualApiKeyResponse = { virtualApiKey: VirtualApiKeyWithResources; }; + +export type EmailIdentityKeysResponse = { + sent: number; + skipped: number; +}; diff --git a/src/app/[orgId]/settings/virtual-api-keys/(list)/identity/page.tsx b/src/app/[orgId]/settings/virtual-api-keys/(list)/identity/page.tsx new file mode 100644 index 000000000..4709345ed --- /dev/null +++ b/src/app/[orgId]/settings/virtual-api-keys/(list)/identity/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import IdentityKeysSplash from "@app/components/IdentityKeysSplash"; + +export const metadata: Metadata = { + title: "Identity Keys" +}; + +type IdentityKeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export default async function IdentityKeysPage(props: IdentityKeysPageProps) { + const params = await props.params; + + return ; +} diff --git a/src/app/[orgId]/settings/virtual-api-keys/(list)/keys/page.tsx b/src/app/[orgId]/settings/virtual-api-keys/(list)/keys/page.tsx new file mode 100644 index 000000000..2a5cb0e57 --- /dev/null +++ b/src/app/[orgId]/settings/virtual-api-keys/(list)/keys/page.tsx @@ -0,0 +1,142 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +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 Keys" +}; + +type VirtualApiKeysTablePageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function VirtualApiKeysTablePage( + props: VirtualApiKeysTablePageProps +) { + const params = await props.params; + const cookieHeader = await authCookieHeader(); + const t = await getTranslations(); + + let keys: ListVirtualApiKeysResponse["virtualApiKeys"] = []; + let users: { + userId: string; + email: string | null; + name: string | null; + username: string | null; + }[] = []; + let resources: { + resourceId: number; + name: string; + niceId: string; + }[] = []; + + try { + const [keysRes, usersRes, resourcesRes] = await Promise.all([ + internal.get>( + `/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`, + cookieHeader + ), + internal.get>( + `/org/${params.orgId}/users?page=1&pageSize=1000`, + cookieHeader + ), + internal.get>( + `/org/${params.orgId}/resources?page=1&pageSize=1000`, + cookieHeader + ) + ]); + + keys = keysRes.data.data.virtualApiKeys ?? []; + users = (usersRes.data.data.users ?? []).map((u) => ({ + userId: u.id, + email: u.email ?? null, + name: u.name ?? null, + username: u.username ?? null + })); + resources = (resourcesRes.data.data.resources ?? []).map((r) => ({ + resourceId: r.resourceId, + name: r.name, + niceId: r.niceId + })); + } catch { + // leave empty; page still renders + } + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${params.orgId}`, + cookieHeader + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + if (!org) { + redirect(`/${params.orgId}/settings/resources`); + } + + const userById = new Map(users.map((u) => [u.userId, u])); + const resourceById = new Map(resources.map((r) => [r.resourceId, r])); + + const rows: VirtualApiKeyRow[] = keys.map((key) => { + const user = key.userId ? userById.get(key.userId) : undefined; + const keyResources = key.resourceIds + .map((id) => resourceById.get(id)) + .filter(Boolean) as { + resourceId: number; + name: string; + niceId: string; + }[]; + + const resourceNames = key.allResources + ? t("virtualApiKeysAllResources") + : keyResources.map((r) => r.name).join(", ") || + t("virtualApiKeysNoResources"); + + return { + virtualApiKeyId: key.virtualApiKeyId, + orgId: key.orgId, + kind: key.kind, + userId: key.userId, + name: key.name, + description: key.description, + lastChars: key.lastChars, + allResources: key.allResources, + expiresAt: key.expiresAt, + lastUsedAt: key.lastUsedAt, + createdAt: key.createdAt, + createdByUserId: key.createdByUserId, + resourceIds: key.resourceIds, + userName: user?.name ?? null, + username: user?.username ?? null, + userEmail: user?.email ?? null, + resourceNames, + resources: keyResources + }; + }); + + return ( + + + + ); +} diff --git a/src/app/[orgId]/settings/virtual-api-keys/(list)/layout.tsx b/src/app/[orgId]/settings/virtual-api-keys/(list)/layout.tsx new file mode 100644 index 000000000..20d3af73f --- /dev/null +++ b/src/app/[orgId]/settings/virtual-api-keys/(list)/layout.tsx @@ -0,0 +1,37 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; + +type VirtualApiKeysListLayoutProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function VirtualApiKeysListLayout({ + children, + params +}: VirtualApiKeysListLayoutProps) { + const { orgId } = await params; + const t = await getTranslations(); + + const navItems = [ + { + title: t("virtualApiKeysTabIdentity"), + href: `/${orgId}/settings/virtual-api-keys/identity` + }, + { + title: t("virtualApiKeysTabVirtual"), + href: `/${orgId}/settings/virtual-api-keys/keys` + } + ]; + + return ( + <> + + {children} + + ); +} diff --git a/src/app/[orgId]/settings/virtual-api-keys/page.tsx b/src/app/[orgId]/settings/virtual-api-keys/page.tsx index 28a73253e..d841a6e34 100644 --- a/src/app/[orgId]/settings/virtual-api-keys/page.tsx +++ b/src/app/[orgId]/settings/virtual-api-keys/page.tsx @@ -1,156 +1,17 @@ -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 VirtualApiKeysBanner from "@app/components/VirtualApiKeysBanner"; -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"; +import { redirect } from "next/navigation"; export const metadata: Metadata = { title: "Virtual API Keys" }; -type VirtualApiKeysPageProps = { +type VirtualApiKeysIndexPageProps = { params: Promise<{ orgId: string }>; }; -export const dynamic = "force-dynamic"; - -export default async function VirtualApiKeysPage( - props: VirtualApiKeysPageProps +export default async function VirtualApiKeysIndexPage( + props: VirtualApiKeysIndexPageProps ) { const params = await props.params; - const cookieHeader = await authCookieHeader(); - const t = await getTranslations(); - - let keys: ListVirtualApiKeysResponse["virtualApiKeys"] = []; - let users: { - userId: string; - email: string | null; - name: string | null; - username: string | null; - }[] = []; - let resources: { - resourceId: number; - name: string; - niceId: string; - }[] = []; - - try { - const [keysRes, usersRes, resourcesRes] = await Promise.all([ - internal.get>( - `/org/${params.orgId}/virtual-api-keys?page=1&pageSize=1000`, - cookieHeader - ), - internal.get>( - `/org/${params.orgId}/users?page=1&pageSize=1000`, - cookieHeader - ), - internal.get>( - `/org/${params.orgId}/resources?page=1&pageSize=1000`, - cookieHeader - ) - ]); - - keys = keysRes.data.data.virtualApiKeys ?? []; - users = (usersRes.data.data.users ?? []).map((u) => ({ - userId: u.id, - email: u.email ?? null, - name: u.name ?? null, - username: u.username ?? null - })); - resources = (resourcesRes.data.data.resources ?? []).map((r) => ({ - resourceId: r.resourceId, - name: r.name, - niceId: r.niceId - })); - } catch { - // leave empty; page still renders - } - - let org = null; - try { - const getOrg = cache(async () => - internal.get>( - `/org/${params.orgId}`, - cookieHeader - ) - ); - const res = await getOrg(); - org = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/resources`); - } - - if (!org) { - redirect(`/${params.orgId}/settings/resources`); - } - - const userById = new Map(users.map((u) => [u.userId, u])); - const resourceById = new Map(resources.map((r) => [r.resourceId, r])); - - const rows: VirtualApiKeyRow[] = keys.map((key) => { - const user = key.userId ? userById.get(key.userId) : undefined; - const keyResources = key.resourceIds - .map((id) => resourceById.get(id)) - .filter(Boolean) as { - resourceId: number; - name: string; - niceId: string; - }[]; - - const resourceNames = key.allResources - ? t("virtualApiKeysAllResources") - : keyResources.map((r) => r.name).join(", ") || - t("virtualApiKeysNoResources"); - - return { - virtualApiKeyId: key.virtualApiKeyId, - orgId: key.orgId, - kind: key.kind, - userId: key.userId, - name: key.name, - description: key.description, - lastChars: key.lastChars, - allResources: key.allResources, - expiresAt: key.expiresAt, - lastUsedAt: key.lastUsedAt, - createdAt: key.createdAt, - createdByUserId: key.createdByUserId, - resourceIds: key.resourceIds, - userName: user?.name ?? null, - username: user?.username ?? null, - userEmail: user?.email ?? null, - resourceNames, - resources: keyResources - }; - }); - - return ( - <> - - - - - - - - - ); + redirect(`/${params.orgId}/settings/virtual-api-keys/identity`); } diff --git a/src/components/EmailIdentityKeysForm.tsx b/src/components/EmailIdentityKeysForm.tsx new file mode 100644 index 000000000..f0189bf0a --- /dev/null +++ b/src/components/EmailIdentityKeysForm.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Label } from "@app/components/ui/label"; +import { + RolesSelector, + type SelectedRole +} from "@app/components/roles-selector"; +import { + UsersSelector, + type SelectedUser +} from "@app/components/users-selector"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { EmailIdentityKeysResponse } from "@server/routers/virtualApiKey/types"; +import { AxiosResponse } from "axios"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +type EmailIdentityKeysFormProps = { + orgId: string; + open: boolean; + setOpen: (open: boolean) => void; +}; + +export default function EmailIdentityKeysForm({ + orgId, + open, + setOpen +}: EmailIdentityKeysFormProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [sendToAll, setSendToAll] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + const [selectedRoles, setSelectedRoles] = useState([]); + const [loading, setLoading] = useState(false); + + function resetState() { + setSendToAll(false); + setSelectedUsers([]); + setSelectedRoles([]); + setLoading(false); + } + + async function onSubmit() { + if ( + !sendToAll && + selectedUsers.length === 0 && + selectedRoles.length === 0 + ) { + toast({ + variant: "destructive", + title: t("virtualApiKeysEmailIdentityRecipientsRequired"), + description: t("virtualApiKeysEmailIdentityRecipientsRequired") + }); + return; + } + + setLoading(true); + try { + const res = await api.post< + AxiosResponse + >(`/org/${orgId}/virtual-api-keys/email-identity-keys`, { + sendToAll, + userIds: sendToAll ? [] : selectedUsers.map((user) => user.id), + roleIds: sendToAll + ? [] + : selectedRoles.map((role) => Number(role.id)) + }); + + const { sent, skipped } = res.data.data; + toast({ + title: t("virtualApiKeysEmailIdentitySuccess"), + description: + skipped > 0 + ? `${t("virtualApiKeysEmailIdentitySuccessDescription", { sent })} ${t("virtualApiKeysEmailIdentitySkipped", { skipped })}` + : t("virtualApiKeysEmailIdentitySuccessDescription", { + sent + }) + }); + setOpen(false); + resetState(); + } catch (e) { + toast({ + variant: "destructive", + title: t("virtualApiKeysEmailIdentityError"), + description: formatAxiosError( + e, + t("virtualApiKeysEmailIdentityErrorDescription") + ) + }); + } + setLoading(false); + } + + return ( + { + setOpen(val); + if (!val) { + resetState(); + } + }} + > + + + + {t("virtualApiKeysEmailIdentity")} + + + {t("virtualApiKeysEmailIdentityDescription")} + + + +
+
+ + setSendToAll(val === true) + } + className="mt-0.5" + /> +
+ +

+ {t( + "virtualApiKeysEmailIdentitySendAllDescription" + )} +

+
+
+
+ + +
+
+ + +
+
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/IdentityKeysSplash.tsx b/src/components/IdentityKeysSplash.tsx new file mode 100644 index 000000000..b939dedab --- /dev/null +++ b/src/components/IdentityKeysSplash.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionFooter +} from "@app/components/Settings"; +import EmailIdentityKeysForm from "@app/components/EmailIdentityKeysForm"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat"; +import { ArrowRight, ExternalLink, Globe, KeyRound, Mail } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useState } from "react"; + +const EXAMPLE_IDENTITY_KEY = formatVirtualApiKeyCredential( + "k7m2n9qx", + "a8f3c1e0b5d24791" +); + +type IdentityKeysSplashProps = { + orgId: string; +}; + +export default function IdentityKeysSplash({ orgId }: IdentityKeysSplashProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const [emailOpen, setEmailOpen] = useState(false); + const emailEnabled = env.email.emailEnabled; + + const dashboardUrl = env.app.dashboardUrl?.replace(/\/$/, "") ?? ""; + const keysPath = `/${orgId}/keys`; + const keysUrl = dashboardUrl ? `${dashboardUrl}${keysPath}` : keysPath; + + return ( + <> + + +
+ +

+ {t("virtualApiKeysIdentitySplashTitle")} +

+

+ {t("virtualApiKeysIdentitySplashDescription")} +

+ +
+

+ {t("virtualApiKeysIdentitySplashRetrieveTitle")} +

+
    +
  • + + + {t( + "virtualApiKeysIdentitySplashRetrieveResource" + )} + +
  • +
  • + + + {t.rich( + "virtualApiKeysIdentitySplashRetrievePage", + { + url: () => ( + + {keysUrl} + + ) + } + )} + +
  • +
+
+ +

+ {t("virtualApiKeysIdentitySplashManual")} +

+ {!emailEnabled && ( +

+ {t( + "virtualApiKeysEmailSmtpRequiredDescription" + )} +

+ )} +
+
+ + + + +
+ + + ); +} diff --git a/src/components/VirtualApiKeysBanner.tsx b/src/components/VirtualApiKeysBanner.tsx deleted file mode 100644 index dd2aa1186..000000000 --- a/src/components/VirtualApiKeysBanner.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { Button } from "@app/components/ui/button"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { ArrowRight, KeyRound } from "lucide-react"; -import { useTranslations } from "next-intl"; -import Link from "next/link"; -import DismissableBanner from "./DismissableBanner"; - -type VirtualApiKeysBannerProps = { - orgId: string; -}; - -export const VirtualApiKeysBanner = ({ orgId }: VirtualApiKeysBannerProps) => { - const t = useTranslations(); - const { env } = useEnvContext(); - - const dashboardUrl = env.app.dashboardUrl?.replace(/\/$/, "") ?? ""; - const keysUrl = dashboardUrl - ? `${dashboardUrl}/${orgId}/keys` - : `/${orgId}/keys`; - - return ( - } - description={t("virtualApiKeysBannerDescription", { keysUrl })} - > - - - - - ); -}; - -export default VirtualApiKeysBanner;