diff --git a/messages/en-US.json b/messages/en-US.json index e41d06f7b..f918a51c3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1725,6 +1725,18 @@ "virtualApiKeysFilterUnassigned": "Unassigned", "virtualApiKeysInferenceBudget": "Budget", "virtualApiKeysInferenceBudgetDescription": "Configure how this key restricts AI usage based on spending or token limits", + "virtualApiKeysEmailOnGenerate": "Email key upon generation", + "virtualApiKeysEmailThisKey": "Email this key", + "virtualApiKeysEmailOnGenerateDescription": "Send the key to the associated user and additional addresses after it is created", + "virtualApiKeysEmailThisKeyDescription": "Send the current key to the associated user and additional addresses", + "virtualApiKeysEmailSmtpRequired": "Email is not configured on this server", + "virtualApiKeysEmailSmtpRequiredDescription": "Configure SMTP to email virtual API keys.", + "virtualApiKeysEmailSendToUser": "Send to associated user", + "virtualApiKeysEmailSendToUserDescription": "Email the key to the associated user's account email", + "virtualApiKeysEmailSendToUserDisabled": "Associate a user to send the key to that user", + "virtualApiKeysEmailAdditional": "Additional emails", + "virtualApiKeysEmailAdditionalPlaceholder": "Add email and press Enter", + "virtualApiKeysEmailRecipientsRequired": "Select the associated user or add at least one email address", "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 {resourceName}", diff --git a/package.json b/package.json index e94b2fda3..32c22d4fa 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,7 @@ "@types/yargs": "17.0.35", "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.10", - "esbuild": "0.28.1", + "esbuild": "0.28.0", "esbuild-node-externals": "1.22.0", "eslint": "10.4.0", "eslint-config-next": "16.2.6", @@ -180,7 +180,7 @@ "typescript-eslint": "8.60.0" }, "overrides": { - "esbuild": "0.28.1", + "esbuild": "0.28.0", "dompurify": "3.4.0", "postcss": "8.5.15" } diff --git a/server/emails/templates/VirtualApiKeyGenerated.tsx b/server/emails/templates/VirtualApiKeyGenerated.tsx new file mode 100644 index 000000000..f156c1b6e --- /dev/null +++ b/server/emails/templates/VirtualApiKeyGenerated.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailInfoSection, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText +} from "./components/Email"; + +type VirtualApiKeyGeneratedProps = { + orgName: string; + keyName: string | null; + credential: string; + resourceUrls: string[]; + hasMoreResources: boolean; +}; + +export const VirtualApiKeyGenerated = ({ + orgName, + keyName, + credential, + resourceUrls, + hasMoreResources +}: VirtualApiKeyGeneratedProps) => { + const previewText = `A virtual API key for ${orgName} has been shared with you`; + + return ( + + + {previewText} + + + + + + Hi there, + + + A virtual API key for {orgName} has + been shared with you. Treat this key like a password + and do not share it. + + + + Your virtual API key: +
+
+ + {credential} + +
+
+
+ + + + {resourceUrls.length > 0 && ( + <> + + This key can be used to authenticate to the + following AI gateway resources: + +
+ {resourceUrls.map((url) => ( +

+ + {url} + +

+ ))} +
+ {hasMoreResources && ( + + Contact your administrator to get the + full list. + + )} + + )} + + + + +
+ +
+ + ); +}; + +export default VirtualApiKeyGenerated; diff --git a/server/lib/sendVirtualApiKeyEmail.ts b/server/lib/sendVirtualApiKeyEmail.ts new file mode 100644 index 000000000..48d7d0d6b --- /dev/null +++ b/server/lib/sendVirtualApiKeyEmail.ts @@ -0,0 +1,161 @@ +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 VirtualApiKeyGenerated from "@server/emails/templates/VirtualApiKeyGenerated"; +import { formatVirtualApiKeyCredential } from "@server/lib/virtualApiKey"; + +const EMAIL_GATEWAY_URL_LIMIT = 5; + +async function listVirtualApiKeyGatewayUrls(params: { + orgId: string; + allResources: boolean; + virtualApiKeyId: string; +}): Promise<{ urls: string[]; hasMore: boolean }> { + const rows = params.allResources + ? await db + .select({ + fullDomain: resources.fullDomain, + ssl: resources.ssl + }) + .from(resources) + .where( + and( + eq(resources.orgId, params.orgId), + eq(resources.mode, "inference") + ) + ) + .orderBy(asc(resources.name)) + .limit(EMAIL_GATEWAY_URL_LIMIT + 1) + : await db + .select({ + fullDomain: resources.fullDomain, + ssl: resources.ssl + }) + .from(virtualApiKeyResources) + .innerJoin( + resources, + eq(virtualApiKeyResources.resourceId, resources.resourceId) + ) + .where( + eq( + virtualApiKeyResources.virtualApiKeyId, + params.virtualApiKeyId + ) + ) + .orderBy(asc(resources.name)) + .limit(EMAIL_GATEWAY_URL_LIMIT + 1); + + const urls = rows + .map((row) => + row.fullDomain + ? `${row.ssl ? "https" : "http"}://${row.fullDomain}` + : null + ) + .filter((url): url is string => Boolean(url)); + + return { + urls: urls.slice(0, EMAIL_GATEWAY_URL_LIMIT), + hasMore: rows.length > EMAIL_GATEWAY_URL_LIMIT + }; +} + +export async function resolveVirtualApiKeyEmailRecipients(params: { + sendEmail: boolean; + sendToAttributedUser: boolean; + userId: string | null | undefined; + emails: string[]; +}): Promise< + { ok: true; recipients: string[] } | { ok: false; message: string } +> { + if (!params.sendEmail) { + return { ok: true, recipients: [] }; + } + + if (!config.getRawConfig().email) { + return { + ok: false, + message: "Email is not configured on this server" + }; + } + + const recipients = new Set( + params.emails.map((email) => email.trim().toLowerCase()).filter(Boolean) + ); + + if (params.sendToAttributedUser) { + if (!params.userId) { + return { + ok: false, + message: "Associate a user to email the key to that user" + }; + } + + const [user] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.userId, params.userId)) + .limit(1); + + if (!user?.email) { + return { + ok: false, + message: "The associated user does not have an email address" + }; + } + + recipients.add(user.email.toLowerCase()); + } + + if (recipients.size === 0) { + return { + ok: false, + message: "Select at least one email recipient" + }; + } + + return { ok: true, recipients: [...recipients] }; +} + +export async function sendVirtualApiKeyEmails(params: { + recipients: string[]; + orgName: string; + orgId: string; + keyName: string | null; + virtualApiKeyId: string; + secret: string; + allResources: boolean; +}): Promise { + if (params.recipients.length === 0) { + return; + } + + const credential = formatVirtualApiKeyCredential( + params.virtualApiKeyId, + params.secret + ); + const { urls, hasMore } = await listVirtualApiKeyGatewayUrls({ + orgId: params.orgId, + allResources: params.allResources, + virtualApiKeyId: params.virtualApiKeyId + }); + const from = config.getNoReplyEmail(); + const subject = `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 + }), + { + to, + from, + subject + } + ); + } +} diff --git a/server/routers/virtualApiKey/createVirtualApiKey.ts b/server/routers/virtualApiKey/createVirtualApiKey.ts index 5e49a5717..068ce13cc 100644 --- a/server/routers/virtualApiKey/createVirtualApiKey.ts +++ b/server/routers/virtualApiKey/createVirtualApiKey.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, userOrgs, virtualApiKeys } from "@server/db"; +import { db, orgs, userOrgs, virtualApiKeys } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -18,6 +18,10 @@ import { } from "@server/lib/virtualApiKey"; import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; import { createVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation"; +import { + resolveVirtualApiKeyEmailRecipients, + sendVirtualApiKeyEmails +} from "@server/lib/sendVirtualApiKeyEmail"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -78,7 +82,10 @@ export async function createVirtualApiKey( userId, allResources, resourceIds, - validForSeconds + validForSeconds, + sendEmail: doEmail, + sendToAttributedUser, + emails } = parsedBody.data; if (req.user && orgId && orgId !== req.userOrgId) { @@ -121,6 +128,18 @@ export async function createVirtualApiKey( ); } + const emailRecipients = await resolveVirtualApiKeyEmailRecipients({ + sendEmail: doEmail, + sendToAttributedUser, + userId, + emails + }); + if (!emailRecipients.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, emailRecipients.message) + ); + } + const minted = mintVirtualApiKeySecret(); const expiresAt = validForSeconds ? createDate(new TimeSpan(validForSeconds, "s")).getTime() @@ -156,6 +175,24 @@ export async function createVirtualApiKey( return row; }); + if (emailRecipients.recipients.length > 0) { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + await sendVirtualApiKeyEmails({ + recipients: emailRecipients.recipients, + orgName: org?.name || orgId, + orgId, + keyName: created.name, + virtualApiKeyId: created.virtualApiKeyId, + secret: minted.secret, + allResources: created.allResources + }); + } + return response(res, { data: { virtualApiKey: { diff --git a/server/routers/virtualApiKey/updateVirtualApiKey.ts b/server/routers/virtualApiKey/updateVirtualApiKey.ts index 42554803f..8307d4fd3 100644 --- a/server/routers/virtualApiKey/updateVirtualApiKey.ts +++ b/server/routers/virtualApiKey/updateVirtualApiKey.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, + orgs, userOrgs, virtualApiKeyResources, virtualApiKeys @@ -16,11 +17,16 @@ import { and, eq } from "drizzle-orm"; import { createDate, TimeSpan } from "oslo"; import { assertManualKeyResourcesInOrg, + decryptVirtualApiKeyToken, replaceVirtualApiKeyResources, toPublicVirtualApiKey } from "@server/lib/virtualApiKey"; import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types"; import { updateVirtualApiKeyBodySchema } from "@server/routers/virtualApiKey/validation"; +import { + resolveVirtualApiKeyEmailRecipients, + sendVirtualApiKeyEmails +} from "@server/lib/sendVirtualApiKeyEmail"; const paramsSchema = z.strictObject({ virtualApiKeyId: z.string().nonempty() @@ -146,6 +152,20 @@ export async function updateVirtualApiKey( } } + const nextUserId = + body.userId !== undefined ? body.userId : existing.userId; + const emailRecipients = await resolveVirtualApiKeyEmailRecipients({ + sendEmail: body.sendEmail, + sendToAttributedUser: body.sendToAttributedUser, + userId: nextUserId, + emails: body.emails + }); + if (!emailRecipients.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, emailRecipients.message) + ); + } + const updates: Partial = {}; if (body.name !== undefined) { @@ -192,6 +212,24 @@ export async function updateVirtualApiKey( return row; }); + if (emailRecipients.recipients.length > 0) { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, existing.orgId)) + .limit(1); + + await sendVirtualApiKeyEmails({ + recipients: emailRecipients.recipients, + orgName: org?.name || existing.orgId, + orgId: existing.orgId, + keyName: updated.name, + virtualApiKeyId: updated.virtualApiKeyId, + secret: decryptVirtualApiKeyToken(updated.token), + allResources: updated.allResources + }); + } + const resourceRows = await db .select({ resourceId: virtualApiKeyResources.resourceId }) .from(virtualApiKeyResources) diff --git a/server/routers/virtualApiKey/validation.ts b/server/routers/virtualApiKey/validation.ts index 42964b3ba..5e8d22e64 100644 --- a/server/routers/virtualApiKey/validation.ts +++ b/server/routers/virtualApiKey/validation.ts @@ -4,6 +4,43 @@ export const virtualApiKeyResourceIdsSchema = z .array(z.coerce.number().int().positive()) .optional(); +const virtualApiKeyEmailFieldsSchema = { + sendEmail: z.boolean().optional().default(false), + sendToAttributedUser: z.boolean().optional().default(false), + emails: z.array(z.email().toLowerCase()).max(20).optional().default([]) +}; + +function refineVirtualApiKeyEmailFields( + data: { + sendEmail: boolean; + sendToAttributedUser: boolean; + emails: string[]; + userId?: string | null; + }, + ctx: z.RefinementCtx +) { + if (!data.sendEmail) { + return; + } + + if (!data.sendToAttributedUser && data.emails.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Select the associated user or add at least one email address", + path: ["sendEmail"] + }); + } + + if (data.sendToAttributedUser && !data.userId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Associate a user to email the key to that user", + path: ["sendToAttributedUser"] + }); + } +} + export const createVirtualApiKeyBodySchema = z .strictObject({ name: z.string().nonempty(), @@ -11,7 +48,8 @@ export const createVirtualApiKeyBodySchema = z userId: z.string().optional().nullable(), allResources: z.boolean().optional().default(false), resourceIds: virtualApiKeyResourceIdsSchema, - validForSeconds: z.int().positive().optional() + validForSeconds: z.int().positive().optional(), + ...virtualApiKeyEmailFieldsSchema }) .refine( (data) => data.allResources || (data.resourceIds?.length ?? 0) > 0, @@ -20,13 +58,30 @@ export const createVirtualApiKeyBodySchema = z "Select at least one public inference resource, or enable all public inference resources", path: ["resourceIds"] } - ); + ) + .superRefine(refineVirtualApiKeyEmailFields); -export const updateVirtualApiKeyBodySchema = z.strictObject({ - name: z.string().nonempty().optional(), - description: z.string().optional().nullable(), - userId: z.string().optional().nullable(), - allResources: z.boolean().optional(), - resourceIds: virtualApiKeyResourceIdsSchema, - validForSeconds: z.int().positive().optional().nullable() -}); +export const updateVirtualApiKeyBodySchema = z + .strictObject({ + name: z.string().nonempty().optional(), + description: z.string().optional().nullable(), + userId: z.string().optional().nullable(), + allResources: z.boolean().optional(), + resourceIds: virtualApiKeyResourceIdsSchema, + validForSeconds: z.int().positive().optional().nullable(), + ...virtualApiKeyEmailFieldsSchema + }) + .superRefine((data, ctx) => { + if (!data.sendEmail) { + return; + } + + if (!data.sendToAttributedUser && data.emails.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Select the associated user or add at least one email address", + path: ["sendEmail"] + }); + } + }); diff --git a/src/components/CreateVirtualApiKeyForm.tsx b/src/components/CreateVirtualApiKeyForm.tsx index 5c2e75d0f..14bca22f5 100644 --- a/src/components/CreateVirtualApiKeyForm.tsx +++ b/src/components/CreateVirtualApiKeyForm.tsx @@ -54,6 +54,8 @@ import { getBudgetRowsErrors, type BudgetRow } from "@app/components/BudgetsEditor"; +import VirtualApiKeyEmailSection from "@app/components/VirtualApiKeyEmailSection"; +import type { Tag } from "@app/components/tags/tag-input"; export type CreatedVirtualApiKey = { virtualApiKeyId: string; @@ -101,6 +103,9 @@ export default function CreateVirtualApiKeyForm({ >([]); const [pendingBudgetRows, setPendingBudgetRows] = useState([]); const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false); + const [sendEmail, setSendEmail] = useState(false); + const [sendToAttributedUser, setSendToAttributedUser] = useState(false); + const [emailTags, setEmailTags] = useState([]); const formSchema = z.object({ name: z.string().min(1), @@ -123,6 +128,9 @@ export default function CreateVirtualApiKeyForm({ setSelectedResources([]); setPendingBudgetRows([]); setAttemptedBudgetsSave(false); + setSendEmail(false); + setSendToAttributedUser(false); + setEmailTags([]); form.reset(); } @@ -141,6 +149,20 @@ export default function CreateVirtualApiKeyForm({ return; } + if ( + env.email.emailEnabled && + sendEmail && + !sendToAttributedUser && + emailTags.length === 0 + ) { + toast({ + variant: "destructive", + title: t("virtualApiKeysEmailRecipientsRequired"), + description: t("virtualApiKeysEmailRecipientsRequired") + }); + return; + } + return onSubmit(values); } @@ -157,7 +179,16 @@ export default function CreateVirtualApiKeyForm({ allResources, resourceIds: allResources ? [] - : selectedResources.map((r) => r.resourceId) + : selectedResources.map((r) => r.resourceId), + sendEmail: env.email.emailEnabled && sendEmail, + sendToAttributedUser: + env.email.emailEnabled && + sendEmail && + sendToAttributedUser, + emails: + env.email.emailEnabled && sendEmail + ? emailTags.map((tag) => tag.text) + : [] } ) .catch((e) => { @@ -473,6 +504,26 @@ export default function CreateVirtualApiKeyForm({ )} + +
@@ -505,7 +556,7 @@ export default function CreateVirtualApiKeyForm({
)} + +
diff --git a/src/components/VirtualApiKeyEmailSection.tsx b/src/components/VirtualApiKeyEmailSection.tsx new file mode 100644 index 000000000..a00abd69e --- /dev/null +++ b/src/components/VirtualApiKeyEmailSection.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { FormLabel } from "@app/components/ui/form"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { useTranslations } from "next-intl"; + +type VirtualApiKeyEmailSectionProps = { + emailEnabled: boolean; + mode: "create" | "edit"; + sendEmail: boolean; + onSendEmailChange: (value: boolean) => void; + sendToAttributedUser: boolean; + onSendToAttributedUserChange: (value: boolean) => void; + hasAssociatedUser: boolean; + emailTags: Tag[]; + onEmailTagsChange: (tags: Tag[]) => void; +}; + +export default function VirtualApiKeyEmailSection({ + emailEnabled, + mode, + sendEmail, + onSendEmailChange, + sendToAttributedUser, + onSendToAttributedUserChange, + hasAssociatedUser, + emailTags, + onEmailTagsChange +}: VirtualApiKeyEmailSectionProps) { + const t = useTranslations(); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + useEffect(() => { + if (!hasAssociatedUser && sendToAttributedUser) { + onSendToAttributedUserChange(false); + } + }, [hasAssociatedUser, sendToAttributedUser, onSendToAttributedUserChange]); + + const checkboxId = + mode === "create" + ? "virtual-api-key-send-email" + : "edit-virtual-api-key-send-email"; + const sendToUserId = + mode === "create" + ? "virtual-api-key-send-to-user" + : "edit-virtual-api-key-send-to-user"; + + return ( +
+
+ { + if (emailEnabled) { + onSendEmailChange(val === true); + } + }} + className="mt-0.5" + /> +
+ +

+ {emailEnabled + ? t( + mode === "create" + ? "virtualApiKeysEmailOnGenerateDescription" + : "virtualApiKeysEmailThisKeyDescription" + ) + : t("virtualApiKeysEmailSmtpRequiredDescription")} +

+
+
+ + {emailEnabled && sendEmail && ( +
+
+ + onSendToAttributedUserChange(val === true) + } + className="mt-0.5" + /> +
+ +

+ {hasAssociatedUser + ? t( + "virtualApiKeysEmailSendToUserDescription" + ) + : t( + "virtualApiKeysEmailSendToUserDisabled" + )} +

+
+
+ +
+ + {t("virtualApiKeysEmailAdditional")} + + { + const next = + typeof newTags === "function" + ? newTags(emailTags) + : newTags; + onEmailTagsChange(next as Tag[]); + }} + allowDuplicates={false} + sortTags + validateTag={(tag) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + } + delimiterList={[",", "Enter"]} + /> +
+
+ )} +
+ ); +}