From 9330f454f07394af6e0ad38153b018362f3c0b78 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Mon, 29 Dec 2025 11:49:01 +0100 Subject: [PATCH] Update api key page to use iam api Signed-off-by: Bryan Frimin --- apps/console/src/layouts/EmployeeLayout.tsx | 2 +- apps/console/src/pages/APIKeysPage.tsx | 846 ------------------ .../src/pages/iam/apiKeys/APIKeysPage.tsx | 28 + .../pages/iam/apiKeys/APIKeysPageLoader.tsx | 33 + .../__generated__/APIKeysPageQuery.graphql.ts | 209 +++++ .../_components/PersonalAPIKeyList.tsx | 393 ++++++++ .../_components/PersonalAPIKeyTokenDialog.tsx | 45 + .../_components/PersonalAPIKeysTable.tsx | 83 ++ ...ersonalAPIKeyListCreateMutation.graphql.ts | 202 +++++ .../PersonalAPIKeyListFragment.graphql.ts | 176 ++++ ...alAPIKeyListRevealTokenMutation.graphql.ts | 92 ++ ...ersonalAPIKeyListRevokeMutation.graphql.ts | 92 ++ .../_components/SessionDropdown.tsx | 2 +- apps/console/src/routes.tsx | 4 +- 14 files changed, 1357 insertions(+), 850 deletions(-) delete mode 100644 apps/console/src/pages/APIKeysPage.tsx create mode 100644 apps/console/src/pages/iam/apiKeys/APIKeysPage.tsx create mode 100644 apps/console/src/pages/iam/apiKeys/APIKeysPageLoader.tsx create mode 100644 apps/console/src/pages/iam/apiKeys/__generated__/APIKeysPageQuery.graphql.ts create mode 100644 apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyList.tsx create mode 100644 apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyTokenDialog.tsx create mode 100644 apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeysTable.tsx create mode 100644 apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListCreateMutation.graphql.ts create mode 100644 apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListFragment.graphql.ts create mode 100644 apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListRevealTokenMutation.graphql.ts create mode 100644 apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListRevokeMutation.graphql.ts diff --git a/apps/console/src/layouts/EmployeeLayout.tsx b/apps/console/src/layouts/EmployeeLayout.tsx index 74585b910..d6fe23f6b 100644 --- a/apps/console/src/layouts/EmployeeLayout.tsx +++ b/apps/console/src/layouts/EmployeeLayout.tsx @@ -379,7 +379,7 @@ function UserDropdown() { {isAuthorized("Organization", "deleteOrganization") && ( diff --git a/apps/console/src/pages/APIKeysPage.tsx b/apps/console/src/pages/APIKeysPage.tsx deleted file mode 100644 index 60d17001b..000000000 --- a/apps/console/src/pages/APIKeysPage.tsx +++ /dev/null @@ -1,846 +0,0 @@ -import { useState, useEffect } from "react"; -import { - Button, - Card, - Dialog, - DialogContent, - DialogFooter, - Field, - IconTrashCan, - IconPlusLarge, - IconPencil, - IconSquareBehindSquare2, - Label, - Select, - Badge, - useConfirm, - useDialogRef, - Option, - useToast, - Checkbox, - Table, - Thead, - Tbody, - Tr, - Th, - Td, - Input, -} from "@probo/ui"; -import { useTranslate } from "@probo/i18n"; -import { formatDate } from "@probo/helpers"; -import { useFormWithSchema } from "/hooks/useFormWithSchema"; -import { Controller } from "react-hook-form"; -import { z } from "zod"; -import { UnAuthenticatedError } from "@probo/relay"; - -interface APIKey { - id: string; - name: string; - expiresAt: string; - createdAt: string; - organizations: APIKeyOrganization[]; -} - -interface APIKeyOrganization { - organizationId: string; - organizationName: string; - role: string; -} - -interface Organization { - id: string; - name: string; - authStatus: "authenticated" | "unauthenticated" | "expired"; -} - -const createSchema = z.object({ - name: z.string().min(1, "Name is required"), - expiresIn: z.enum(["1month", "3months", "6months", "1year"]), - organizations: z - .array( - z.object({ - organizationId: z.string(), - role: z.string(), - }) - ) - .min(1, "At least one organization is required"), -}); - -type CreateFormData = z.infer; - -export default function APIKeysPage() { - const { __ } = useTranslate(); - const { toast } = useToast(); - const confirm = useConfirm(); - const dialogRef = useDialogRef(); - const editDialogRef = useDialogRef(); - const keyDialogRef = useDialogRef(); - const [currentKey, setCurrentKey] = useState(null); - const [isLoadingKey, setIsLoadingKey] = useState(false); - const [apiKeys, setApiKeys] = useState([]); - const [organizations, setOrganizations] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isCreating, setIsCreating] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [selectedOrganizations, setSelectedOrganizations] = useState( - [] - ); - const [organizationRoles, setOrganizationRoles] = useState< - Record - >({}); - const [editingAPIKey, setEditingAPIKey] = useState(null); - const [editingName, setEditingName] = useState(""); - const [error, setError] = useState(null); - - const { formState, handleSubmit, reset, control, setValue, register } = - useFormWithSchema(createSchema, { - defaultValues: { - name: new Date().toISOString().split("T")[0], - expiresIn: "1month", - organizations: [], - }, - }); - - if (error) { - throw error; - } - - const fetchAPIKeys = async () => { - try { - const response = await fetch("/connect/api-keys", { - credentials: "include", - }); - if (!response.ok) { - throw new Error("Failed to fetch API keys"); - } - const data: { apiKeys: APIKey[] } = await response.json(); - setApiKeys(data.apiKeys); - } catch (err) { - console.error("Failed to fetch API keys:", err); - toast({ - title: __("Error"), - description: __("Failed to load API keys"), - variant: "error", - }); - } - }; - - useEffect(() => { - const fetchData = async () => { - try { - const [apiKeysResponse, organizationsResponse] = await Promise.all([ - fetch("/connect/api-keys", { credentials: "include" }), - fetch("/connect/organizations?role=OWNER", { - credentials: "include", - }), - ]); - - if ( - apiKeysResponse.status === 401 || - organizationsResponse.status === 401 - ) { - setError(new UnAuthenticatedError()); - return; - } - - if (!apiKeysResponse.ok) { - throw new Error("Failed to fetch API keys"); - } - - if (!organizationsResponse.ok) { - throw new Error("Failed to fetch organizations"); - } - - const apiKeysData: { apiKeys: APIKey[] } = await apiKeysResponse.json(); - const orgsData: { organizations: Organization[] } = - await organizationsResponse.json(); - - const authenticatedOrgs = orgsData.organizations.filter( - (org) => org.authStatus === "authenticated" - ); - - setApiKeys(apiKeysData.apiKeys); - setOrganizations(authenticatedOrgs); - } catch (err) { - console.error("Failed to fetch data:", err); - toast({ - title: __("Error"), - description: __("Failed to load data"), - variant: "error", - }); - } finally { - setIsLoading(false); - } - }; - - fetchData(); - }, [__, toast]); - - const handleCreate = async (formData: CreateFormData) => { - const now = new Date(); - const expiresAt = new Date(now); - - switch (formData.expiresIn) { - case "1month": - expiresAt.setMonth(now.getMonth() + 1); - break; - case "3months": - expiresAt.setMonth(now.getMonth() + 3); - break; - case "6months": - expiresAt.setMonth(now.getMonth() + 6); - break; - case "1year": - expiresAt.setFullYear(now.getFullYear() + 1); - break; - } - - setIsCreating(true); - try { - const response = await fetch("/connect/api-keys", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - body: JSON.stringify({ - name: formData.name, - expiresAt: expiresAt.toISOString(), - organizations: formData.organizations, - }), - }); - - if (!response.ok) { - throw new Error("Failed to create API key"); - } - - const data: { apiKey: APIKey; key: string } = await response.json(); - - await fetchAPIKeys(); - dialogRef.current?.close(); - reset(); - setSelectedOrganizations([]); - setOrganizationRoles({}); - setCurrentKey(data.key); - keyDialogRef.current?.open(); - toast({ - title: __("Success"), - description: __("API Key created successfully"), - variant: "success", - }); - } catch (error) { - toast({ - title: __("Error"), - description: (error as Error).message, - variant: "error", - }); - } finally { - setIsCreating(false); - } - }; - - const handleEdit = (apiKey: APIKey) => { - setEditingAPIKey(apiKey); - setEditingName(apiKey.name); - const orgIds = apiKey.organizations.map((org) => org.organizationId); - const roles: Record = {}; - apiKey.organizations.forEach((org) => { - roles[org.organizationId] = org.role; - }); - setSelectedOrganizations(orgIds); - setOrganizationRoles(roles); - editDialogRef.current?.open(); - }; - - const handleUpdate = async () => { - if (!editingAPIKey) return; - - setIsUpdating(true); - try { - const response = await fetch("/connect/api-keys", { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - body: JSON.stringify({ - id: editingAPIKey.id, - name: editingName, - organizations: selectedOrganizations.map((id) => ({ - organizationId: id, - role: organizationRoles[id] || "FULL", - })), - }), - }); - - if (!response.ok) { - throw new Error("Failed to update API key"); - } - - await fetchAPIKeys(); - editDialogRef.current?.close(); - setEditingAPIKey(null); - setEditingName(""); - setSelectedOrganizations([]); - setOrganizationRoles({}); - toast({ - title: __("Success"), - description: __("API Key updated successfully"), - variant: "success", - }); - } catch (error) { - toast({ - title: __("Error"), - description: (error as Error).message, - variant: "error", - }); - } finally { - setIsUpdating(false); - } - }; - - const handleDelete = (id: string, name: string) => { - confirm( - async () => { - setIsDeleting(true); - try { - const response = await fetch("/connect/api-keys", { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - body: JSON.stringify({ id }), - }); - - if (!response.ok) { - throw new Error("Failed to delete API key"); - } - - setApiKeys(apiKeys.filter((key) => key.id !== id)); - toast({ - title: __("Success"), - description: __("API Key deleted successfully"), - variant: "success", - }); - } catch (error) { - toast({ - title: __("Error"), - description: (error as Error).message, - variant: "error", - }); - throw error; - } finally { - setIsDeleting(false); - } - }, - { - message: __( - `Are you sure you want to delete the API key "${name}"? This action cannot be undone.` - ), - } - ); - }; - - const handleShowToken = async (id: string) => { - setIsLoadingKey(true); - try { - const response = await fetch(`/connect/api-keys/${id}`, { - credentials: "include", - }); - - if (!response.ok) { - throw new Error("Failed to load API key"); - } - - const data: { key: string } = await response.json(); - setCurrentKey(data.key); - keyDialogRef.current?.open(); - } catch { - toast({ - title: __("Error"), - description: __("Failed to load API key"), - variant: "error", - }); - } finally { - setIsLoadingKey(false); - } - }; - - const copyToClipboard = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - toast({ - title: __("Success"), - description: __("API key copied to clipboard"), - variant: "success", - }); - } catch { - toast({ - title: __("Error"), - description: __("Failed to copy to clipboard"), - variant: "error", - }); - } - }; - - const isExpired = (expiresAt: string) => { - return new Date(expiresAt) < new Date(); - }; - - if (isLoading) { - return ( -
-

{__("API Keys")}

- -
-

{__("Loading...")}

-
-
-
- ); - } - - return ( -
-

{__("API Keys")}

- -
- {apiKeys.length === 0 ? ( - -
-

- {__("No API keys yet. Create one to get started.")} -

-
-
- ) : ( - apiKeys.map((apiKey) => { - const expired = isExpired(apiKey.expiresAt); - - return ( - -
-
-
-

{apiKey.name}

- {expired ? ( - {__("Expired")} - ) : ( - {__("Active")} - )} -
-
- - {__("Created")}: {formatDate(apiKey.createdAt)} - - • - - {__("Expires")}: {formatDate(apiKey.expiresAt)} - -
- {apiKey.organizations.length > 0 && ( -
- {apiKey.organizations.map((org) => ( - - {org.organizationName} ({org.role}) - - ))} -
- )} -
-
- -
-
-
- ); - }) - )} - - -

- {__("Create an API key")} -

-

- {__( - "Generate a new API key for programmatic access to your organization" - )} -

- -
-
- - -
- - - - - - - - ( - - )} - /> - - -
-

