Skip to content
Snippets Groups Projects
Unverified Commit b04e1a35 authored by Sloan Sparger's avatar Sloan Sparger Committed by GitHub
Browse files

RTKQuery integration: API Keys (#40043)

parent 6710930a
No related branches found
No related tags found
No related merge requests found
Showing
with 139 additions and 94 deletions
...@@ -14,3 +14,24 @@ export type ApiKey = { ...@@ -14,3 +14,24 @@ export type ApiKey = {
common_name: string; 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;
};
import { useMemo } from "react"; import { useMemo } from "react";
import { useAsync } from "react-use";
import { t } from "ttag"; import { t } from "ttag";
import { useListApiKeyQuery } from "metabase/api";
import AdminContentTable from "metabase/components/AdminContentTable"; import AdminContentTable from "metabase/components/AdminContentTable";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import PaginationControls from "metabase/components/PaginationControls"; import PaginationControls from "metabase/components/PaginationControls";
...@@ -11,7 +11,6 @@ import { isAdminGroup, isDefaultGroup } from "metabase/lib/groups"; ...@@ -11,7 +11,6 @@ import { isAdminGroup, isDefaultGroup } from "metabase/lib/groups";
import { isNotNull } from "metabase/lib/types"; import { isNotNull } from "metabase/lib/types";
import { getFullName } from "metabase/lib/user"; import { getFullName } from "metabase/lib/user";
import { PLUGIN_GROUP_MANAGERS } from "metabase/plugins"; import { PLUGIN_GROUP_MANAGERS } from "metabase/plugins";
import { ApiKeysApi } from "metabase/services";
import { Tooltip, Text, Icon } from "metabase/ui"; import { Tooltip, Text, Icon } from "metabase/ui";
import type { ApiKey, Group, Member, User as IUser } from "metabase-types/api"; import type { ApiKey, Group, Member, User as IUser } from "metabase-types/api";
import type { State } from "metabase-types/store"; import type { State } from "metabase-types/store";
...@@ -59,14 +58,10 @@ function GroupMembersTable({ ...@@ -59,14 +58,10 @@ function GroupMembersTable({
onPreviousPage, onPreviousPage,
reload, reload,
}: GroupMembersTableProps) { }: GroupMembersTableProps) {
const { loading, value: apiKeys } = useAsync(async () => { const { isLoading, data: apiKeys } = useListApiKeyQuery();
const apiKeys = await (ApiKeysApi.list() as Promise<ApiKey[]>); const groupApiKeys = useMemo(() => {
const filteredApiKeys = apiKeys?.filter( return apiKeys?.filter(apiKey => apiKey.group.id === group.id) ?? [];
(apiKey: ApiKey) => apiKey.group.id === group.id, }, [apiKeys, group.id]);
);
return filteredApiKeys ?? [];
}, [group.id]);
// you can't remove people from Default and you can't remove the last user from Admin // you can't remove people from Default and you can't remove the last user from Admin
const isCurrentUser = ({ id }: Partial<IUser>) => id === currentUserId; const isCurrentUser = ({ id }: Partial<IUser>) => id === currentUserId;
...@@ -97,8 +92,8 @@ function GroupMembersTable({ ...@@ -97,8 +92,8 @@ function GroupMembersTable({
[groupMemberships], [groupMemberships],
); );
if (loading) { if (isLoading) {
return <LoadingAndErrorWrapper loading={loading} />; return <LoadingAndErrorWrapper loading={isLoading} />;
} }
return ( return (
...@@ -112,7 +107,7 @@ function GroupMembersTable({ ...@@ -112,7 +107,7 @@ function GroupMembersTable({
onDone={handleAddUser} onDone={handleAddUser}
/> />
)} )}
{apiKeys?.map((apiKey: ApiKey) => ( {groupApiKeys?.map((apiKey: ApiKey) => (
<ApiKeyRow key={`apiKey-${apiKey.id}`} apiKey={apiKey} /> <ApiKeyRow key={`apiKey-${apiKey.id}`} apiKey={apiKey} />
))} ))}
{groupUsers.map((user: IUser) => { {groupUsers.map((user: IUser) => {
......
/* eslint-disable react/prop-types */ /* eslint-disable react/prop-types */
import cx from "classnames"; import cx from "classnames";
import { Component } from "react"; import { Component } from "react";
import { useAsync } from "react-use";
import { jt, t } from "ttag"; import { jt, t } from "ttag";
import _ from "underscore"; import _ from "underscore";
import { useListApiKeyQuery } from "metabase/api";
import AdminContentTable from "metabase/components/AdminContentTable"; import AdminContentTable from "metabase/components/AdminContentTable";
import AdminPaneLayout from "metabase/components/AdminPaneLayout"; import AdminPaneLayout from "metabase/components/AdminPaneLayout";
import Alert from "metabase/components/Alert"; import Alert from "metabase/components/Alert";
...@@ -25,7 +25,6 @@ import { ...@@ -25,7 +25,6 @@ import {
getGroupNameLocalized, getGroupNameLocalized,
} from "metabase/lib/groups"; } from "metabase/lib/groups";
import { KEYCODE_ENTER } from "metabase/lib/keyboard"; import { KEYCODE_ENTER } from "metabase/lib/keyboard";
import { ApiKeysApi } from "metabase/services";
import { Stack, Text, Group, Button, Icon } from "metabase/ui"; import { Stack, Text, Group, Button, Icon } from "metabase/ui";
import { AddRow } from "./AddRow"; import { AddRow } from "./AddRow";
...@@ -277,10 +276,10 @@ function GroupsTable({ ...@@ -277,10 +276,10 @@ function GroupsTable({
onEditGroupCancelClicked, onEditGroupCancelClicked,
onEditGroupDoneClicked, onEditGroupDoneClicked,
}) { }) {
const { loading, value: apiKeys } = useAsync(() => ApiKeysApi.list(), []); const { isLoading, data: apiKeys } = useListApiKeyQuery();
if (loading) { if (isLoading) {
return <LoadingAndErrorWrapper loading={loading} />; return <LoadingAndErrorWrapper loading={isLoading} />;
} }
return ( return (
......
import { useEffect, useState } from "react";
import { t } from "ttag"; import { t } from "ttag";
import { ApiKeysApi } from "metabase/services"; import { useCountApiKeyQuery } from "metabase/api";
import { AuthCardBody } from "./AuthCard/AuthCard"; import { AuthCardBody } from "./AuthCard/AuthCard";
export const ApiKeysAuthCard = () => { export const ApiKeysAuthCard = () => {
const [keyCount, setKeyCount] = useState(0); const { data } = useCountApiKeyQuery();
const keyCount = data ?? 0;
useEffect(() => {
ApiKeysApi.count().then(setKeyCount);
}, []);
const isConfigured = keyCount > 0; const isConfigured = keyCount > 0;
return ( return (
......
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { t } from "ttag"; import { t } from "ttag";
import { useCreateApiKeyMutation } from "metabase/api";
import { import {
Form, Form,
FormErrorMessage, FormErrorMessage,
...@@ -9,30 +10,22 @@ import { ...@@ -9,30 +10,22 @@ import {
FormSubmitButton, FormSubmitButton,
FormTextInput, FormTextInput,
} from "metabase/forms"; } from "metabase/forms";
import { ApiKeysApi } from "metabase/services";
import { Text, Button, Group, Modal, Stack } from "metabase/ui"; import { Text, Button, Group, Modal, Stack } from "metabase/ui";
import { SecretKeyModal } from "./SecretKeyModal"; import { SecretKeyModal } from "./SecretKeyModal";
import { API_KEY_VALIDATION_SCHEMA } from "./utils"; import { API_KEY_VALIDATION_SCHEMA } from "./utils";
export const CreateApiKeyModal = ({ export const CreateApiKeyModal = ({ onClose }: { onClose: () => void }) => {
onClose,
refreshList,
}: {
onClose: () => void;
refreshList: () => void;
}) => {
const [modal, setModal] = useState<"create" | "secretKey">("create"); 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( const handleSubmit = useCallback(
async vals => { async vals => {
const response = await ApiKeysApi.create(vals); await createApiKey(vals);
setSecretKey(response.unmasked_key);
setModal("secretKey"); setModal("secretKey");
refreshList();
}, },
[refreshList], [createApiKey],
); );
if (modal === "secretKey") { if (modal === "secretKey") {
......
import { useCallback } from "react"; import { useCallback } from "react";
import { t } from "ttag"; import { t } from "ttag";
import { useDeleteApiKeyMutation } from "metabase/api";
import { import {
FormProvider, FormProvider,
Form, Form,
FormSubmitButton, FormSubmitButton,
FormErrorMessage, FormErrorMessage,
} from "metabase/forms"; } from "metabase/forms";
import { ApiKeysApi } from "metabase/services";
import { Text, Button, Group, Modal, Stack } from "metabase/ui"; import { Text, Button, Group, Modal, Stack } from "metabase/ui";
import type { ApiKey } from "metabase-types/api"; import type { ApiKey } from "metabase-types/api";
export const DeleteApiKeyModal = ({ export const DeleteApiKeyModal = ({
onClose, onClose,
refreshList,
apiKey, apiKey,
}: { }: {
onClose: () => void; onClose: () => void;
refreshList: () => void;
apiKey: ApiKey; apiKey: ApiKey;
}) => { }) => {
const [deleteApiKey] = useDeleteApiKeyMutation();
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
await ApiKeysApi.delete({ id: apiKey.id }); await deleteApiKey(apiKey.id);
refreshList();
onClose(); onClose();
}, [refreshList, onClose, apiKey.id]); }, [onClose, apiKey.id, deleteApiKey]);
return ( return (
<Modal <Modal
......
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { t } from "ttag"; import { t } from "ttag";
import {
useRegenerateApiKeyMutation,
useUpdateApiKeyMutation,
} from "metabase/api";
import { import {
Form, Form,
FormErrorMessage, FormErrorMessage,
...@@ -9,7 +13,6 @@ import { ...@@ -9,7 +13,6 @@ import {
FormSubmitButton, FormSubmitButton,
FormTextInput, FormTextInput,
} from "metabase/forms"; } from "metabase/forms";
import { ApiKeysApi } from "metabase/services";
import { Button, Group, Modal, Stack, Text } from "metabase/ui"; import { Button, Group, Modal, Stack, Text } from "metabase/ui";
import { getThemeOverrides } from "metabase/ui/theme"; import { getThemeOverrides } from "metabase/ui/theme";
import type { ApiKey } from "metabase-types/api"; import type { ApiKey } from "metabase-types/api";
...@@ -18,25 +21,24 @@ import { SecretKeyModal } from "./SecretKeyModal"; ...@@ -18,25 +21,24 @@ import { SecretKeyModal } from "./SecretKeyModal";
import { API_KEY_VALIDATION_SCHEMA } from "./utils"; import { API_KEY_VALIDATION_SCHEMA } from "./utils";
const { fontFamilyMonospace } = getThemeOverrides(); const { fontFamilyMonospace } = getThemeOverrides();
type EditModalName = "edit" | "regenerate" | "secretKey"; type EditModalName = "edit" | "regenerate" | "secretKey";
const RegenerateKeyModal = ({ const RegenerateKeyModal = ({
apiKey, apiKey,
setModal, setModal,
setSecretKey, setSecretKey,
refreshList,
}: { }: {
apiKey: ApiKey; apiKey: ApiKey;
setModal: (name: EditModalName) => void; setModal: (name: EditModalName) => void;
setSecretKey: (key: string) => void; setSecretKey: (key: string) => void;
refreshList: () => void;
}) => { }) => {
const [regenerateApiKey] = useRegenerateApiKeyMutation();
const handleRegenerate = useCallback(async () => { const handleRegenerate = useCallback(async () => {
const result = await ApiKeysApi.regenerate({ id: apiKey.id }); const result = await regenerateApiKey(apiKey.id).unwrap();
setSecretKey(result.unmasked_key); setSecretKey(result.unmasked_key);
setModal("secretKey"); setModal("secretKey");
refreshList(); }, [apiKey.id, setModal, setSecretKey, regenerateApiKey]);
}, [apiKey.id, refreshList, setModal, setSecretKey]);
return ( return (
<Modal <Modal
...@@ -88,27 +90,25 @@ const RegenerateKeyModal = ({ ...@@ -88,27 +90,25 @@ const RegenerateKeyModal = ({
export const EditApiKeyModal = ({ export const EditApiKeyModal = ({
onClose, onClose,
refreshList,
apiKey, apiKey,
}: { }: {
onClose: () => void; onClose: () => void;
refreshList: () => void;
apiKey: ApiKey; apiKey: ApiKey;
}) => { }) => {
const [modal, setModal] = useState<EditModalName>("edit"); const [modal, setModal] = useState<EditModalName>("edit");
const [secretKey, setSecretKey] = useState<string>(""); const [secretKey, setSecretKey] = useState<string>("");
const [updateApiKey] = useUpdateApiKeyMutation();
const handleSubmit = useCallback( const handleSubmit = useCallback(
async vals => { async vals => {
await ApiKeysApi.edit({ await updateApiKey({
id: vals.id, id: vals.id,
group_id: vals.group_id, group_id: vals.group_id,
name: vals.name, name: vals.name,
}); }).unwrap();
refreshList();
onClose(); onClose();
}, },
[onClose, refreshList], [onClose, updateApiKey],
); );
if (modal === "secretKey") { if (modal === "secretKey") {
...@@ -121,7 +121,6 @@ export const EditApiKeyModal = ({ ...@@ -121,7 +121,6 @@ export const EditApiKeyModal = ({
apiKey={apiKey} apiKey={apiKey}
setModal={setModal} setModal={setModal}
setSecretKey={setSecretKey} setSecretKey={setSecretKey}
refreshList={refreshList}
/> />
); );
} }
......
import { useEffect, useState } from "react"; import { useState, useMemo } from "react";
import { useAsyncFn } from "react-use";
import { t } from "ttag"; import { t } from "ttag";
const { fontFamilyMonospace } = getThemeOverrides(); import { useListApiKeyQuery } from "metabase/api";
import Breadcrumbs from "metabase/components/Breadcrumbs"; import Breadcrumbs from "metabase/components/Breadcrumbs";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { Ellipsified } from "metabase/core/components/Ellipsified"; import { Ellipsified } from "metabase/core/components/Ellipsified";
import { formatDateTimeWithUnit } from "metabase/lib/formatting/date"; import { formatDateTimeWithUnit } from "metabase/lib/formatting/date";
import { ApiKeysApi } from "metabase/services";
import { Stack, Title, Text, Button, Group, Icon } from "metabase/ui"; import { Stack, Title, Text, Button, Group, Icon } from "metabase/ui";
import { getThemeOverrides } from "metabase/ui/theme"; import { getThemeOverrides } from "metabase/ui/theme";
import type { ApiKey } from "metabase-types/api"; import type { ApiKey } from "metabase-types/api";
...@@ -18,6 +15,8 @@ import { DeleteApiKeyModal } from "./DeleteApiKeyModal"; ...@@ -18,6 +15,8 @@ import { DeleteApiKeyModal } from "./DeleteApiKeyModal";
import { EditApiKeyModal } from "./EditApiKeyModal"; import { EditApiKeyModal } from "./EditApiKeyModal";
import { formatMaskedKey } from "./utils"; import { formatMaskedKey } from "./utils";
const { fontFamilyMonospace } = getThemeOverrides();
type Modal = null | "create" | "edit" | "delete"; type Modal = null | "create" | "edit" | "delete";
function EmptyTableWarning({ onCreate }: { onCreate: () => void }) { function EmptyTableWarning({ onCreate }: { onCreate: () => void }) {
...@@ -45,7 +44,7 @@ function ApiKeysTable({ ...@@ -45,7 +44,7 @@ function ApiKeysTable({
setActiveApiKey: (apiKey: ApiKey) => void; setActiveApiKey: (apiKey: ApiKey) => void;
setModal: (modal: Modal) => void; setModal: (modal: Modal) => void;
loading: boolean; loading: boolean;
error?: Error; error?: unknown;
}) { }) {
return ( return (
<Stack data-testid="api-keys-table" pb="lg"> <Stack data-testid="api-keys-table" pb="lg">
...@@ -111,24 +110,23 @@ export const ManageApiKeys = () => { ...@@ -111,24 +110,23 @@ export const ManageApiKeys = () => {
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
const [activeApiKey, setActiveApiKey] = useState<null | ApiKey>(null); const [activeApiKey, setActiveApiKey] = useState<null | ApiKey>(null);
const [{ value: apiKeys, loading, error }, refreshList] = useAsyncFn( const { data: apiKeys, error, isLoading } = useListApiKeyQuery();
(): Promise<ApiKey[]> => ApiKeysApi.list(),
[],
);
const handleClose = () => setModal(null); const sortedApiKeys = useMemo(() => {
if (!apiKeys) {
return;
}
return [...apiKeys].sort((a, b) => a.name.localeCompare(b.name));
}, [apiKeys]);
useEffect(() => { const handleClose = () => setModal(null);
refreshList();
}, [refreshList]);
const tableIsEmpty = !loading && !error && apiKeys?.length === 0; const tableIsEmpty = !isLoading && !error && apiKeys?.length === 0;
return ( return (
<> <>
<ApiKeyModals <ApiKeyModals
onClose={handleClose} onClose={handleClose}
refreshList={refreshList}
modal={modal} modal={modal}
activeApiKey={activeApiKey} activeApiKey={activeApiKey}
/> />
...@@ -156,9 +154,9 @@ export const ManageApiKeys = () => { ...@@ -156,9 +154,9 @@ export const ManageApiKeys = () => {
>{t`Create API Key`}</Button> >{t`Create API Key`}</Button>
</Group> </Group>
<ApiKeysTable <ApiKeysTable
loading={loading} loading={isLoading}
error={error} error={error}
apiKeys={apiKeys?.sort((a, b) => a.name.localeCompare(b.name))} apiKeys={sortedApiKeys}
setActiveApiKey={setActiveApiKey} setActiveApiKey={setActiveApiKey}
setModal={setModal} setModal={setModal}
/> />
...@@ -169,37 +167,23 @@ export const ManageApiKeys = () => { ...@@ -169,37 +167,23 @@ export const ManageApiKeys = () => {
function ApiKeyModals({ function ApiKeyModals({
onClose, onClose,
refreshList,
modal, modal,
activeApiKey, activeApiKey,
}: { }: {
onClose: () => void; onClose: () => void;
refreshList: () => void;
modal: Modal; modal: Modal;
activeApiKey: ApiKey | null; activeApiKey: ApiKey | null;
}) { }) {
if (modal === "create") { if (modal === "create") {
return <CreateApiKeyModal onClose={onClose} refreshList={refreshList} />; return <CreateApiKeyModal onClose={onClose} />;
} }
if (modal === "edit" && activeApiKey) { if (modal === "edit" && activeApiKey) {
return ( return <EditApiKeyModal onClose={onClose} apiKey={activeApiKey} />;
<EditApiKeyModal
onClose={onClose}
refreshList={refreshList}
apiKey={activeApiKey}
/>
);
} }
if (modal === "delete" && activeApiKey) { if (modal === "delete" && activeApiKey) {
return ( return <DeleteApiKeyModal apiKey={activeApiKey} onClose={onClose} />;
<DeleteApiKeyModal
apiKey={activeApiKey}
onClose={onClose}
refreshList={refreshList}
/>
);
} }
return null; return null;
......
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;
export * from "./api"; export * from "./api";
export * from "./api-keys";
...@@ -14,5 +14,8 @@ export function providesList< ...@@ -14,5 +14,8 @@ export function providesList<
: [listTag]; : [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]; export type TagTypes = typeof tagTypes[number];
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment