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