From e019210f4b1d6406cd989638c48824735f44ace6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Mon, 15 Jun 2026 18:26:58 +0200 Subject: [PATCH] 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 --- site/permissions.json | 4 + site/src/api/api.ts | 21 ++ site/src/api/queries/aiGatewayKeys.ts | 30 +++ site/src/modules/dashboard/Navbar/Navbar.tsx | 3 +- .../management/AISettingsSidebarView.tsx | 13 +- site/src/modules/permissions/index.ts | 3 +- .../CreateGatewayKeyDialog.stories.tsx | 226 ++++++++++++++++++ .../CreateGatewayKeyDialog.tsx | 140 +++++++++++ .../GatewayKeysPage/GatewayKeysPage.tsx | 97 ++++++++ .../GatewayKeysPageView.stories.tsx | 92 +++++++ .../GatewayKeysPage/GatewayKeysPageView.tsx | 145 +++++++++++ site/src/router.tsx | 24 +- site/src/testHelpers/entities.ts | 27 +++ 13 files changed, 819 insertions(+), 6 deletions(-) create mode 100644 site/src/api/queries/aiGatewayKeys.ts create mode 100644 site/src/pages/AISettingsPage/GatewayKeysPage/CreateGatewayKeyDialog.stories.tsx create mode 100644 site/src/pages/AISettingsPage/GatewayKeysPage/CreateGatewayKeyDialog.tsx create mode 100644 site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx create mode 100644 site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx create mode 100644 site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx diff --git a/site/permissions.json b/site/permissions.json index 63c26797b5f..66ef7aa2370 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -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" diff --git a/site/src/api/api.ts b/site/src/api/api.ts index d2b09d9b40b..3580a85992d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3121,6 +3121,27 @@ class ApiMethods { `/api/v2/ai/providers/${encodeURIComponent(idOrName)}`, ); }; + + getAIGatewayKeys = async (): Promise => { + const response = await this.axios.get( + "/api/v2/aibridge/keys", + ); + return response.data; + }; + + createAIGatewayKey = async ( + req: TypesGen.CreateAIGatewayKeyRequest, + ): Promise => { + const response = await this.axios.post( + "/api/v2/aibridge/keys", + req, + ); + return response.data; + }; + + deleteAIGatewayKey = async (id: string): Promise => { + await this.axios.delete(`/api/v2/aibridge/keys/${encodeURIComponent(id)}`); + }; } export type TaskFeedbackRating = "good" | "okay" | "bad"; diff --git a/site/src/api/queries/aiGatewayKeys.ts b/site/src/api/queries/aiGatewayKeys.ts new file mode 100644 index 00000000000..a7c38dc9e0c --- /dev/null +++ b/site/src/api/queries/aiGatewayKeys.ts @@ -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 => API.getAIGatewayKeys(), +}); + +export const createAIGatewayKeyMutation = (queryClient: QueryClient) => ({ + mutationFn: ( + request: CreateAIGatewayKeyRequest, + ): Promise => API.createAIGatewayKey(request), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: aiGatewayKeysListKey }); + }, +}); + +export const deleteAIGatewayKeyMutation = (queryClient: QueryClient) => ({ + mutationFn: (id: string): Promise => API.deleteAIGatewayKey(id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: aiGatewayKeysListKey }); + }, +}); diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index c54aad1109f..e5aefebe626 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -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(); diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 3e83c23ea71..664e4529cc7 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -22,9 +22,16 @@ const AISettingsSidebarView: FC = ({ AI Governance )} - - Providers - + {permissions.viewAnyAIProvider && ( + + Providers + + )} + {permissions.viewAIGatewayKeys && ( + + AI Gateway Keys + + )} {permissions.editDeploymentConfig && (
diff --git a/site/src/modules/permissions/index.ts b/site/src/modules/permissions/index.ts index f192ae1a57a..be97417f9c5 100644 --- a/site/src/modules/permissions/index.ts +++ b/site/src/modules/permissions/index.ts @@ -26,7 +26,8 @@ export const canViewDeploymentSettings = ( permissions.viewAnyGroup || permissions.viewNotificationTemplate || permissions.viewOrganizationIDPSyncSettings || - permissions.viewAnyAIProvider) + permissions.viewAnyAIProvider || + permissions.viewAIGatewayKeys) ); }; diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/CreateGatewayKeyDialog.stories.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/CreateGatewayKeyDialog.stories.tsx new file mode 100644 index 00000000000..6fceb83509e --- /dev/null +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/CreateGatewayKeyDialog.stories.tsx @@ -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 = { + title: "pages/AISettingsPage/CreateGatewayKeyDialog", + component: CreateGatewayKeyDialog, + args: { + open: true, + onClose: fn(), + onCreate: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Form: Story = {}; + +export const CreateAndReveal: Story = { + render: (args) => { + const [createdKey, setCreatedKey] = useState< + CreateAIGatewayKeyResponse | undefined + >(undefined); + return ( + { + 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(undefined); + const error = mockApiError({ + message: "Key name must be unique.", + validations: [ + { + field: "name", + detail: "A key with this name already exists.", + }, + ], + }); + return ( + { + 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(undefined); + const error = mockApiError({ message: "Failed to create key." }); + return ( + { + 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 ( + { + 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 ( +
+ + { + args.onCreate(name); + setCreatedKey(MockCreateAIGatewayKeyResponse); + }} + onClose={() => { + args.onClose(); + setCreatedKey(undefined); + setOpen(false); + }} + /> +
+ ); + }, + 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(); + }, +}; diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/CreateGatewayKeyDialog.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/CreateGatewayKeyDialog.tsx new file mode 100644 index 00000000000..c4156502001 --- /dev/null +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/CreateGatewayKeyDialog.tsx @@ -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 = ({ + open, + onClose, + onCreate, + createdKey, + submitError, + isSubmitting = false, +}) => { + const form = useFormik({ + 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 ( + { + if (!nextOpen && !isBusy && !createdKey) { + closeDialog(); + } + }} + > + + + + {createdKey ? "Save your AI Gateway key" : "Create AI Gateway key"} + + + + {createdKey ? ( +
+ + + Copy this key now. For security reasons it cannot be shown + again. + + + + + + +
+ ) : ( +
+ {showSubmitError && } +
+ + + + + + +
+ )} +
+
+ ); +}; diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx new file mode 100644 index 00000000000..e6f604dc59c --- /dev/null +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPage.tsx @@ -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( + undefined, + ); + + return ( + + {pageTitle("AI Gateway Keys")} + + setIsCreateOpen(true)} + onDeleteKey={setKeyToDelete} + /> + + { + createMutation.reset(); + setIsCreateOpen(false); + }} + onCreate={(name) => createMutation.mutate({ name })} + createdKey={createMutation.data} + submitError={createMutation.error} + isSubmitting={createMutation.isPending} + /> + + + Are you sure you want to permanently delete key{" "} + {keyToDelete?.name}? 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)} + /> + + ); +}; + +export default GatewayKeysPage; diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx new file mode 100644 index 00000000000..037837c5568 --- /dev/null +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.stories.tsx @@ -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 = { + 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; + +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]); + }, +}; diff --git a/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx new file mode 100644 index 00000000000..e973a1fe860 --- /dev/null +++ b/site/src/pages/AISettingsPage/GatewayKeysPage/GatewayKeysPageView.tsx @@ -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 = ({ + keys, + isLoading, + error, + showPaywall, + onCreateKey, + onDeleteKey, +}) => { + return ( +
+ + + Create key + + ) + } + > + AI Gateway Keys + + Keys authenticate standalone AI Gateway replicas to this deployment. + The key value is shown only once when created.{" "} + View docs + + + + {showPaywall && ( + + )} + + {!showPaywall && Boolean(error) && ( +
+ +
+ )} + + {!showPaywall && ( + + + + Name + Key prefix + Last used + Created + + + + + {isLoading ? ( + + ) : error ? ( + + ) : keys.length === 0 ? ( + + + Create key + + } + /> + ) : ( + keys.map((key) => ( + + {key.name} + + + {key.key_prefix} + + + + {key.last_used_at ? ( + + {relativeTime(new Date(key.last_used_at))} + + ) : ( + Never + )} + + + + {relativeTime(new Date(key.created_at))} + + + + + + + )) + )} + +
+ )} +
+ ); +}; diff --git a/site/src/router.tsx b/site/src/router.tsx index ce40bbd9820..22757783c52 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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 ; + } + + if (permissions.viewAIGatewayKeys) { + return ; + } + + return ; +}; const GlobalLayout = () => { return ( @@ -697,7 +715,11 @@ export const router = createBrowserRouter( }> } /> - } /> + } + /> + } /> } />