diff --git a/frontend/src/component/api-token/ApiTokenCreate/ApiTokenCreate.tsx b/frontend/src/component/api-token/ApiTokenCreate/ApiTokenCreate.tsx new file mode 100644 index 0000000000..0041dd4c5d --- /dev/null +++ b/frontend/src/component/api-token/ApiTokenCreate/ApiTokenCreate.tsx @@ -0,0 +1,192 @@ +import { TextField, } from '@material-ui/core'; +import classNames from 'classnames'; +import React, { useState, useEffect } from 'react'; +import { styles as commonStyles } from '../../../component/common'; +import { IApiTokenCreate } from '../../../hooks/api/actions/useApiTokensApi/useApiTokensApi'; +import useEnvironments from '../../../hooks/api/getters/useEnvironments/useEnvironments'; +import useProjects from '../../../hooks/api/getters/useProjects/useProjects'; +import Dialogue from '../../common/Dialogue'; +import MySelect from '../../common/select'; +import { useStyles } from './styles'; + +const ALL = '*'; +const TYPE_ADMIN = 'ADMIN'; +const TYPE_CLIENT = 'CLIENT'; + +interface IApiTokenCreateProps { + showDialog: boolean; + closeDialog: () => void; + createToken: (token: IApiTokenCreate) => Promise; +} + +interface IDataError { + username?: string; + general?: string; +} + +const INITIAL_DATA: IApiTokenCreate = { + username: '', + type: TYPE_CLIENT, + project: ALL +} + +const ApiTokenCreate = ({ + showDialog, + closeDialog, + createToken, +}: IApiTokenCreateProps) => { + const styles = useStyles(); + const [data, setData] = useState(INITIAL_DATA); + const [error, setError] = useState({}); + const { projects } = useProjects(); + const { environments } = useEnvironments(); + + useEffect(() => { + if(environments && data.type === TYPE_CLIENT && !data.environment) { + setData({...data, environment: environments[0].name}) + } + }, [data, environments]); + + const clear = () => { + setData({...INITIAL_DATA}); + setError({}); + } + + const onCancel = (e: Event) => { + clear(); + closeDialog(); + }; + + const isValid = () => { + if(!data.username) { + setError({username: 'Username is required.'}); + return false; + } else { + setError({}) + return true; + } + } + + + + const submit = async () => { + if(!isValid()) { + return; + } + + try { + await createToken(data); + clear(); + closeDialog(); + } catch (error) { + setError({general: 'Unable to create new API token'}); + } + + } + + const setType = (event: React.ChangeEvent<{value: string }>) => { + const value = event.target.value; + if(value === TYPE_ADMIN) { + setData({...data, type: value, environment: ALL, project: ALL}) + + } else { + setData({...data, type: value, environment: environments[0].name}) + } + + } + + const setUsername = (event: React.ChangeEvent<{value: string }>) => { + const value = event.target.value; + setData({...data, username: value}) + } + + const setProject = (event: React.ChangeEvent<{value: string }>) => { + const value = event.target.value; + setData({...data, project: value}) + } + + const setEnvironment = (event: React.ChangeEvent<{value: string }>) => { + const value = event.target.value; + setData({...data, environment: value}) + } + + const selectableProjects = [{id: '*', name: 'ALL'}, ...projects].map(i => ({ + key: i.id, + label: i.name, + title: i.name, + })); + + const selectableEnvs = data.type === TYPE_ADMIN ? [{key: '*', label: 'ALL'}] : environments.map(i => ({ + key: i.name, + label: i.name, + title: i.name, + })); + + + const selectableTypes = [ + {key: 'CLIENT', label: 'Client', title: 'Client SDK token'}, + {key: 'ADMIN', label: 'Admin', title: 'Admin API token'} + ] + + return ( + submit()} + open={showDialog} + onClose={onCancel} + primaryButtonText="Create" + secondaryButtonText="Cancel" + title="New API token" + > +
+ + + + + +
+ ); +}; + +export default ApiTokenCreate; diff --git a/frontend/src/page/admin/api/styles.js b/frontend/src/component/api-token/ApiTokenCreate/styles.js similarity index 100% rename from frontend/src/page/admin/api/styles.js rename to frontend/src/component/api-token/ApiTokenCreate/styles.js diff --git a/frontend/src/component/api-token/ApiTokenList/ApiTokenList.styles.ts b/frontend/src/component/api-token/ApiTokenList/ApiTokenList.styles.ts new file mode 100644 index 0000000000..583037a6e4 --- /dev/null +++ b/frontend/src/component/api-token/ApiTokenList/ApiTokenList.styles.ts @@ -0,0 +1,27 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(theme => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + [theme.breakpoints.down('xs')]: { + justifyContent: 'center', + }, + }, + apiError: { + maxWidth: '400px', + marginBottom: '1rem', + }, + cardLink: { + color: 'inherit', + textDecoration: 'none', + border: 'none', + padding: '0', + background: 'transparent', + fontFamily: theme.typography.fontFamily, + pointer: 'cursor', + }, + center: { + textAlign: 'center' + } +})); diff --git a/frontend/src/component/api-token/ApiTokenList/ApiTokenList.tsx b/frontend/src/component/api-token/ApiTokenList/ApiTokenList.tsx new file mode 100644 index 0000000000..1d93351e91 --- /dev/null +++ b/frontend/src/component/api-token/ApiTokenList/ApiTokenList.tsx @@ -0,0 +1,206 @@ +import { useContext, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Button, IconButton, Table, TableBody, TableCell, TableHead, TableRow } from '@material-ui/core'; +import AccessContext from '../../../contexts/AccessContext'; +import useToast from '../../../hooks/useToast'; +import useLoading from '../../../hooks/useLoading'; +import useApiTokens from '../../../hooks/api/getters/useApiTokens/useApiTokens'; +import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; +import useApiTokensApi, { IApiTokenCreate } from '../../../hooks/api/actions/useApiTokensApi/useApiTokensApi'; +import ApiError from '../../common/ApiError/ApiError'; +import PageContent from '../../common/PageContent'; +import HeaderTitle from '../../common/HeaderTitle'; +import ConditionallyRender from '../../common/ConditionallyRender'; +import { CREATE_API_TOKEN, DELETE_API_TOKEN } from '../../AccessProvider/permissions'; +import { useStyles } from './ApiTokenList.styles'; +import { formatDateWithLocale } from '../../common/util'; +import Secret from './secret'; +import { Delete } from '@material-ui/icons'; +import ApiTokenCreate from '../ApiTokenCreate/ApiTokenCreate'; +import Dialogue from '../../common/Dialogue'; + +interface IApiToken { + createdAt: Date; + username: string; + secret: string; + type: string; + project: string; + environment: string; +} + +interface IApiTokenList { + location: any; +} + +const ApiTokenList = ({ location }: IApiTokenList) => { + const styles = useStyles(); + const { hasAccess } = useContext(AccessContext); + const { uiConfig } = useUiConfig(); + const [showDelete, setShowDelete] = useState(false); + const [delToken, setDeleteToken] = useState(); + const { toast, setToastData } = useToast(); + const { tokens, loading, refetch, error } = useApiTokens(); + const { deleteToken, createToken } = useApiTokensApi(); + const ref = useLoading(loading); + + const [showDialog, setDialog] = useState(false); + + const openDialog = () => { + setDialog(true); + }; + + const closeDialog = () => { + setDialog(false); + }; + + + const renderError = () => { + return ( + + ); + }; + + const onCreateToken = async (token: IApiTokenCreate) => { + await createToken(token); + refetch(); + setToastData({ + type: 'success', + show: true, + text: 'Successfully created API token.', + }); + } + + const onDeleteToken = async () => { + if(delToken) { + await deleteToken(delToken.secret); + } + setDeleteToken(undefined); + setShowDelete(false); + refetch(); + setToastData({ + type: 'success', + show: true, + text: 'Successfully deleted API token.', + }); + }; + + const renderProject = (projectId: string) => { + if(!projectId || projectId === '*') { + return projectId; + } else { + return ({projectId}); + } + } + + const renderApiTokens = (tokens: IApiToken[]) => { + return ( + + + + Created + Username + Type + + Project + Environment + } /> + Secret + Action + + + + {tokens.map(item => { + return ( + + + {formatDateWithLocale( + item.createdAt, + location.locale + )} + + + {item.username} + + + {item.type} + + + + {renderProject(item.project)} + + + {item.environment} + + } /> + + + + + + { + setDeleteToken(item); + setShowDelete(true); + } } + > + + + } /> + + ); + })} + +
) + } + + return ( +
+ Create API token} />} />} + > + +
+ No API tokens available.
} + elseShow={renderApiTokens(tokens)} + /> +
+ {toast} + + { + setShowDelete(false); + setDeleteToken(undefined); + }} + title="Confirm deletion" + > +
+ Are you sure you want to delete the following API token?
+
    +
  • username: {delToken?.username}
  • +
  • type: {delToken?.type}
  • +
