Skip to content

Commit

Permalink
[Search] [Onboarding] Search api key refactor (elastic#199790)
Browse files Browse the repository at this point in the history
Refactor search api key
- get rid from useReduces
- simplify logic in provider
- create request hooks
- fix multiple initialization

---------

Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
2 people authored and CAWilson94 committed Dec 12, 2024
1 parent 35d3f4f commit 1705075
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 167 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,31 @@ import { ApiKeyFlyoutWrapper } from './api_key_flyout_wrapper';
import { useSearchApiKey } from '../hooks/use_search_api_key';
import { Status } from '../constants';

const API_KEY_MASK = '•'.repeat(60);

interface ApiKeyFormProps {
hasTitle?: boolean;
}

export const ApiKeyForm: React.FC<ApiKeyFormProps> = ({ hasTitle = true }) => {
const [showFlyout, setShowFlyout] = useState(false);
const { apiKey, status, updateApiKey, toggleApiKeyVisibility, displayedApiKey, apiKeyIsVisible } =
useSearchApiKey();
const { apiKey, status, updateApiKey, toggleApiKeyVisibility } = useSearchApiKey();

const titleLocale = i18n.translate('searchApiKeysComponents.apiKeyForm.title', {
defaultMessage: 'API Key',
});

if (apiKey && displayedApiKey) {
if (apiKey) {
return (
<FormInfoField
label={hasTitle ? titleLocale : undefined}
value={displayedApiKey}
value={status === Status.showPreviewKey ? apiKey : API_KEY_MASK}
copyValue={apiKey}
dataTestSubj="apiKeyFormAPIKey"
copyValueDataTestSubj="APIKeyButtonCopy"
actions={[
<EuiButtonIcon
iconType={apiKeyIsVisible ? 'eyeClosed' : 'eye'}
iconType={status === Status.showPreviewKey ? 'eyeClosed' : 'eye'}
color="text"
onClick={toggleApiKeyVisibility}
data-test-subj="showAPIKeyButton"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { useMutation } from '@tanstack/react-query';
import type { APIKeyCreationResponse } from '@kbn/search-api-keys-server/types';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { APIRoutes } from '../types';

export const useCreateApiKey = ({
onSuccess,
onError,
}: {
onSuccess(key: APIKeyCreationResponse): void;
onError(err: XMLHttpRequest): void;
}) => {
const { http } = useKibana().services;
const { mutateAsync: createApiKey } = useMutation<APIKeyCreationResponse | undefined>({
mutationFn: async () => {
try {
if (!http?.post) {
throw new Error('HTTP service is unavailable');
}

return await http.post<APIKeyCreationResponse>(APIRoutes.API_KEYS);
} catch (err) {
onError(err);
}
},
onSuccess: (receivedApiKey) => {
if (receivedApiKey) {
onSuccess(receivedApiKey);
}
},
});

return createApiKey;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { useMutation } from '@tanstack/react-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { APIRoutes } from '../types';

export const useValidateApiKey = (): ((id: string) => Promise<boolean>) => {
const { http } = useKibana().services;
const { mutateAsync: validateApiKey } = useMutation(async (id: string) => {
try {
if (!http?.post) {
throw new Error('HTTP service is unavailable');
}

const response = await http.post<{ isValid: boolean }>(APIRoutes.API_KEY_VALIDITY, {
body: JSON.stringify({ id }),
});

return response.isValid;
} catch (err) {
return false;
}
});

return validateApiKey;
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,176 +7,109 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React, { useCallback, useReducer, createContext, useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { APIKeyCreationResponse } from '@kbn/search-api-keys-server/types';
import { APIRoutes } from '../types';
import React, { useCallback, createContext, useState, useMemo, useRef } from 'react';
import { useCreateApiKey } from '../hooks/use_create_api_key';
import { Status } from '../constants';
import { useValidateApiKey } from '../hooks/use_validate_api_key';

const API_KEY_STORAGE_KEY = 'searchApiKey';
const API_KEY_MASK = '•'.repeat(60);

interface ApiKeyState {
status: Status;
apiKey: string | null;
}

interface APIKeyContext {
displayedApiKey: string | null;
apiKey: string | null;
toggleApiKeyVisibility: () => void;
updateApiKey: ({ id, encoded }: { id: string; encoded: string }) => void;
status: Status;
apiKeyIsVisible: boolean;
initialiseKey: () => void;
}

type Action =
| { type: 'SET_API_KEY'; apiKey: string; status: Status }
| { type: 'SET_STATUS'; status: Status }
| { type: 'CLEAR_API_KEY' }
| { type: 'TOGGLE_API_KEY_VISIBILITY' };

const initialState: ApiKeyState = {
apiKey: null,
status: Status.uninitialized,
};

const reducer = (state: ApiKeyState, action: Action): ApiKeyState => {
switch (action.type) {
case 'SET_API_KEY':
return { ...state, apiKey: action.apiKey, status: action.status };
case 'SET_STATUS':
return { ...state, status: action.status };
case 'TOGGLE_API_KEY_VISIBILITY':
return {
...state,
status:
state.status === Status.showHiddenKey ? Status.showPreviewKey : Status.showHiddenKey,
};
case 'CLEAR_API_KEY':
return { ...state, apiKey: null, status: Status.showCreateButton };
default:
return state;
}
};

export const ApiKeyContext = createContext<APIKeyContext>({
displayedApiKey: null,
apiKey: null,
toggleApiKeyVisibility: () => {},
updateApiKey: () => {},
status: Status.uninitialized,
apiKeyIsVisible: false,
initialiseKey: () => {},
});

export const SearchApiKeyProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const { http } = useKibana().services;
const [state, dispatch] = useReducer(reducer, initialState);

const isInitialising = useRef(false);
const [apiKey, setApiKey] = useState<string | null>(null);
const [status, setStatus] = useState<Status>(Status.uninitialized);
const updateApiKey = useCallback(({ id, encoded }: { id: string; encoded: string }) => {
sessionStorage.setItem(API_KEY_STORAGE_KEY, JSON.stringify({ id, encoded }));
dispatch({ type: 'SET_API_KEY', apiKey: encoded, status: Status.showHiddenKey });
}, []);
const handleShowKeyVisibility = useCallback(() => {
dispatch({ type: 'TOGGLE_API_KEY_VISIBILITY' });
setApiKey(encoded);
setStatus(Status.showHiddenKey);
}, []);
const initialiseKey = useCallback(() => {
dispatch({ type: 'SET_STATUS', status: Status.loading });
const toggleApiKeyVisibility = useCallback(() => {
setStatus((prevStatus) =>
prevStatus === Status.showHiddenKey ? Status.showPreviewKey : Status.showHiddenKey
);
}, []);
const { mutateAsync: validateApiKey } = useMutation(async (id: string) => {
try {
if (!http?.post) {
throw new Error('HTTP service is unavailable');
}

const response = await http.post<{ isValid: boolean }>(APIRoutes.API_KEY_VALIDITY, {
body: JSON.stringify({ id }),
});

return response.isValid;
} catch (err) {
return false;
}
});
const { mutateAsync: createApiKey } = useMutation<APIKeyCreationResponse | undefined>({
mutationFn: async () => {
try {
if (!http?.post) {
throw new Error('HTTP service is unavailable');
}

return await http.post<APIKeyCreationResponse>(APIRoutes.API_KEYS);
} catch (err) {
if (err.response?.status === 400) {
dispatch({ type: 'SET_STATUS', status: Status.showCreateButton });
} else if (err.response?.status === 403) {
dispatch({ type: 'SET_STATUS', status: Status.showUserPrivilegesError });
} else {
throw err;
}
}
},
const validateApiKey = useValidateApiKey();
const createApiKey = useCreateApiKey({
onSuccess: (receivedApiKey) => {
if (receivedApiKey) {
sessionStorage.setItem(
API_KEY_STORAGE_KEY,
JSON.stringify({ id: receivedApiKey.id, encoded: receivedApiKey.encoded })
);
dispatch({
type: 'SET_API_KEY',
apiKey: receivedApiKey.encoded,
status: Status.showHiddenKey,
});
setApiKey(receivedApiKey.encoded);
setStatus(Status.showHiddenKey);
}
},
onError: (err) => {
if (err.response?.status === 400) {
setStatus(Status.showCreateButton);
} else if (err.response?.status === 403) {
setStatus(Status.showUserPrivilegesError);
} else {
throw err;
}
},
});
const initialiseKey = useCallback(async () => {
if (status !== Status.uninitialized || isInitialising.current) {
return;
}

useEffect(() => {
const initialiseApiKey = async () => {
try {
if (state.status === Status.loading) {
const storedKey = sessionStorage.getItem(API_KEY_STORAGE_KEY);
isInitialising.current = true;

try {
setStatus(Status.loading);
const storedKey = sessionStorage.getItem(API_KEY_STORAGE_KEY);

if (storedKey) {
const { id, encoded } = JSON.parse(storedKey);
if (storedKey) {
const { id, encoded } = JSON.parse(storedKey);

if (await validateApiKey(id)) {
dispatch({
type: 'SET_API_KEY',
apiKey: encoded,
status: Status.showHiddenKey,
});
} else {
sessionStorage.removeItem(API_KEY_STORAGE_KEY);
dispatch({
type: 'CLEAR_API_KEY',
});
await createApiKey();
}
} else {
await createApiKey();
}
if (await validateApiKey(id)) {
setApiKey(encoded);
setStatus(Status.showHiddenKey);
} else {
sessionStorage.removeItem(API_KEY_STORAGE_KEY);
setApiKey(null);
setStatus(Status.showCreateButton);
await createApiKey();
}
} catch (e) {
dispatch({ type: 'CLEAR_API_KEY' });
} else {
await createApiKey();
}
};

initialiseApiKey();
}, [state.status, createApiKey, validateApiKey]);

const value: APIKeyContext = {
displayedApiKey: state.status === Status.showPreviewKey ? state.apiKey : API_KEY_MASK,
apiKey: state.apiKey,
toggleApiKeyVisibility: handleShowKeyVisibility,
updateApiKey,
status: state.status,
apiKeyIsVisible: state.status === Status.showPreviewKey,
initialiseKey,
};
} catch (e) {
setApiKey(null);
setStatus(Status.showCreateButton);
} finally {
isInitialising.current = false;
}
}, [status, createApiKey, validateApiKey]);

const value: APIKeyContext = useMemo(
() => ({
apiKey,
toggleApiKeyVisibility,
updateApiKey,
status,
initialiseKey,
}),
[apiKey, status, toggleApiKeyVisibility, updateApiKey, initialiseKey]
);

return <ApiKeyContext.Provider value={value}>{children}</ApiKeyContext.Provider>;
};
8 changes: 7 additions & 1 deletion packages/kbn-search-api-keys-server/src/lib/privileges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ export async function fetchUserStartPrivileges(
// and can also have permissions for index monitoring
const securityCheck = await client.security.hasPrivileges({
cluster: ['manage'],
index: [
{
names: ['*'],
privileges: ['read', 'write'],
},
],
});

return securityCheck?.cluster?.manage ?? false;
return securityCheck.has_all_requested ?? false;
} catch (e) {
logger.error(`Error checking user privileges for search API Keys`);
logger.error(e);
Expand Down
Loading

0 comments on commit 1705075

Please sign in to comment.