- {__("Organizations")} -

- {organizations.length > 0 && ( - - )} -
- {organizations.length === 0 ? ( -
- {__("No organizations available")} -
- ) : ( -
- - - - - - - - - - {organizations.map((org) => ( - - - - - - ))} - -
{__("Name")}{__("Role")} -
{__("Access")}
-
-
- {org.name} -
-
-
- {selectedOrganizations.includes(org.id) ? ( - - ) : ( - — - )} -
-
-
- { - let newSelected: string[]; - const newRoles = { ...organizationRoles }; - if (checked) { - newSelected = [ - ...selectedOrganizations, - org.id, - ]; - if (!newRoles[org.id]) { - newRoles[org.id] = "FULL"; - } - } else { - newSelected = selectedOrganizations.filter( - (id) => id !== org.id - ); - delete newRoles[org.id]; - } - setSelectedOrganizations(newSelected); - setOrganizationRoles(newRoles); - setValue( - "organizations", - newSelected.map((id) => ({ - organizationId: id, - role: newRoles[id] || "FULL", - })) - ); - }} - /> -
-
-
- )} -
-
- - - -
-
- - - - - - setEditingName(e.target.value)} - placeholder={__("API Key Name")} - /> - - -
-
-

- {__("Organizations")} -

- {organizations.length > 0 && ( - - )} -
- {organizations.length === 0 ? ( -
- {__("No organizations available")} -
- ) : ( -
- - - - - - - - - - {organizations.map((org) => ( - - - - - - ))} - -
{__("Name")}{__("Role")} -
{__("Access")}
-
-
- {org.name} -
-
-
- {selectedOrganizations.includes(org.id) ? ( - - ) : ( - — - )} -
-
-
- { - let newSelected: string[]; - const newRoles = { ...organizationRoles }; - if (checked) { - newSelected = [ - ...selectedOrganizations, - org.id, - ]; - if (!newRoles[org.id]) { - newRoles[org.id] = "FULL"; - } - } else { - newSelected = selectedOrganizations.filter( - (id) => id !== org.id - ); - delete newRoles[org.id]; - } - setSelectedOrganizations(newSelected); - setOrganizationRoles(newRoles); - }} - /> -
-
-
- )} -
-
- - - -
- - - -