+
+
+ + + ); +}; + +export default ApiTokenList; diff --git a/frontend/src/page/admin/api/secret.jsx b/frontend/src/component/api-token/ApiTokenList/secret.jsx similarity index 86% rename from frontend/src/page/admin/api/secret.jsx rename to frontend/src/component/api-token/ApiTokenList/secret.jsx index 4d52bad7f1..1bd494438b 100644 --- a/frontend/src/page/admin/api/secret.jsx +++ b/frontend/src/component/api-token/ApiTokenList/secret.jsx @@ -12,9 +12,9 @@ function Secret({ value }) { return (
{show ? ( - + ) : ( - *************************** + ************************************ )} { const styles = useStyles(); diff --git a/frontend/src/hooks/api/actions/useApiTokensApi/useApiTokensApi.ts b/frontend/src/hooks/api/actions/useApiTokensApi/useApiTokensApi.ts new file mode 100644 index 0000000000..12120080de --- /dev/null +++ b/frontend/src/hooks/api/actions/useApiTokensApi/useApiTokensApi.ts @@ -0,0 +1,44 @@ +import useAPI from '../useApi/useApi'; + +export interface IApiTokenCreate { + username: string; + type: string; + project: string; + environment?: string; +} + +const useApiTokensApi = () => { + const { makeRequest, createRequest, errors } = useAPI({ + propagateErrors: true, + }); + + const deleteToken = async (secret: string) => { + const path = `api/admin/api-tokens/${secret}`; + const req = createRequest(path, { method: 'DELETE' }); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + + const createToken = async (newToken: IApiTokenCreate) => { + const path = `api/admin/api-tokens`; + const req = createRequest(path, { method: 'POST', body: JSON.stringify(newToken) }); + + try { + const res = await makeRequest(req.caller, req.id); + + return res; + } catch (e) { + throw e; + } + }; + + return { deleteToken, createToken, errors }; +}; + +export default useApiTokensApi; diff --git a/frontend/src/hooks/api/getters/useApiTokens/useApiTokens.ts b/frontend/src/hooks/api/getters/useApiTokens/useApiTokens.ts new file mode 100644 index 0000000000..7c9b891a73 --- /dev/null +++ b/frontend/src/hooks/api/getters/useApiTokens/useApiTokens.ts @@ -0,0 +1,35 @@ +import useSWR, { mutate } from 'swr'; +import { useState, useEffect } from 'react'; +import { formatApiPath } from '../../../../utils/format-path'; + +const useApiTokens = () => { + const fetcher = async () => { + const path = formatApiPath(`api/admin/api-tokens`); + const res = await fetch(path, { + method: 'GET', + }); + return res.json(); + }; + + const KEY = `api/admin/api-tokens`; + + const { data, error } = useSWR(KEY, fetcher); + const [loading, setLoading] = useState(!error && !data); + + const refetch = () => { + mutate(KEY); + }; + + useEffect(() => { + setLoading(!error && !data); + }, [data, error]); + + return { + tokens: data?.tokens || [], + error, + loading, + refetch, + }; +}; + +export default useApiTokens; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index d7503c20e7..83d0271ce7 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -13,6 +13,7 @@ export interface IUiConfig { export interface IFlags { C: boolean; P: boolean; + E: boolean; } export interface IVersionInfo { diff --git a/frontend/src/page/admin/api/api-key-create.jsx b/frontend/src/page/admin/api/api-key-create.jsx deleted file mode 100644 index 266445ec09..0000000000 --- a/frontend/src/page/admin/api/api-key-create.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { - Select, - TextField, - Button, - MenuItem, - FormControl, - InputLabel, -} from '@material-ui/core'; -import Dialogue from '../../../component/common/Dialogue/Dialogue'; -import classnames from 'classnames'; -import { styles as commonStyles } from '../../../component/common'; -import { useStyles } from './styles'; - -function CreateApiKey({ addKey, show, setShow }) { - const styles = useStyles(); - const [type, setType] = useState('CLIENT'); - const [username, setUsername] = useState(); - const [error, setError] = useState(); - - const toggle = evt => { - evt.preventDefault(); - setShow(!show); - }; - - const submit = async e => { - e.preventDefault(); - if (!username) { - setError('You must define a username'); - return; - } - await addKey({ username, type }); - setUsername(''); - setType('CLIENT'); - setShow(false); - }; - - return ( -
- { - submit(e); - setShow(false); - }} - open={show} - primaryButtonText="Create new key" - onClose={toggle} - secondaryButtonText="Cancel" - title="Add new API key" - > -
- setUsername(e.target.value)} - label="Username" - style={{ width: '200px' }} - error={error !== undefined} - helperText={error} - variant="outlined" - size="small" - /> - - - - - -
- -
- ); -} - -CreateApiKey.propTypes = { - addKey: PropTypes.func.isRequired, - setShow: PropTypes.func.isRequired, - show: PropTypes.bool.isRequired, - toggle: PropTypes.func.isRequired, -}; - -export default CreateApiKey; diff --git a/frontend/src/page/admin/api/api-key-list-container.js b/frontend/src/page/admin/api/api-key-list-container.js deleted file mode 100644 index 1a872a55ad..0000000000 --- a/frontend/src/page/admin/api/api-key-list-container.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; - -import Component from './api-key-list'; -import { fetchApiKeys, removeKey, addKey } from './../../../store/e-api-admin/actions'; -export default connect( - state => ({ - location: state.settings.toJS().location || {}, - unleashUrl: state.uiConfig.toJS().unleashUrl, - keys: state.apiAdmin.toJS(), - }), - { fetchApiKeys, removeKey, addKey } -)(Component); diff --git a/frontend/src/page/admin/api/api-key-list.jsx b/frontend/src/page/admin/api/api-key-list.jsx deleted file mode 100644 index 83c065d898..0000000000 --- a/frontend/src/page/admin/api/api-key-list.jsx +++ /dev/null @@ -1,169 +0,0 @@ -/* eslint-disable no-alert */ -import React, { useContext, useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; -import { - Table, - TableHead, - TableBody, - TableRow, - TableCell, - IconButton, -} from '@material-ui/core'; -import { Delete } from '@material-ui/icons'; -import { Alert } from '@material-ui/lab'; -import { formatFullDateTimeWithLocale } from '../../../component/common/util'; -import CreateApiKey from './api-key-create'; -import Secret from './secret'; -import ConditionallyRender from '../../../component/common/ConditionallyRender/ConditionallyRender'; -import Dialogue from '../../../component/common/Dialogue/Dialogue'; -import AccessContext from '../../../contexts/AccessContext'; -import { - DELETE_API_TOKEN, - CREATE_API_TOKEN, -} from '../../../component/AccessProvider/permissions'; -import PageContent from '../../../component/common/PageContent'; -import HeaderTitle from '../../../component/common/HeaderTitle'; - -function ApiKeyList({ - location, - fetchApiKeys, - removeKey, - addKey, - keys, - unleashUrl, -}) { - const [show, setShow] = useState(false); - - const { hasAccess } = useContext(AccessContext); - const [showDelete, setShowDelete] = useState(false); - const [delKey, setDelKey] = useState(undefined); - const deleteKey = async () => { - await removeKey(delKey); - setDelKey(undefined); - setShowDelete(false); - }; - - useEffect(() => { - fetchApiKeys(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - - } - /> - } - /> - } - > -
- -

- Read the{' '} - - Getting started guide - {' '} - to learn how to connect to the Unleash API from your - application or programmatically. Please note it can take - up to 1 minute before a new API key is activated. -

-
- API URL: {' '} -
{unleashUrl}/api/
-
- -
-
- -
- - - - Created - Username - Access Type - Secret - Action - - - - {keys.map(item => ( - - - {formatFullDateTimeWithLocale( - item.createdAt, - location.locale - )} - - - {item.username} - - - {item.type} - - - - - - { - setDelKey(item.secret); - setShowDelete(true); - }} - > - - - - } - /> - - ))} - -
- { - setShowDelete(false); - setDelKey(undefined); - }} - title="Really delete API key?" - > -
Are you sure you want to delete?
-
-
-
- ); -} - -ApiKeyList.propTypes = { - location: PropTypes.object, - fetchApiKeys: PropTypes.func.isRequired, - removeKey: PropTypes.func.isRequired, - addKey: PropTypes.func.isRequired, - keys: PropTypes.array.isRequired, - unleashUrl: PropTypes.string, -}; - -export default ApiKeyList; diff --git a/frontend/src/page/admin/api/index.js b/frontend/src/page/admin/api/index.js index 6e59440b79..92350ac88b 100644 --- a/frontend/src/page/admin/api/index.js +++ b/frontend/src/page/admin/api/index.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; -import ApiKeyList from './api-key-list-container'; +import ApiTokenList from '../../../component/api-token/ApiTokenList/ApiTokenList'; import AdminMenu from '../admin-menu'; import usePermissions from '../../../hooks/usePermissions'; import ConditionallyRender from '../../../component/common/ConditionallyRender'; -const ApiPage = ({ history }) => { +const ApiPage = ({ history, location }) => { const { isAdmin } = usePermissions(); return ( @@ -14,7 +14,7 @@ const ApiPage = ({ history }) => { condition={isAdmin()} show={} /> - +
); }; @@ -22,6 +22,7 @@ const ApiPage = ({ history }) => { ApiPage.propTypes = { match: PropTypes.object.isRequired, history: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, }; export default ApiPage; diff --git a/frontend/src/store/e-api-admin/actions.js b/frontend/src/store/e-api-admin/actions.js deleted file mode 100644 index da4c423641..0000000000 --- a/frontend/src/store/e-api-admin/actions.js +++ /dev/null @@ -1,40 +0,0 @@ -import api from './api'; -import { dispatchError } from '../util'; -export const RECIEVE_KEYS = 'RECIEVE_KEYS'; -export const ERROR_FETCH_KEYS = 'ERROR_FETCH_KEYS'; -export const REMOVE_KEY = 'REMOVE_KEY'; -export const REMOVE_KEY_ERROR = 'REMOVE_KEY_ERROR'; -export const ADD_KEY = 'ADD_KEY'; -export const ADD_KEY_ERROR = 'ADD_KEY_ERROR'; - -const debug = require('debug')('unleash:e-api-admin-actions'); - -export function fetchApiKeys() { - debug('Start fetching api-keys'); - return dispatch => - api - .fetchAll() - .then(value => - dispatch({ - type: RECIEVE_KEYS, - tokens: value.tokens, - }) - ) - .catch(dispatchError(dispatch, ERROR_FETCH_KEYS)); -} - -export function removeKey(secret) { - return dispatch => - api - .remove(secret) - .then(() => dispatch({ type: REMOVE_KEY, secret })) - .catch(dispatchError(dispatch, REMOVE_KEY)); -} - -export function addKey(data) { - return dispatch => - api - .create(data) - .then(newToken => dispatch({ type: ADD_KEY, token: newToken })) - .catch(dispatchError(dispatch, ADD_KEY_ERROR)); -} diff --git a/frontend/src/store/e-api-admin/api.js b/frontend/src/store/e-api-admin/api.js deleted file mode 100644 index 3c8195d190..0000000000 --- a/frontend/src/store/e-api-admin/api.js +++ /dev/null @@ -1,35 +0,0 @@ -import { formatApiPath } from '../../utils/format-path'; -import { throwIfNotSuccess, headers } from '../api-helper'; - -const URI = formatApiPath('api/admin/api-tokens'); - -function fetchAll() { - return fetch(URI, { headers, credentials: 'include' }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -function create(data) { - return fetch(URI, { - method: 'POST', - headers, - body: JSON.stringify(data), - credentials: 'include', - }) - .then(throwIfNotSuccess) - .then(response => response.json()); -} - -function remove(key) { - return fetch(`${URI}/${key}`, { - method: 'DELETE', - headers, - credentials: 'include', - }).then(throwIfNotSuccess); -} - -export default { - fetchAll, - create, - remove, -}; diff --git a/frontend/src/store/e-api-admin/index.js b/frontend/src/store/e-api-admin/index.js deleted file mode 100644 index 8a526b23cf..0000000000 --- a/frontend/src/store/e-api-admin/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import { List } from 'immutable'; -import { RECIEVE_KEYS, ADD_KEY, REMOVE_KEY } from './actions'; - -const store = (state = new List(), action) => { - switch (action.type) { - case RECIEVE_KEYS: - return new List(action.tokens); - case ADD_KEY: - return state.push(action.token); - case REMOVE_KEY: - return state.filter(v => v.secret !== action.secret); - default: - return state; - } -}; - -export default store; diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 1f6f33e19d..70ecc0b250 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -16,7 +16,6 @@ import uiConfig from './ui-config'; import context from './context'; import projects from './project'; import addons from './addons'; -import apiAdmin from './e-api-admin'; import authAdmin from './e-admin-auth'; import apiCalls from './api-calls'; import invoiceAdmin from './e-admin-invoice'; @@ -40,7 +39,6 @@ const unleashStore = combineReducers({ context, projects, addons, - apiAdmin, authAdmin, apiCalls, invoiceAdmin,