mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
feat(site): add AI Gateway keys management page (#25817)
> Vibe-coded using Coder Agents, author with limited frontend knowledge, manually tested. Adds an `AI Gateway Keys` page under `Admin settings > AI > AI Gateway Keys` for key management of keys used by standalone AI Gateway replicas to authenticate into `coderd`. The page is shown to users with `viewAIGatewayKeys` permission and a Premium license with AI Gateway enabled. Adds Storybook coverage. --------- Co-authored-by: Jake Howell <jacob@coder.com>
This commit is contained in:
co-authored by
Jake Howell
parent
86bdedb0a9
commit
e019210f4b
@@ -107,6 +107,10 @@
|
||||
"object": { "resource_type": "ai_provider" },
|
||||
"action": "read"
|
||||
},
|
||||
"viewAIGatewayKeys": {
|
||||
"object": { "resource_type": "ai_gateway_key" },
|
||||
"action": "read"
|
||||
},
|
||||
"createOAuth2App": {
|
||||
"object": { "resource_type": "oauth2_app" },
|
||||
"action": "create"
|
||||
|
||||
@@ -3121,6 +3121,27 @@ class ApiMethods {
|
||||
`/api/v2/ai/providers/${encodeURIComponent(idOrName)}`,
|
||||
);
|
||||
};
|
||||
|
||||
getAIGatewayKeys = async (): Promise<TypesGen.AIGatewayKey[]> => {
|
||||
const response = await this.axios.get<TypesGen.AIGatewayKey[]>(
|
||||
"/api/v2/aibridge/keys",
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
createAIGatewayKey = async (
|
||||
req: TypesGen.CreateAIGatewayKeyRequest,
|
||||
): Promise<TypesGen.CreateAIGatewayKeyResponse> => {
|
||||
const response = await this.axios.post<TypesGen.CreateAIGatewayKeyResponse>(
|
||||
"/api/v2/aibridge/keys",
|
||||
req,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
deleteAIGatewayKey = async (id: string): Promise<void> => {
|
||||
await this.axios.delete(`/api/v2/aibridge/keys/${encodeURIComponent(id)}`);
|
||||
};
|
||||
}
|
||||
|
||||
export type TaskFeedbackRating = "good" | "okay" | "bad";
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { QueryClient } from "react-query";
|
||||
import { API } from "#/api/api";
|
||||
import type {
|
||||
AIGatewayKey,
|
||||
CreateAIGatewayKeyRequest,
|
||||
CreateAIGatewayKeyResponse,
|
||||
} from "#/api/typesGenerated";
|
||||
|
||||
const aiGatewayKeysListKey = ["ai", "gatewayKeys"] as const;
|
||||
|
||||
export const aiGatewayKeysList = () => ({
|
||||
queryKey: aiGatewayKeysListKey,
|
||||
queryFn: (): Promise<AIGatewayKey[]> => API.getAIGatewayKeys(),
|
||||
});
|
||||
|
||||
export const createAIGatewayKeyMutation = (queryClient: QueryClient) => ({
|
||||
mutationFn: (
|
||||
request: CreateAIGatewayKeyRequest,
|
||||
): Promise<CreateAIGatewayKeyResponse> => API.createAIGatewayKey(request),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: aiGatewayKeysListKey });
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteAIGatewayKeyMutation = (queryClient: QueryClient) => ({
|
||||
mutationFn: (id: string): Promise<void> => API.deleteAIGatewayKey(id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: aiGatewayKeysListKey });
|
||||
},
|
||||
});
|
||||
@@ -27,7 +27,8 @@ export const Navbar: FC = () => {
|
||||
featureVisibility.connection_log && permissions.viewAnyConnectionLog;
|
||||
const canViewAIBridge =
|
||||
featureVisibility.aibridge && permissions.viewAnyAIBridgeInterception;
|
||||
const canViewAISettings = permissions.viewAnyAIProvider;
|
||||
const canViewAISettings =
|
||||
permissions.viewAnyAIProvider || permissions.viewAIGatewayKeys;
|
||||
const canCreateChat = permissions.createChat;
|
||||
|
||||
const uniqueLinks = new Map<string, LinkConfig>();
|
||||
|
||||
@@ -22,9 +22,16 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
|
||||
AI Governance
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
<SidebarNavItem href="/ai/settings" end>
|
||||
Providers
|
||||
</SidebarNavItem>
|
||||
{permissions.viewAnyAIProvider && (
|
||||
<SidebarNavItem href="/ai/settings" end>
|
||||
Providers
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.viewAIGatewayKeys && (
|
||||
<SidebarNavItem href="/ai/settings/gateway-keys">
|
||||
AI Gateway Keys
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
{permissions.editDeploymentConfig && (
|
||||
<SidebarNavItem href="/agents/settings/agents">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
|
||||
@@ -26,7 +26,8 @@ export const canViewDeploymentSettings = (
|
||||
permissions.viewAnyGroup ||
|
||||
permissions.viewNotificationTemplate ||
|
||||
permissions.viewOrganizationIDPSyncSettings ||
|
||||
permissions.viewAnyAIProvider)
|
||||
permissions.viewAnyAIProvider ||
|
||||
permissions.viewAIGatewayKeys)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import type { CreateAIGatewayKeyResponse } from "#/api/typesGenerated";
|
||||
import {
|
||||
MockCreateAIGatewayKeyResponse,
|
||||
mockApiError,
|
||||
} from "#/testHelpers/entities";
|
||||
import { CreateGatewayKeyDialog } from "./CreateGatewayKeyDialog";
|
||||
|
||||
const meta: Meta<typeof CreateGatewayKeyDialog> = {
|
||||
title: "pages/AISettingsPage/CreateGatewayKeyDialog",
|
||||
component: CreateGatewayKeyDialog,
|
||||
args: {
|
||||
open: true,
|
||||
onClose: fn(),
|
||||
onCreate: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CreateGatewayKeyDialog>;
|
||||
|
||||
export const Form: Story = {};
|
||||
|
||||
export const CreateAndReveal: Story = {
|
||||
render: (args) => {
|
||||
const [createdKey, setCreatedKey] = useState<
|
||||
CreateAIGatewayKeyResponse | undefined
|
||||
>(undefined);
|
||||
return (
|
||||
<CreateGatewayKeyDialog
|
||||
{...args}
|
||||
createdKey={createdKey}
|
||||
onCreate={(name) => {
|
||||
args.onCreate(name);
|
||||
setCreatedKey(MockCreateAIGatewayKeyResponse);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const nameField = await body.findByLabelText("Name");
|
||||
await userEvent.type(nameField, "new-gateway");
|
||||
|
||||
const createButton = await body.findByRole("button", { name: "Create" });
|
||||
await userEvent.click(createButton);
|
||||
|
||||
await expect(args.onCreate).toHaveBeenCalledWith("new-gateway");
|
||||
await body.findByText(MockCreateAIGatewayKeyResponse.key);
|
||||
},
|
||||
};
|
||||
|
||||
export const InvalidName: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const nameField = await body.findByLabelText("Name");
|
||||
await userEvent.type(nameField, "UPPER_CASE");
|
||||
|
||||
const createButton = await body.findByRole("button", { name: "Create" });
|
||||
await userEvent.click(createButton);
|
||||
|
||||
await expect(
|
||||
body.findByText(
|
||||
"Use lowercase letters and numbers with optional single hyphens between words.",
|
||||
),
|
||||
).resolves.toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const CreateError: Story = {
|
||||
render: (args) => {
|
||||
const [submitError, setSubmitError] = useState<unknown>(undefined);
|
||||
const error = mockApiError({
|
||||
message: "Key name must be unique.",
|
||||
validations: [
|
||||
{
|
||||
field: "name",
|
||||
detail: "A key with this name already exists.",
|
||||
},
|
||||
],
|
||||
});
|
||||
return (
|
||||
<CreateGatewayKeyDialog
|
||||
{...args}
|
||||
submitError={submitError}
|
||||
onCreate={(name) => {
|
||||
args.onCreate(name);
|
||||
setSubmitError(error);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const nameField = await body.findByLabelText("Name");
|
||||
await userEvent.type(nameField, "dup-key");
|
||||
|
||||
const createButton = await body.findByRole("button", { name: "Create" });
|
||||
await userEvent.click(createButton);
|
||||
|
||||
await expect(
|
||||
body.findByText("A key with this name already exists."),
|
||||
).resolves.toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const GeneralCreateError: Story = {
|
||||
render: (args) => {
|
||||
const [submitError, setSubmitError] = useState<unknown>(undefined);
|
||||
const error = mockApiError({ message: "Failed to create key." });
|
||||
return (
|
||||
<CreateGatewayKeyDialog
|
||||
{...args}
|
||||
submitError={submitError}
|
||||
onCreate={(name) => {
|
||||
args.onCreate(name);
|
||||
setSubmitError(error);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const nameField = await body.findByLabelText("Name");
|
||||
await userEvent.type(nameField, "new-gateway");
|
||||
|
||||
const createButton = await body.findByRole("button", { name: "Create" });
|
||||
await userEvent.click(createButton);
|
||||
|
||||
await expect(
|
||||
body.findByText("Failed to create key."),
|
||||
).resolves.toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const EscapeDoesNotDismissCreatedKey: Story = {
|
||||
render: (args) => {
|
||||
const [createdKey, setCreatedKey] = useState<
|
||||
CreateAIGatewayKeyResponse | undefined
|
||||
>(undefined);
|
||||
return (
|
||||
<CreateGatewayKeyDialog
|
||||
{...args}
|
||||
createdKey={createdKey}
|
||||
onCreate={(name) => {
|
||||
args.onCreate(name);
|
||||
setCreatedKey(MockCreateAIGatewayKeyResponse);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const nameField = await body.findByLabelText("Name");
|
||||
await userEvent.type(nameField, "new-gateway");
|
||||
|
||||
const createButton = await body.findByRole("button", { name: "Create" });
|
||||
await userEvent.click(createButton);
|
||||
|
||||
await expect(args.onCreate).toHaveBeenCalledWith("new-gateway");
|
||||
await body.findByText(MockCreateAIGatewayKeyResponse.key);
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
await expect(
|
||||
body.findByText(MockCreateAIGatewayKeyResponse.key),
|
||||
).resolves.toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const ReopenAfterCreate: Story = {
|
||||
render: (args) => {
|
||||
const [open, setOpen] = useState(args.open);
|
||||
const [createdKey, setCreatedKey] = useState<
|
||||
CreateAIGatewayKeyResponse | undefined
|
||||
>(undefined);
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Open dialog
|
||||
</button>
|
||||
<CreateGatewayKeyDialog
|
||||
{...args}
|
||||
open={open}
|
||||
createdKey={createdKey}
|
||||
onCreate={(name) => {
|
||||
args.onCreate(name);
|
||||
setCreatedKey(MockCreateAIGatewayKeyResponse);
|
||||
}}
|
||||
onClose={() => {
|
||||
args.onClose();
|
||||
setCreatedKey(undefined);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const nameField = await body.findByLabelText("Name");
|
||||
await userEvent.type(nameField, "new-gateway");
|
||||
|
||||
const createButton = await body.findByRole("button", { name: "Create" });
|
||||
await userEvent.click(createButton);
|
||||
|
||||
await expect(args.onCreate).toHaveBeenCalledWith("new-gateway");
|
||||
await body.findByText(MockCreateAIGatewayKeyResponse.key);
|
||||
|
||||
const doneButton = await body.findByRole("button", { name: "Done" });
|
||||
await userEvent.click(doneButton);
|
||||
|
||||
const canvas = within(canvasElement);
|
||||
const openButton = await canvas.findByRole("button", {
|
||||
name: "Open dialog",
|
||||
});
|
||||
await userEvent.click(openButton);
|
||||
|
||||
await expect(body.findByLabelText("Name")).resolves.toHaveValue("");
|
||||
await expect(
|
||||
body.queryByText(MockCreateAIGatewayKeyResponse.key),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useFormik } from "formik";
|
||||
import type { FC } from "react";
|
||||
import * as Yup from "yup";
|
||||
import { isApiValidationError } from "#/api/errors";
|
||||
import type { CreateAIGatewayKeyResponse } from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription } from "#/components/Alert/Alert";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { CodeExample } from "#/components/CodeExample/CodeExample";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { FormField } from "#/components/FormField/FormField";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { getFormHelpers } from "#/utils/formUtils";
|
||||
|
||||
interface CreateGatewayKeyFormValues {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
name: Yup.string()
|
||||
.required("Name is required.")
|
||||
.matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
excludeEmptyString: true,
|
||||
message:
|
||||
"Use lowercase letters and numbers with optional single hyphens between words.",
|
||||
})
|
||||
.max(64, "Name cannot be longer than 64 characters."),
|
||||
});
|
||||
|
||||
interface CreateGatewayKeyDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string) => void;
|
||||
createdKey?: CreateAIGatewayKeyResponse;
|
||||
submitError?: unknown;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export const CreateGatewayKeyDialog: FC<CreateGatewayKeyDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onCreate,
|
||||
createdKey,
|
||||
submitError,
|
||||
isSubmitting = false,
|
||||
}) => {
|
||||
const form = useFormik<CreateGatewayKeyFormValues>({
|
||||
initialValues: { name: "" },
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
onCreate(values.name);
|
||||
},
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form, submitError);
|
||||
|
||||
const closeDialog = () => {
|
||||
form.resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isBusy = isSubmitting;
|
||||
const showSubmitError =
|
||||
Boolean(submitError) && !isApiValidationError(submitError);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !isBusy && !createdKey) {
|
||||
closeDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-h-[90vh] overflow-y-auto"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{createdKey ? "Save your AI Gateway key" : "Create AI Gateway key"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{createdKey ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Alert severity="warning">
|
||||
<AlertDescription>
|
||||
Copy this key now. For security reasons it cannot be shown
|
||||
again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<CodeExample
|
||||
secret={false}
|
||||
code={createdKey.key}
|
||||
className="min-h-0 select-all w-full"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={closeDialog}>Done</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
{showSubmitError && <ErrorAlert error={submitError} />}
|
||||
<form onSubmit={form.handleSubmit} className="flex flex-col gap-5">
|
||||
<FormField
|
||||
field={getFieldHelpers("name", {
|
||||
helperText:
|
||||
"Lowercase letters and numbers with optional single hyphens between words. Maximum 64 characters.",
|
||||
})}
|
||||
label="Name"
|
||||
placeholder="primary-gateway"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isBusy}
|
||||
onClick={closeDialog}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isBusy || !form.dirty}>
|
||||
<Spinner loading={isBusy} />
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { type FC, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import {
|
||||
aiGatewayKeysList,
|
||||
createAIGatewayKeyMutation,
|
||||
deleteAIGatewayKeyMutation,
|
||||
} from "#/api/queries/aiGatewayKeys";
|
||||
import type { AIGatewayKey } from "#/api/typesGenerated";
|
||||
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { CreateGatewayKeyDialog } from "./CreateGatewayKeyDialog";
|
||||
import { GatewayKeysPageView } from "./GatewayKeysPageView";
|
||||
|
||||
const GatewayKeysPage: FC = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
const featureVisibility = useFeatureVisibility();
|
||||
const showPaywall = !featureVisibility.aibridge;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const keysQuery = useQuery({
|
||||
...aiGatewayKeysList(),
|
||||
enabled: !showPaywall,
|
||||
});
|
||||
const createMutation = useMutation(createAIGatewayKeyMutation(queryClient));
|
||||
const deleteMutation = useMutation(deleteAIGatewayKeyMutation(queryClient));
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [keyToDelete, setKeyToDelete] = useState<AIGatewayKey | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<RequirePermission isFeatureVisible={permissions.viewAIGatewayKeys}>
|
||||
<title>{pageTitle("AI Gateway Keys")}</title>
|
||||
|
||||
<GatewayKeysPageView
|
||||
keys={keysQuery.data ?? []}
|
||||
isLoading={keysQuery.isLoading}
|
||||
error={keysQuery.error}
|
||||
showPaywall={showPaywall}
|
||||
onCreateKey={() => setIsCreateOpen(true)}
|
||||
onDeleteKey={setKeyToDelete}
|
||||
/>
|
||||
|
||||
<CreateGatewayKeyDialog
|
||||
open={isCreateOpen}
|
||||
onClose={() => {
|
||||
createMutation.reset();
|
||||
setIsCreateOpen(false);
|
||||
}}
|
||||
onCreate={(name) => createMutation.mutate({ name })}
|
||||
createdKey={createMutation.data}
|
||||
submitError={createMutation.error}
|
||||
isSubmitting={createMutation.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
type="delete"
|
||||
title="Delete AI Gateway key"
|
||||
description={
|
||||
<>
|
||||
Are you sure you want to permanently delete key{" "}
|
||||
<strong>{keyToDelete?.name}</strong>? Any AI Gateway replica using
|
||||
it will no longer be able to authenticate.
|
||||
</>
|
||||
}
|
||||
open={Boolean(keyToDelete)}
|
||||
confirmLoading={deleteMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (!keyToDelete) {
|
||||
return;
|
||||
}
|
||||
const name = keyToDelete.name;
|
||||
deleteMutation.mutate(keyToDelete.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Deleted AI Gateway key "${name}" successfully.`);
|
||||
setKeyToDelete(undefined);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to delete AI Gateway key."),
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onClose={() => setKeyToDelete(undefined)}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
export default GatewayKeysPage;
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { MockAIGatewayKeys, mockApiError } from "#/testHelpers/entities";
|
||||
import { GatewayKeysPageView } from "./GatewayKeysPageView";
|
||||
|
||||
const meta: Meta<typeof GatewayKeysPageView> = {
|
||||
title: "pages/AISettingsPage/GatewayKeysPageView",
|
||||
component: GatewayKeysPageView,
|
||||
args: {
|
||||
keys: MockAIGatewayKeys,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
showPaywall: false,
|
||||
onCreateKey: fn(),
|
||||
onDeleteKey: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GatewayKeysPageView>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
keys: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
keys: [],
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const [, emptyCta] = await canvas.findAllByRole("button", {
|
||||
name: /create key/i,
|
||||
});
|
||||
await userEvent.click(emptyCta);
|
||||
await expect(args.onCreateKey).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadError: Story = {
|
||||
args: {
|
||||
keys: [],
|
||||
error: mockApiError({ message: "Failed to load AI Gateway keys" }),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
canvas.findByText("Failed to load AI Gateway keys"),
|
||||
).resolves.toBeVisible();
|
||||
await expect(
|
||||
canvas.findByText("Failed to fetch AI Gateway keys"),
|
||||
).resolves.toBeVisible();
|
||||
await expect(
|
||||
canvas.queryByText("No AI Gateway keys"),
|
||||
).not.toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getAllByRole("button", { name: /create key/i }),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const Paywall: Story = {
|
||||
args: {
|
||||
showPaywall: true,
|
||||
keys: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const ClickCreate: Story = {
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = await canvas.findByRole("button", { name: /create key/i });
|
||||
await userEvent.click(button);
|
||||
await expect(args.onCreateKey).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
};
|
||||
|
||||
export const ClickDelete: Story = {
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = await canvas.findByRole("button", {
|
||||
name: `Delete ${MockAIGatewayKeys[0].name}`,
|
||||
});
|
||||
await userEvent.click(button);
|
||||
await expect(args.onDeleteKey).toHaveBeenCalledWith(MockAIGatewayKeys[0]);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { AIGatewayKey } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "#/components/Table/Table";
|
||||
import { TableEmpty } from "#/components/TableEmpty/TableEmpty";
|
||||
import { TableLoader } from "#/components/TableLoader/TableLoader";
|
||||
import { docs } from "#/utils/docs";
|
||||
import { relativeTime } from "#/utils/time";
|
||||
|
||||
interface GatewayKeysPageViewProps {
|
||||
keys: AIGatewayKey[];
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
showPaywall: boolean;
|
||||
onCreateKey: () => void;
|
||||
onDeleteKey: (key: AIGatewayKey) => void;
|
||||
}
|
||||
|
||||
export const GatewayKeysPageView: FC<GatewayKeysPageViewProps> = ({
|
||||
keys,
|
||||
isLoading,
|
||||
error,
|
||||
showPaywall,
|
||||
onCreateKey,
|
||||
onDeleteKey,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
actions={
|
||||
!showPaywall && (
|
||||
<Button variant="outline" onClick={onCreateKey}>
|
||||
<PlusIcon />
|
||||
Create key
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SettingsHeaderTitle>AI Gateway Keys</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Keys authenticate standalone AI Gateway replicas to this deployment.
|
||||
The key value is shown only once when created.{" "}
|
||||
<Link href={docs("/ai-coder/ai-gateway")}>View docs</Link>
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
{showPaywall && (
|
||||
<PaywallPremium
|
||||
message="AI Gateway"
|
||||
description="Authenticate standalone AI Gateway replicas to your deployment. You need a Premium license with AI Gateway enabled to use this feature."
|
||||
documentationLink={docs("/ai-coder/ai-gateway")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showPaywall && Boolean(error) && (
|
||||
<div className="mb-4">
|
||||
<ErrorAlert error={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showPaywall && (
|
||||
<Table aria-label="AI Gateway keys">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Key prefix</TableHead>
|
||||
<TableHead>Last used</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableLoader />
|
||||
) : error ? (
|
||||
<TableEmpty message="Failed to fetch AI Gateway keys" />
|
||||
) : keys.length === 0 ? (
|
||||
<TableEmpty
|
||||
message="No AI Gateway keys"
|
||||
description="Create a key to authenticate a standalone AI Gateway replica."
|
||||
cta={
|
||||
<Button variant="outline" onClick={onCreateKey}>
|
||||
<PlusIcon />
|
||||
Create key
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell>{key.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-content-secondary">
|
||||
{key.key_prefix}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.last_used_at ? (
|
||||
<span className="block first-letter:uppercase">
|
||||
{relativeTime(new Date(key.last_used_at))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-content-disabled">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="block first-letter:uppercase">
|
||||
{relativeTime(new Date(key.created_at))}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
aria-label={`Delete ${key.name}`}
|
||||
onClick={() => onDeleteKey(key)}
|
||||
>
|
||||
<TrashIcon className="size-icon-sm" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+23
-1
@@ -11,6 +11,7 @@ import {
|
||||
import { GlobalErrorBoundary } from "./components/ErrorBoundary/GlobalErrorBoundary";
|
||||
import { Loader } from "./components/Loader/Loader";
|
||||
import { RequireAuth } from "./contexts/auth/RequireAuth";
|
||||
import { useAuthenticated } from "./hooks/useAuthenticated";
|
||||
import { DashboardLayout } from "./modules/dashboard/DashboardLayout";
|
||||
import AuditPage from "./pages/AuditPage/AuditPage";
|
||||
import ConnectionLogPage from "./pages/ConnectionLogPage/ConnectionLogPage";
|
||||
@@ -446,6 +447,23 @@ const AISettingsAddProviderPage = lazy(
|
||||
"./pages/AISettingsPage/ProvidersPage/AddProviderPage/AddProviderPage"
|
||||
),
|
||||
);
|
||||
const AISettingsGatewayKeysPage = lazy(
|
||||
() => import("./pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage"),
|
||||
);
|
||||
|
||||
const AISettingsIndexPage = () => {
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
if (permissions.viewAnyAIProvider) {
|
||||
return <AISettingsProvidersPage />;
|
||||
}
|
||||
|
||||
if (permissions.viewAIGatewayKeys) {
|
||||
return <Navigate to="/ai/settings/gateway-keys" replace />;
|
||||
}
|
||||
|
||||
return <AISettingsProvidersPage />;
|
||||
};
|
||||
|
||||
const GlobalLayout = () => {
|
||||
return (
|
||||
@@ -697,7 +715,11 @@ export const router = createBrowserRouter(
|
||||
<Route element={<DeploymentConfigProvider />}>
|
||||
<Route path="governance" element={<AIGovernanceSettingsPage />} />
|
||||
</Route>
|
||||
<Route index element={<AISettingsProvidersPage />} />
|
||||
<Route
|
||||
path="gateway-keys"
|
||||
element={<AISettingsGatewayKeysPage />}
|
||||
/>
|
||||
<Route index element={<AISettingsIndexPage />} />
|
||||
<Route path="add" element={<AISettingsAddProviderPage />} />
|
||||
<Route
|
||||
path=":providerId"
|
||||
|
||||
@@ -3302,6 +3302,7 @@ export const MockPermissions: Permissions = {
|
||||
viewAnyMembers: true,
|
||||
viewAnyAIBridgeInterception: true,
|
||||
viewAnyAIProvider: true,
|
||||
viewAIGatewayKeys: true,
|
||||
createOAuth2App: true,
|
||||
editOAuth2App: true,
|
||||
deleteOAuth2App: true,
|
||||
@@ -3337,6 +3338,7 @@ export const MockNoPermissions: Permissions = {
|
||||
viewAnyMembers: false,
|
||||
viewAnyAIBridgeInterception: true,
|
||||
viewAnyAIProvider: false,
|
||||
viewAIGatewayKeys: false,
|
||||
createOAuth2App: false,
|
||||
editOAuth2App: false,
|
||||
deleteOAuth2App: false,
|
||||
@@ -5521,3 +5523,28 @@ export const MockAIProviders: TypesGen.AIProvider[] = [
|
||||
MockAIProviderBedrock,
|
||||
MockAIProviderCopilot,
|
||||
];
|
||||
|
||||
export const MockAIGatewayKeys: TypesGen.AIGatewayKey[] = [
|
||||
{
|
||||
id: "1c2e6f4a-8b3d-4f1a-9c7e-2a5b8d6f0e11",
|
||||
name: "primary-gateway",
|
||||
key_prefix: "a1B2c3D4e5F",
|
||||
created_at: "2024-05-01T14:00:00Z",
|
||||
last_used_at: "2024-05-20T09:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "2d3f7a5b-9c4e-4a2b-8d6f-3b6c9e7f1a22",
|
||||
name: "backup-gateway",
|
||||
key_prefix: "z9Y8x7W6v5U",
|
||||
created_at: "2024-05-10T08:15:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
export const MockCreateAIGatewayKeyResponse: TypesGen.CreateAIGatewayKeyResponse =
|
||||
{
|
||||
id: "3e4a8b6c-0d5f-4b3c-9e7a-4c7d0f8a2b33",
|
||||
name: "new-gateway",
|
||||
key: "K3mNp7qRs2TvW9xY4zA6bC1dE8fG5hJ0kL3nM7pQ2rS",
|
||||
key_prefix: "K3mNp7qRs2T",
|
||||
created_at: "2024-05-28T12:00:00Z",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user