- {__("Please save this API key securely.")} -

-
- - {currentKey || ""} - - -
-
- - - -
-
- ); -} diff --git a/apps/console/src/pages/iam/apiKeys/APIKeysPage.tsx b/apps/console/src/pages/iam/apiKeys/APIKeysPage.tsx new file mode 100644 index 000000000..733e167a6 --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/APIKeysPage.tsx @@ -0,0 +1,28 @@ +import { graphql, usePreloadedQuery, type PreloadedQuery } from "react-relay"; +import { useTranslate } from "@probo/i18n"; +import type { APIKeysPageQuery } from "./__generated__/APIKeysPageQuery.graphql"; +import { PersonalAPIKeyList } from "./_components/PersonalAPIKeyList"; + +export const apiKeysPageQuery = graphql` + query APIKeysPageQuery { + viewer { + ...PersonalAPIKeyListFragment + } + } +`; + +export function APIKeysPage(props: { + queryRef: PreloadedQuery; +}) { + const { queryRef } = props; + const { __ } = useTranslate(); + + const data = usePreloadedQuery(apiKeysPageQuery, queryRef); + + return ( +
+

{__("API Keys")}

+ {data.viewer && } +
+ ); +} diff --git a/apps/console/src/pages/iam/apiKeys/APIKeysPageLoader.tsx b/apps/console/src/pages/iam/apiKeys/APIKeysPageLoader.tsx new file mode 100644 index 000000000..853f9cef1 --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/APIKeysPageLoader.tsx @@ -0,0 +1,33 @@ +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { CenteredLayoutSkeleton } from "@probo/ui"; +import { APIKeysPage, apiKeysPageQuery } from "./APIKeysPage"; +import type { APIKeysPageQuery } from "./__generated__/APIKeysPageQuery.graphql"; +import { IAMRelayProvider } from "/providers/IAMRelayProvider"; + +function APIKeysPageLoaderInner() { + const [queryRef, loadQuery] = + useQueryLoader(apiKeysPageQuery); + + useEffect(() => { + loadQuery({}); + }, [loadQuery]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} + +export default function APIKeysPageLoader() { + return ( + + + + ); +} diff --git a/apps/console/src/pages/iam/apiKeys/__generated__/APIKeysPageQuery.graphql.ts b/apps/console/src/pages/iam/apiKeys/__generated__/APIKeysPageQuery.graphql.ts new file mode 100644 index 000000000..769a320bf --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/__generated__/APIKeysPageQuery.graphql.ts @@ -0,0 +1,209 @@ +/** + * @generated SignedSource<<83cc00d864e6388ef2156203499da849>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type APIKeysPageQuery$variables = Record; +export type APIKeysPageQuery$data = { + readonly viewer: { + readonly " $fragmentSpreads": FragmentRefs<"PersonalAPIKeyListFragment">; + } | null | undefined; +}; +export type APIKeysPageQuery = { + response: APIKeysPageQuery$data; + variables: APIKeysPageQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v1 = [ + { + "kind": "Literal", + "name": "first", + "value": 1000 + } +]; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "APIKeysPageQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "viewer", + "plural": false, + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "PersonalAPIKeyListFragment" + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "APIKeysPageQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "viewer", + "plural": false, + "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": (v1/*: any*/), + "concreteType": "PersonalAPIKeyConnection", + "kind": "LinkedField", + "name": "personalAPIKeys", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "PersonalAPIKeyEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "PersonalAPIKey", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "expiresAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "lastUsedAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": "personalAPIKeys(first:1000)" + }, + { + "alias": null, + "args": (v1/*: any*/), + "filters": null, + "handle": "connection", + "key": "PersonalAPIKeyListFragment_personalAPIKeys", + "kind": "LinkedHandle", + "name": "personalAPIKeys" + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "14e698e7b6edf0afe9cf3398986cd529", + "id": null, + "metadata": {}, + "name": "APIKeysPageQuery", + "operationKind": "query", + "text": "query APIKeysPageQuery {\n viewer {\n ...PersonalAPIKeyListFragment\n id\n }\n}\n\nfragment PersonalAPIKeyListFragment on Identity {\n id\n personalAPIKeys(first: 1000) {\n edges {\n node {\n id\n name\n createdAt\n expiresAt\n lastUsedAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "e99d7224e11d4e9060518c3155f98a7d"; + +export default node; diff --git a/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyList.tsx b/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyList.tsx new file mode 100644 index 000000000..6e0e08566 --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyList.tsx @@ -0,0 +1,393 @@ +import { useState } from "react"; +import { Controller } from "react-hook-form"; +import { + ConnectionHandler, + graphql, + useFragment, + useMutation, +} from "react-relay"; +import type { RecordSourceSelectorProxy } from "relay-runtime"; +import { z } from "zod"; +import { useTranslate } from "@probo/i18n"; +import { formatError, type GraphQLError } from "@probo/helpers"; +import { + Breadcrumb, + Button, + Card, + Dialog, + DialogContent, + DialogFooter, + Field, + Input, + Label, + Option, + Select, + useConfirm, + useDialogRef, + useToast, +} from "@probo/ui"; +import { useFormWithSchema } from "/hooks/useFormWithSchema"; +import type { PersonalAPIKeyListFragment$key } from "./__generated__/PersonalAPIKeyListFragment.graphql"; +import type { PersonalAPIKeyListCreateMutation } from "./__generated__/PersonalAPIKeyListCreateMutation.graphql"; +import type { PersonalAPIKeyListRevokeMutation } from "./__generated__/PersonalAPIKeyListRevokeMutation.graphql"; +import type { PersonalAPIKeyListRevealTokenMutation } from "./__generated__/PersonalAPIKeyListRevealTokenMutation.graphql"; +import { PersonalAPIKeysTable } from "./PersonalAPIKeysTable"; +import { PersonalAPIKeyTokenDialog } from "./PersonalAPIKeyTokenDialog"; + +const fragment = graphql` + fragment PersonalAPIKeyListFragment on Identity { + id + + personalAPIKeys(first: 1000) + @required(action: THROW) + @connection(key: "PersonalAPIKeyListFragment_personalAPIKeys") { + edges @required(action: THROW) { + node { + id + name + createdAt + expiresAt + lastUsedAt + } + } + } + } +`; + +const createMutation = graphql` + mutation PersonalAPIKeyListCreateMutation( + $input: CreatePersonalAPIKeyInput! + $connections: [ID!]! + ) { + createPersonalAPIKey(input: $input) { + personalAPIKeyEdge @prependEdge(connections: $connections) { + node { + id + name + createdAt + expiresAt + lastUsedAt + } + } + token + } + } +`; + +const revokeMutation = graphql` + mutation PersonalAPIKeyListRevokeMutation( + $input: RevokePersonalAPIKeyInput! + ) { + revokePersonalAPIKey(input: $input) { + success + } + } +`; + +const revealTokenMutation = graphql` + mutation PersonalAPIKeyListRevealTokenMutation( + $input: RevealPersonalAPIKeyTokenInput! + ) { + revealPersonalAPIKeyToken(input: $input) { + token + } + } +`; + +const createSchema = z.object({ + name: z.string().min(1, "Name is required"), + expiresIn: z.enum(["1month", "3months", "6months", "1year"]), +}); +type CreateFormData = z.infer; + +function computeExpiresAt(expiresIn: CreateFormData["expiresIn"]) { + const now = new Date(); + const expiresAt = new Date(now); + switch (expiresIn) { + case "1month": + expiresAt.setMonth(now.getMonth() + 1); + break; + case "3months": + expiresAt.setMonth(now.getMonth() + 3); + break; + case "6months": + expiresAt.setMonth(now.getMonth() + 6); + break; + case "1year": + expiresAt.setFullYear(now.getFullYear() + 1); + break; + } + return expiresAt; +} + +export function PersonalAPIKeyList(props: { + fKey: PersonalAPIKeyListFragment$key; +}) { + const { fKey } = props; + const { __ } = useTranslate(); + const { toast } = useToast(); + const confirm = useConfirm(); + const createDialogRef = useDialogRef(); + const tokenDialogRef = useDialogRef(); + + const [token, setToken] = useState(""); + + const viewer = useFragment(fragment, fKey); + + const keys = viewer.personalAPIKeys.edges.map(({ node }) => node); + + const { formState, handleSubmit, register, control, reset, watch } = + useFormWithSchema(createSchema, { + defaultValues: { + name: new Date().toISOString().split("T")[0], + expiresIn: "1month", + }, + }); + + watch(); + + const [createCommit, isCreating] = + useMutation(createMutation); + const [revokeCommit] = + useMutation(revokeMutation); + const [revealTokenCommit, isRevealingToken] = + useMutation(revealTokenMutation); + + const handleCreate = (data: CreateFormData) => { + const expiresAt = computeExpiresAt(data.expiresIn); + const connectionID = ConnectionHandler.getConnectionID( + viewer.id, + "PersonalAPIKeyListFragment_personalAPIKeys" + ); + + createCommit({ + variables: { + input: { + name: data.name, + expiresAt: expiresAt.toISOString(), + // API keys are no longer linked to organizations; keep schema compatibility. + organizationIds: [], + }, + connections: [connectionID], + }, + onCompleted: (response) => { + toast({ + title: __("Success"), + description: __("API key created successfully."), + variant: "success", + }); + const newToken = response.createPersonalAPIKey?.token; + if (newToken) { + setToken(newToken); + tokenDialogRef.current?.open(); + } + createDialogRef.current?.close(); + reset(); + }, + onError: (error) => { + toast({ + title: __("Error"), + description: formatError(__("Failed to create API key."), error), + variant: "error", + }); + }, + }); + }; + + const handleRevoke = (key: { id: string; name: string }) => { + confirm( + async () => { + await new Promise((resolve, reject) => { + revokeCommit({ + variables: { + input: { tokenId: key.id }, + }, + updater: (store: RecordSourceSelectorProxy) => { + const viewerRecord = store.getRoot().getLinkedRecord("viewer"); + if (!viewerRecord) return; + const connection = ConnectionHandler.getConnection( + viewerRecord, + "PersonalAPIKeyListFragment_personalAPIKeys" + ); + if (connection) { + ConnectionHandler.deleteNode(connection, key.id); + } + }, + onCompleted: (_response, errors) => { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to revoke API key."), + errors as GraphQLError[] + ), + variant: "error", + }); + reject(errors); + return; + } + toast({ + title: __("Success"), + description: __("API key revoked successfully."), + variant: "success", + }); + resolve(); + }, + onError: (error) => { + toast({ + title: __("Error"), + description: formatError( + __("Failed to revoke API key."), + error + ), + variant: "error", + }); + reject(error); + }, + }); + }); + }, + { + title: __("Revoke API Key"), + message: __( + `Are you sure you want to revoke the API key "${key.name}"? This action cannot be undone.` + ), + label: __("Revoke"), + variant: "danger", + } + ); + }; + + const handleShowToken = (key: { id: string; name: string }) => { + revealTokenCommit({ + variables: { + input: { + tokenId: key.id, + }, + }, + onCompleted: (response, errors) => { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to reveal API key token."), + errors as any + ), + variant: "error", + }); + return; + } + + const tokenValue = response.revealPersonalAPIKeyToken?.token; + if (!tokenValue) { + toast({ + title: __("Error"), + description: __("No token returned."), + variant: "error", + }); + return; + } + + setToken(tokenValue); + tokenDialogRef.current?.open(); + }, + onError: (error: Error) => { + toast({ + title: __("Error"), + description: formatError( + __("Failed to reveal API key token."), + error + ), + variant: "error", + }); + }, + }); + }; + + return ( + <> +
+
+

{__("API Keys")}

+ +
+ + {keys.length === 0 ? ( + +
+

+ {__("No API keys")} +

+

+ {__("Create an API key to authenticate programmatic access.")} +

+
+
+ ) : ( + + + + )} +
+ + } + onClose={() => reset()} + > +
+ + + + + + + + + ( + + )} + /> + + + + + +
+
+ + { + tokenDialogRef.current?.close(); + setToken(""); + }} + /> + + ); +} diff --git a/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyTokenDialog.tsx b/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyTokenDialog.tsx new file mode 100644 index 000000000..bda4c7ad5 --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyTokenDialog.tsx @@ -0,0 +1,45 @@ +import { + Breadcrumb, + Button, + Dialog, + DialogContent, + DialogFooter, +} from "@probo/ui"; +import { useTranslate } from "@probo/i18n"; +import { useCopy } from "@probo/hooks"; +import { useEffect } from "react"; + +export function PersonalAPIKeyTokenDialog(props: { + dialogRef: React.RefObject<{ open: () => void; close: () => void } | null>; + token: string; + onDone: () => void; +}) { + const { dialogRef, token, onDone } = props; + const { __ } = useTranslate(); + const [isCopied, copy] = useCopy(); + + useEffect(() => {}, [token]); + + return ( + } + > + +
+ {token} + +
+
+ + + +
+ ); +} diff --git a/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeysTable.tsx b/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeysTable.tsx new file mode 100644 index 000000000..d3c2fc191 --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeysTable.tsx @@ -0,0 +1,83 @@ +import { useTranslate } from "@probo/i18n"; +import { formatDate } from "@probo/helpers"; +import { Button, Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui"; + +export type PersonalAPIKeyRow = { + id: string; + name: string; + createdAt: string; + expiresAt: string; + lastUsedAt: string | null; +}; + +export function PersonalAPIKeysTable(props: { + keys: PersonalAPIKeyRow[]; + onRevoke: (key: { id: string; name: string }) => void; + onShowToken: (key: { id: string; name: string }) => void; + isShowingToken?: boolean; +}) { + const { keys, onRevoke, onShowToken, isShowingToken } = props; + const { __ } = useTranslate(); + const now = new Date(); + + return ( + + + + + + + + + + + + {keys.map((k) => { + const expired = new Date(k.expiresAt) < now; + return ( + + + + + + + + ); + })} + +
{__("Name")}{__("Last used")}{__("Created")}{__("Expires")}
+
{k.name}
+
+ {expired ? __("Expired") : __("Active")} +
+
+ + {k.lastUsedAt ? formatDate(k.lastUsedAt) : "—"} + + + + {formatDate(k.createdAt)} + + + + {formatDate(k.expiresAt)} + + +
+ + +
+
+ ); +} diff --git a/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListCreateMutation.graphql.ts b/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListCreateMutation.graphql.ts new file mode 100644 index 000000000..f630499b7 --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListCreateMutation.graphql.ts @@ -0,0 +1,202 @@ +/** + * @generated SignedSource<<875eae6f5455a17a1bb8e0590c7acdb0>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type CreatePersonalAPIKeyInput = { + expiresAt: any; + name: string; + organizationIds: ReadonlyArray; +}; +export type PersonalAPIKeyListCreateMutation$variables = { + connections: ReadonlyArray; + input: CreatePersonalAPIKeyInput; +}; +export type PersonalAPIKeyListCreateMutation$data = { + readonly createPersonalAPIKey: { + readonly personalAPIKeyEdge: { + readonly node: { + readonly createdAt: any; + readonly expiresAt: any; + readonly id: string; + readonly lastUsedAt: any | null | undefined; + readonly name: string; + }; + }; + readonly token: string; + } | null | undefined; +}; +export type PersonalAPIKeyListCreateMutation = { + response: PersonalAPIKeyListCreateMutation$data; + variables: PersonalAPIKeyListCreateMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "connections" +}, +v1 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" +}, +v2 = [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } +], +v3 = { + "alias": null, + "args": null, + "concreteType": "PersonalAPIKeyEdge", + "kind": "LinkedField", + "name": "personalAPIKeyEdge", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "PersonalAPIKey", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "expiresAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "lastUsedAt", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null +}, +v4 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "token", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [ + (v0/*: any*/), + (v1/*: any*/) + ], + "kind": "Fragment", + "metadata": null, + "name": "PersonalAPIKeyListCreateMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "CreatePersonalAPIKeyPayload", + "kind": "LinkedField", + "name": "createPersonalAPIKey", + "plural": false, + "selections": [ + (v3/*: any*/), + (v4/*: any*/) + ], + "storageKey": null + } + ], + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [ + (v1/*: any*/), + (v0/*: any*/) + ], + "kind": "Operation", + "name": "PersonalAPIKeyListCreateMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "CreatePersonalAPIKeyPayload", + "kind": "LinkedField", + "name": "createPersonalAPIKey", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "alias": null, + "args": null, + "filters": null, + "handle": "prependEdge", + "key": "", + "kind": "LinkedHandle", + "name": "personalAPIKeyEdge", + "handleArgs": [ + { + "kind": "Variable", + "name": "connections", + "variableName": "connections" + } + ] + }, + (v4/*: any*/) + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "5b1f6109730bd9f261afd6635d568b59", + "id": null, + "metadata": {}, + "name": "PersonalAPIKeyListCreateMutation", + "operationKind": "mutation", + "text": "mutation PersonalAPIKeyListCreateMutation(\n $input: CreatePersonalAPIKeyInput!\n) {\n createPersonalAPIKey(input: $input) {\n personalAPIKeyEdge {\n node {\n id\n name\n createdAt\n expiresAt\n lastUsedAt\n }\n }\n token\n }\n}\n" + } +}; +})(); + +(node as any).hash = "c7e932ad41ff2740e8687b7edb904431"; + +export default node; diff --git a/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListFragment.graphql.ts b/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListFragment.graphql.ts new file mode 100644 index 000000000..84a606d19 --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListFragment.graphql.ts @@ -0,0 +1,176 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type PersonalAPIKeyListFragment$data = { + readonly id: string; + readonly personalAPIKeys: { + readonly edges: ReadonlyArray<{ + readonly node: { + readonly createdAt: any; + readonly expiresAt: any; + readonly id: string; + readonly lastUsedAt: any | null | undefined; + readonly name: string; + }; + }>; + }; + readonly " $fragmentType": "PersonalAPIKeyListFragment"; +}; +export type PersonalAPIKeyListFragment$key = { + readonly " $data"?: PersonalAPIKeyListFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"PersonalAPIKeyListFragment">; +}; + +const node: ReaderFragment = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}; +return { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": { + "connection": [ + { + "count": null, + "cursor": null, + "direction": "forward", + "path": [ + "personalAPIKeys" + ] + } + ] + }, + "name": "PersonalAPIKeyListFragment", + "selections": [ + (v0/*: any*/), + { + "kind": "RequiredField", + "field": { + "alias": "personalAPIKeys", + "args": null, + "concreteType": "PersonalAPIKeyConnection", + "kind": "LinkedField", + "name": "__PersonalAPIKeyListFragment_personalAPIKeys_connection", + "plural": false, + "selections": [ + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "PersonalAPIKeyEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "PersonalAPIKey", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "expiresAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "lastUsedAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + "action": "THROW" + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + }, + "action": "THROW" + } + ], + "type": "Identity", + "abstractKey": null +}; +})(); + +(node as any).hash = "70fa6b4e568fc1598b8dc23a030428b5"; + +export default node; diff --git a/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListRevealTokenMutation.graphql.ts b/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListRevealTokenMutation.graphql.ts new file mode 100644 index 000000000..99b0e019d --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListRevealTokenMutation.graphql.ts @@ -0,0 +1,92 @@ +/** + * @generated SignedSource<<7a6fb678e9147fd1810e02bd09d5cef1>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type RevealPersonalAPIKeyTokenInput = { + tokenId: string; +}; +export type PersonalAPIKeyListRevealTokenMutation$variables = { + input: RevealPersonalAPIKeyTokenInput; +}; +export type PersonalAPIKeyListRevealTokenMutation$data = { + readonly revealPersonalAPIKeyToken: { + readonly token: string; + } | null | undefined; +}; +export type PersonalAPIKeyListRevealTokenMutation = { + response: PersonalAPIKeyListRevealTokenMutation$data; + variables: PersonalAPIKeyListRevealTokenMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "RevealPersonalAPIKeyTokenPayload", + "kind": "LinkedField", + "name": "revealPersonalAPIKeyToken", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "token", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "PersonalAPIKeyListRevealTokenMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "PersonalAPIKeyListRevealTokenMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "25ffba7c8a69e22dcc46b56a6c8633ed", + "id": null, + "metadata": {}, + "name": "PersonalAPIKeyListRevealTokenMutation", + "operationKind": "mutation", + "text": "mutation PersonalAPIKeyListRevealTokenMutation(\n $input: RevealPersonalAPIKeyTokenInput!\n) {\n revealPersonalAPIKeyToken(input: $input) {\n token\n }\n}\n" + } +}; +})(); + +(node as any).hash = "7dad7ebd6e8086a01b2bac8aa80c532e"; + +export default node; diff --git a/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListRevokeMutation.graphql.ts b/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListRevokeMutation.graphql.ts new file mode 100644 index 000000000..a92b868ee --- /dev/null +++ b/apps/console/src/pages/iam/apiKeys/_components/__generated__/PersonalAPIKeyListRevokeMutation.graphql.ts @@ -0,0 +1,92 @@ +/** + * @generated SignedSource<<84eef5bca22c2ad2b73bbe068767e35a>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type RevokePersonalAPIKeyInput = { + tokenId: string; +}; +export type PersonalAPIKeyListRevokeMutation$variables = { + input: RevokePersonalAPIKeyInput; +}; +export type PersonalAPIKeyListRevokeMutation$data = { + readonly revokePersonalAPIKey: { + readonly success: boolean; + } | null | undefined; +}; +export type PersonalAPIKeyListRevokeMutation = { + response: PersonalAPIKeyListRevokeMutation$data; + variables: PersonalAPIKeyListRevokeMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "RevokePersonalAPIKeyPayload", + "kind": "LinkedField", + "name": "revokePersonalAPIKey", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "success", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "PersonalAPIKeyListRevokeMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "PersonalAPIKeyListRevokeMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "fd05bb0236b583ca54be466cffe8f45b", + "id": null, + "metadata": {}, + "name": "PersonalAPIKeyListRevokeMutation", + "operationKind": "mutation", + "text": "mutation PersonalAPIKeyListRevokeMutation(\n $input: RevokePersonalAPIKeyInput!\n) {\n revokePersonalAPIKey(input: $input) {\n success\n }\n}\n" + } +}; +})(); + +(node as any).hash = "34ef04d19251c479827ca5028346f65b"; + +export default node; diff --git a/apps/console/src/pages/iam/memberships/_components/SessionDropdown.tsx b/apps/console/src/pages/iam/memberships/_components/SessionDropdown.tsx index 802c814ab..fa52fcc18 100644 --- a/apps/console/src/pages/iam/memberships/_components/SessionDropdown.tsx +++ b/apps/console/src/pages/iam/memberships/_components/SessionDropdown.tsx @@ -75,7 +75,7 @@ export function SessionDropdown(props: { fKey: SessionDropdownFragment$key }) { {isAuthorized("Organization", "deleteOrganization") && ( diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 56e73429f..d835f7a67 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -123,8 +123,8 @@ const routes = [ Component: lazy(() => import("./pages/DocumentSigningRequestsPage")), }, { - path: "api-keys", - Component: lazy(() => import("./pages/APIKeysPage")), + path: "me/api-keys", + Component: lazy(() => import("./pages/iam/apiKeys/APIKeysPageLoader")), }, ], },