From b04e1a3568a7c7d5ba4caa5804d212a76f396109 Mon Sep 17 00:00:00 2001 From: Sloan Sparger <sloansparger@users.noreply.github.com> Date: Mon, 18 Mar 2024 11:59:27 -0500 Subject: [PATCH] RTKQuery integration: API Keys (#40043) --- frontend/src/metabase-types/api/admin.ts | 21 +++++++ .../GroupMembersTable/GroupMembersTable.tsx | 21 +++---- .../admin/people/components/GroupsListing.jsx | 9 ++- .../auth/components/ApiKeysAuthCard.tsx | 11 +--- .../components/ApiKeys/CreateApiKeyModal.tsx | 19 ++----- .../components/ApiKeys/DeleteApiKeyModal.tsx | 11 ++-- .../components/ApiKeys/EditApiKeyModal.tsx | 25 ++++----- .../components/ApiKeys/ManageApiKeys.tsx | 54 +++++++----------- frontend/src/metabase/api/api-keys.ts | 56 +++++++++++++++++++ frontend/src/metabase/api/index.ts | 1 + frontend/src/metabase/api/tags.ts | 5 +- 11 files changed, 139 insertions(+), 94 deletions(-) create mode 100644 frontend/src/metabase/api/api-keys.ts diff --git a/frontend/src/metabase-types/api/admin.ts b/frontend/src/metabase-types/api/admin.ts index fa53967eb43..b0597a71372 100644 --- a/frontend/src/metabase-types/api/admin.ts +++ b/frontend/src/metabase-types/api/admin.ts @@ -14,3 +14,24 @@ export type ApiKey = { common_name: string; }; }; + +export type CreateApiKeyInput = { + name: string; + group_id: string; +}; + +export type CreateApiKeyResponse = { + unmasked_key: string; +}; + +export type UpdateApiKeyInput = { + id: number; + group_id: string; + name: string; +}; + +export type UpdateApiKeyResponse = void; + +export type RegenerateApiKeyResponse = { + unmasked_key: string; +}; diff --git a/frontend/src/metabase/admin/people/components/GroupMembersTable/GroupMembersTable.tsx b/frontend/src/metabase/admin/people/components/GroupMembersTable/GroupMembersTable.tsx index af620884e95..acdf65a4c32 100644 --- a/frontend/src/metabase/admin/people/components/GroupMembersTable/GroupMembersTable.tsx +++ b/frontend/src/metabase/admin/people/components/GroupMembersTable/GroupMembersTable.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; -import { useAsync } from "react-use"; import { t } from "ttag"; +import { useListApiKeyQuery } from "metabase/api"; import AdminContentTable from "metabase/components/AdminContentTable"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import PaginationControls from "metabase/components/PaginationControls"; @@ -11,7 +11,6 @@ import { isAdminGroup, isDefaultGroup } from "metabase/lib/groups"; import { isNotNull } from "metabase/lib/types"; import { getFullName } from "metabase/lib/user"; import { PLUGIN_GROUP_MANAGERS } from "metabase/plugins"; -import { ApiKeysApi } from "metabase/services"; import { Tooltip, Text, Icon } from "metabase/ui"; import type { ApiKey, Group, Member, User as IUser } from "metabase-types/api"; import type { State } from "metabase-types/store"; @@ -59,14 +58,10 @@ function GroupMembersTable({ onPreviousPage, reload, }: GroupMembersTableProps) { - const { loading, value: apiKeys } = useAsync(async () => { - const apiKeys = await (ApiKeysApi.list() as Promise<ApiKey[]>); - const filteredApiKeys = apiKeys?.filter( - (apiKey: ApiKey) => apiKey.group.id === group.id, - ); - - return filteredApiKeys ?? []; - }, [group.id]); + const { isLoading, data: apiKeys } = useListApiKeyQuery(); + const groupApiKeys = useMemo(() => { + return apiKeys?.filter(apiKey => apiKey.group.id === group.id) ?? []; + }, [apiKeys, group.id]); // you can't remove people from Default and you can't remove the last user from Admin const isCurrentUser = ({ id }: Partial<IUser>) => id === currentUserId; @@ -97,8 +92,8 @@ function GroupMembersTable({ [groupMemberships], ); - if (loading) { - return <LoadingAndErrorWrapper loading={loading} />; + if (isLoading) { + return <LoadingAndErrorWrapper loading={isLoading} />; } return ( @@ -112,7 +107,7 @@ function GroupMembersTable({ onDone={handleAddUser} /> )} - {apiKeys?.map((apiKey: ApiKey) => ( + {groupApiKeys?.map((apiKey: ApiKey) => ( <ApiKeyRow key={`apiKey-${apiKey.id}`} apiKey={apiKey} /> ))} {groupUsers.map((user: IUser) => { diff --git a/frontend/src/metabase/admin/people/components/GroupsListing.jsx b/frontend/src/metabase/admin/people/components/GroupsListing.jsx index 257aafe3df2..5287a02e9f2 100644 --- a/frontend/src/metabase/admin/people/components/GroupsListing.jsx +++ b/frontend/src/metabase/admin/people/components/GroupsListing.jsx @@ -1,10 +1,10 @@ /* eslint-disable react/prop-types */ import cx from "classnames"; import { Component } from "react"; -import { useAsync } from "react-use"; import { jt, t } from "ttag"; import _ from "underscore"; +import { useListApiKeyQuery } from "metabase/api"; import AdminContentTable from "metabase/components/AdminContentTable"; import AdminPaneLayout from "metabase/components/AdminPaneLayout"; import Alert from "metabase/components/Alert"; @@ -25,7 +25,6 @@ import { getGroupNameLocalized, } from "metabase/lib/groups"; import { KEYCODE_ENTER } from "metabase/lib/keyboard"; -import { ApiKeysApi } from "metabase/services"; import { Stack, Text, Group, Button, Icon } from "metabase/ui"; import { AddRow } from "./AddRow"; @@ -277,10 +276,10 @@ function GroupsTable({ onEditGroupCancelClicked, onEditGroupDoneClicked, }) { - const { loading, value: apiKeys } = useAsync(() => ApiKeysApi.list(), []); + const { isLoading, data: apiKeys } = useListApiKeyQuery(); - if (loading) { - return <LoadingAndErrorWrapper loading={loading} />; + if (isLoading) { + return <LoadingAndErrorWrapper loading={isLoading} />; } return ( diff --git a/frontend/src/metabase/admin/settings/auth/components/ApiKeysAuthCard.tsx b/frontend/src/metabase/admin/settings/auth/components/ApiKeysAuthCard.tsx index 9ad8f809b78..ecf479cc3d4 100644 --- a/frontend/src/metabase/admin/settings/auth/components/ApiKeysAuthCard.tsx +++ b/frontend/src/metabase/admin/settings/auth/components/ApiKeysAuthCard.tsx @@ -1,17 +1,12 @@ -import { useEffect, useState } from "react"; import { t } from "ttag"; -import { ApiKeysApi } from "metabase/services"; +import { useCountApiKeyQuery } from "metabase/api"; import { AuthCardBody } from "./AuthCard/AuthCard"; export const ApiKeysAuthCard = () => { - const [keyCount, setKeyCount] = useState(0); - - useEffect(() => { - ApiKeysApi.count().then(setKeyCount); - }, []); - + const { data } = useCountApiKeyQuery(); + const keyCount = data ?? 0; const isConfigured = keyCount > 0; return ( diff --git a/frontend/src/metabase/admin/settings/components/ApiKeys/CreateApiKeyModal.tsx b/frontend/src/metabase/admin/settings/components/ApiKeys/CreateApiKeyModal.tsx index 39a7cc4a18c..e0a9e5c9832 100644 --- a/frontend/src/metabase/admin/settings/components/ApiKeys/CreateApiKeyModal.tsx +++ b/frontend/src/metabase/admin/settings/components/ApiKeys/CreateApiKeyModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { t } from "ttag"; +import { useCreateApiKeyMutation } from "metabase/api"; import { Form, FormErrorMessage, @@ -9,30 +10,22 @@ import { FormSubmitButton, FormTextInput, } from "metabase/forms"; -import { ApiKeysApi } from "metabase/services"; import { Text, Button, Group, Modal, Stack } from "metabase/ui"; import { SecretKeyModal } from "./SecretKeyModal"; import { API_KEY_VALIDATION_SCHEMA } from "./utils"; -export const CreateApiKeyModal = ({ - onClose, - refreshList, -}: { - onClose: () => void; - refreshList: () => void; -}) => { +export const CreateApiKeyModal = ({ onClose }: { onClose: () => void }) => { const [modal, setModal] = useState<"create" | "secretKey">("create"); - const [secretKey, setSecretKey] = useState<string>(""); + const [createApiKey, response] = useCreateApiKeyMutation(); + const secretKey = response?.data?.unmasked_key || ""; const handleSubmit = useCallback( async vals => { - const response = await ApiKeysApi.create(vals); - setSecretKey(response.unmasked_key); + await createApiKey(vals); setModal("secretKey"); - refreshList(); }, - [refreshList], + [createApiKey], ); if (modal === "secretKey") { diff --git a/frontend/src/metabase/admin/settings/components/ApiKeys/DeleteApiKeyModal.tsx b/frontend/src/metabase/admin/settings/components/ApiKeys/DeleteApiKeyModal.tsx index a7bd6228b33..2edbcea834a 100644 --- a/frontend/src/metabase/admin/settings/components/ApiKeys/DeleteApiKeyModal.tsx +++ b/frontend/src/metabase/admin/settings/components/ApiKeys/DeleteApiKeyModal.tsx @@ -1,30 +1,29 @@ import { useCallback } from "react"; import { t } from "ttag"; +import { useDeleteApiKeyMutation } from "metabase/api"; import { FormProvider, Form, FormSubmitButton, FormErrorMessage, } from "metabase/forms"; -import { ApiKeysApi } from "metabase/services"; import { Text, Button, Group, Modal, Stack } from "metabase/ui"; import type { ApiKey } from "metabase-types/api"; export const DeleteApiKeyModal = ({ onClose, - refreshList, apiKey, }: { onClose: () => void; - refreshList: () => void; apiKey: ApiKey; }) => { + const [deleteApiKey] = useDeleteApiKeyMutation(); + const handleDelete = useCallback(async () => { - await ApiKeysApi.delete({ id: apiKey.id }); - refreshList(); + await deleteApiKey(apiKey.id); onClose(); - }, [refreshList, onClose, apiKey.id]); + }, [onClose, apiKey.id, deleteApiKey]); return ( <Modal diff --git a/frontend/src/metabase/admin/settings/components/ApiKeys/EditApiKeyModal.tsx b/frontend/src/metabase/admin/settings/components/ApiKeys/EditApiKeyModal.tsx index ad387e11278..4afcec69664 100644 --- a/frontend/src/metabase/admin/settings/components/ApiKeys/EditApiKeyModal.tsx +++ b/frontend/src/metabase/admin/settings/components/ApiKeys/EditApiKeyModal.tsx @@ -1,6 +1,10 @@ import { useCallback, useState } from "react"; import { t } from "ttag"; +import { + useRegenerateApiKeyMutation, + useUpdateApiKeyMutation, +} from "metabase/api"; import { Form, FormErrorMessage, @@ -9,7 +13,6 @@ import { FormSubmitButton, FormTextInput, } from "metabase/forms"; -import { ApiKeysApi } from "metabase/services"; import { Button, Group, Modal, Stack, Text } from "metabase/ui"; import { getThemeOverrides } from "metabase/ui/theme"; import type { ApiKey } from "metabase-types/api"; @@ -18,25 +21,24 @@ import { SecretKeyModal } from "./SecretKeyModal"; import { API_KEY_VALIDATION_SCHEMA } from "./utils"; const { fontFamilyMonospace } = getThemeOverrides(); + type EditModalName = "edit" | "regenerate" | "secretKey"; const RegenerateKeyModal = ({ apiKey, setModal, setSecretKey, - refreshList, }: { apiKey: ApiKey; setModal: (name: EditModalName) => void; setSecretKey: (key: string) => void; - refreshList: () => void; }) => { + const [regenerateApiKey] = useRegenerateApiKeyMutation(); const handleRegenerate = useCallback(async () => { - const result = await ApiKeysApi.regenerate({ id: apiKey.id }); + const result = await regenerateApiKey(apiKey.id).unwrap(); setSecretKey(result.unmasked_key); setModal("secretKey"); - refreshList(); - }, [apiKey.id, refreshList, setModal, setSecretKey]); + }, [apiKey.id, setModal, setSecretKey, regenerateApiKey]); return ( <Modal @@ -88,27 +90,25 @@ const RegenerateKeyModal = ({ export const EditApiKeyModal = ({ onClose, - refreshList, apiKey, }: { onClose: () => void; - refreshList: () => void; apiKey: ApiKey; }) => { const [modal, setModal] = useState<EditModalName>("edit"); const [secretKey, setSecretKey] = useState<string>(""); + const [updateApiKey] = useUpdateApiKeyMutation(); const handleSubmit = useCallback( async vals => { - await ApiKeysApi.edit({ + await updateApiKey({ id: vals.id, group_id: vals.group_id, name: vals.name, - }); - refreshList(); + }).unwrap(); onClose(); }, - [onClose, refreshList], + [onClose, updateApiKey], ); if (modal === "secretKey") { @@ -121,7 +121,6 @@ export const EditApiKeyModal = ({ apiKey={apiKey} setModal={setModal} setSecretKey={setSecretKey} - refreshList={refreshList} /> ); } diff --git a/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx b/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx index 4c6430b2943..ce82f7e713c 100644 --- a/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx +++ b/frontend/src/metabase/admin/settings/components/ApiKeys/ManageApiKeys.tsx @@ -1,14 +1,11 @@ -import { useEffect, useState } from "react"; -import { useAsyncFn } from "react-use"; +import { useState, useMemo } from "react"; import { t } from "ttag"; -const { fontFamilyMonospace } = getThemeOverrides(); - +import { useListApiKeyQuery } from "metabase/api"; import Breadcrumbs from "metabase/components/Breadcrumbs"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import { Ellipsified } from "metabase/core/components/Ellipsified"; import { formatDateTimeWithUnit } from "metabase/lib/formatting/date"; -import { ApiKeysApi } from "metabase/services"; import { Stack, Title, Text, Button, Group, Icon } from "metabase/ui"; import { getThemeOverrides } from "metabase/ui/theme"; import type { ApiKey } from "metabase-types/api"; @@ -18,6 +15,8 @@ import { DeleteApiKeyModal } from "./DeleteApiKeyModal"; import { EditApiKeyModal } from "./EditApiKeyModal"; import { formatMaskedKey } from "./utils"; +const { fontFamilyMonospace } = getThemeOverrides(); + type Modal = null | "create" | "edit" | "delete"; function EmptyTableWarning({ onCreate }: { onCreate: () => void }) { @@ -45,7 +44,7 @@ function ApiKeysTable({ setActiveApiKey: (apiKey: ApiKey) => void; setModal: (modal: Modal) => void; loading: boolean; - error?: Error; + error?: unknown; }) { return ( <Stack data-testid="api-keys-table" pb="lg"> @@ -111,24 +110,23 @@ export const ManageApiKeys = () => { const [modal, setModal] = useState<Modal>(null); const [activeApiKey, setActiveApiKey] = useState<null | ApiKey>(null); - const [{ value: apiKeys, loading, error }, refreshList] = useAsyncFn( - (): Promise<ApiKey[]> => ApiKeysApi.list(), - [], - ); + const { data: apiKeys, error, isLoading } = useListApiKeyQuery(); - const handleClose = () => setModal(null); + const sortedApiKeys = useMemo(() => { + if (!apiKeys) { + return; + } + return [...apiKeys].sort((a, b) => a.name.localeCompare(b.name)); + }, [apiKeys]); - useEffect(() => { - refreshList(); - }, [refreshList]); + const handleClose = () => setModal(null); - const tableIsEmpty = !loading && !error && apiKeys?.length === 0; + const tableIsEmpty = !isLoading && !error && apiKeys?.length === 0; return ( <> <ApiKeyModals onClose={handleClose} - refreshList={refreshList} modal={modal} activeApiKey={activeApiKey} /> @@ -156,9 +154,9 @@ export const ManageApiKeys = () => { >{t`Create API Key`}</Button> </Group> <ApiKeysTable - loading={loading} + loading={isLoading} error={error} - apiKeys={apiKeys?.sort((a, b) => a.name.localeCompare(b.name))} + apiKeys={sortedApiKeys} setActiveApiKey={setActiveApiKey} setModal={setModal} /> @@ -169,37 +167,23 @@ export const ManageApiKeys = () => { function ApiKeyModals({ onClose, - refreshList, modal, activeApiKey, }: { onClose: () => void; - refreshList: () => void; modal: Modal; activeApiKey: ApiKey | null; }) { if (modal === "create") { - return <CreateApiKeyModal onClose={onClose} refreshList={refreshList} />; + return <CreateApiKeyModal onClose={onClose} />; } if (modal === "edit" && activeApiKey) { - return ( - <EditApiKeyModal - onClose={onClose} - refreshList={refreshList} - apiKey={activeApiKey} - /> - ); + return <EditApiKeyModal onClose={onClose} apiKey={activeApiKey} />; } if (modal === "delete" && activeApiKey) { - return ( - <DeleteApiKeyModal - apiKey={activeApiKey} - onClose={onClose} - refreshList={refreshList} - /> - ); + return <DeleteApiKeyModal apiKey={activeApiKey} onClose={onClose} />; } return null; diff --git a/frontend/src/metabase/api/api-keys.ts b/frontend/src/metabase/api/api-keys.ts new file mode 100644 index 00000000000..922971beed9 --- /dev/null +++ b/frontend/src/metabase/api/api-keys.ts @@ -0,0 +1,56 @@ +import type { + ApiKey, + CreateApiKeyInput, + CreateApiKeyResponse, + UpdateApiKeyInput, + UpdateApiKeyResponse, + RegenerateApiKeyResponse, +} from "metabase-types/api/admin"; + +import { Api } from "./api"; +import { providesList, API_KEY_TAG, API_KEY_LIST_TAG } from "./tags"; + +export const apiKeyApi = Api.injectEndpoints({ + endpoints: builder => ({ + listApiKey: builder.query<ApiKey[], void>({ + query: () => `/api/api-key`, + providesTags: result => providesList(result, API_KEY_TAG), + }), + countApiKey: builder.query<number, void>({ + query: () => `/api/api-key/count`, + }), + createApiKey: builder.mutation<CreateApiKeyResponse, CreateApiKeyInput>({ + query: input => ({ + method: "POST", + url: `/api/api-key`, + body: input, + }), + invalidatesTags: [API_KEY_LIST_TAG], + }), + updateApiKey: builder.mutation<UpdateApiKeyResponse, UpdateApiKeyInput>({ + query: ({ id, ...body }) => ({ + method: "PUT", + url: `/api/api-key/${id}`, + body, + }), + invalidatesTags: [API_KEY_LIST_TAG], + }), + deleteApiKey: builder.mutation<void, ApiKey["id"]>({ + query: id => ({ method: "DELETE", url: `/api/api-key/${id}` }), + invalidatesTags: [API_KEY_LIST_TAG], + }), + regenerateApiKey: builder.mutation<RegenerateApiKeyResponse, ApiKey["id"]>({ + query: id => ({ method: "PUT", url: `/api/api-key/${id}/regenerate` }), + invalidatesTags: [API_KEY_LIST_TAG], + }), + }), +}); + +export const { + useListApiKeyQuery, + useCountApiKeyQuery, + useCreateApiKeyMutation, + useRegenerateApiKeyMutation, + useUpdateApiKeyMutation, + useDeleteApiKeyMutation, +} = apiKeyApi; diff --git a/frontend/src/metabase/api/index.ts b/frontend/src/metabase/api/index.ts index d158c576401..c3dcefbfa2f 100644 --- a/frontend/src/metabase/api/index.ts +++ b/frontend/src/metabase/api/index.ts @@ -1 +1,2 @@ export * from "./api"; +export * from "./api-keys"; diff --git a/frontend/src/metabase/api/tags.ts b/frontend/src/metabase/api/tags.ts index 58c1d8b447b..4b66e0858b6 100644 --- a/frontend/src/metabase/api/tags.ts +++ b/frontend/src/metabase/api/tags.ts @@ -14,5 +14,8 @@ export function providesList< : [listTag]; } -export const tagTypes = []; +export const API_KEY_TAG = "ApiKey" as const; +export const API_KEY_LIST_TAG = getListTag(API_KEY_TAG); + +export const tagTypes = [API_KEY_TAG]; export type TagTypes = typeof tagTypes[number]; -- GitLab