mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-24 01:18:01 +02:00
feat: Add project and environment scoping to API keys (#336)
Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
parent
34df8617d2
commit
1845eb95e6
@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<IDataError>({});
|
||||||
|
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 (
|
||||||
|
<Dialogue
|
||||||
|
onClick={() => submit()}
|
||||||
|
open={showDialog}
|
||||||
|
onClose={onCancel}
|
||||||
|
primaryButtonText="Create"
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
title="New API token"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={submit}
|
||||||
|
className={classNames(
|
||||||
|
styles.addApiKeyForm,
|
||||||
|
commonStyles.contentSpacing
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
value={data.username}
|
||||||
|
name="username"
|
||||||
|
onChange={setUsername}
|
||||||
|
onBlur={isValid}
|
||||||
|
label="Username"
|
||||||
|
style={{ width: '200px' }}
|
||||||
|
error={error.username !== undefined}
|
||||||
|
helperText={error.username}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<MySelect
|
||||||
|
disabled={false}
|
||||||
|
options={selectableTypes}
|
||||||
|
value={data.type}
|
||||||
|
onChange={setType}
|
||||||
|
label="Token Type"
|
||||||
|
id='api_key_type'
|
||||||
|
name="type" className={undefined} classes={undefined}
|
||||||
|
/>
|
||||||
|
<MySelect
|
||||||
|
disabled={data.type === TYPE_ADMIN}
|
||||||
|
options={selectableProjects}
|
||||||
|
value={data.project}
|
||||||
|
onChange={setProject}
|
||||||
|
label="Project"
|
||||||
|
id='api_key_project'
|
||||||
|
name="project" className={undefined} classes={undefined}
|
||||||
|
/>
|
||||||
|
<MySelect
|
||||||
|
disabled={data.type === TYPE_ADMIN}
|
||||||
|
options={selectableEnvs}
|
||||||
|
value={data.environment}
|
||||||
|
required
|
||||||
|
onChange={setEnvironment}
|
||||||
|
label="Environment"
|
||||||
|
id='api_key_environment'
|
||||||
|
name="environment" className={undefined} classes={undefined} />
|
||||||
|
</form>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiTokenCreate;
|
@ -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'
|
||||||
|
}
|
||||||
|
}));
|
206
frontend/src/component/api-token/ApiTokenList/ApiTokenList.tsx
Normal file
206
frontend/src/component/api-token/ApiTokenList/ApiTokenList.tsx
Normal file
@ -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<IApiToken>();
|
||||||
|
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 (
|
||||||
|
<ApiError
|
||||||
|
onClick={refetch}
|
||||||
|
// className={styles.apiError}
|
||||||
|
text="Error fetching api tokens"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (<Link to={`/projects/${projectId}`}>{projectId}</Link>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderApiTokens = (tokens: IApiToken[]) => {
|
||||||
|
return (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Created</TableCell>
|
||||||
|
<TableCell>Username</TableCell>
|
||||||
|
<TableCell className={styles.center}>Type</TableCell>
|
||||||
|
<ConditionallyRender condition={uiConfig.flags.E} show={<>
|
||||||
|
<TableCell className={styles.center}>Project</TableCell>
|
||||||
|
<TableCell className={styles.center}>Environment</TableCell>
|
||||||
|
</>} />
|
||||||
|
<TableCell>Secret</TableCell>
|
||||||
|
<TableCell align="right">Action</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{tokens.map(item => {
|
||||||
|
return (
|
||||||
|
<TableRow key={item.secret}>
|
||||||
|
<TableCell align="left">
|
||||||
|
{formatDateWithLocale(
|
||||||
|
item.createdAt,
|
||||||
|
location.locale
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="left">
|
||||||
|
{item.username}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={styles.center}>
|
||||||
|
{item.type}
|
||||||
|
</TableCell>
|
||||||
|
<ConditionallyRender condition={uiConfig.flags.E} show={<>
|
||||||
|
<TableCell className={styles.center}>
|
||||||
|
{renderProject(item.project)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={styles.center}>
|
||||||
|
{item.environment}
|
||||||
|
</TableCell>
|
||||||
|
</>} />
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Secret value={item.secret} />
|
||||||
|
</TableCell>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={hasAccess(DELETE_API_TOKEN)}
|
||||||
|
show={<TableCell
|
||||||
|
width="20"
|
||||||
|
style={{ textAlign: 'right' }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteToken(item);
|
||||||
|
setShowDelete(true);
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>} />
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<PageContent
|
||||||
|
headerContent={<HeaderTitle
|
||||||
|
title="API Access"
|
||||||
|
actions={<ConditionallyRender
|
||||||
|
condition={hasAccess(CREATE_API_TOKEN)}
|
||||||
|
show={<Button variant="contained" color="primary" onClick={openDialog}>Create API token</Button>} />} />}
|
||||||
|
>
|
||||||
|
<ConditionallyRender condition={error} show={renderError()} />
|
||||||
|
<div className={styles.container}>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={tokens.length < 1 && !loading}
|
||||||
|
show={<div>No API tokens available.</div>}
|
||||||
|
elseShow={renderApiTokens(tokens)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{toast}
|
||||||
|
<ApiTokenCreate showDialog={showDialog} createToken={onCreateToken} closeDialog={closeDialog} />
|
||||||
|
<Dialogue
|
||||||
|
open={showDelete}
|
||||||
|
onClick={onDeleteToken}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDelete(false);
|
||||||
|
setDeleteToken(undefined);
|
||||||
|
}}
|
||||||
|
title="Confirm deletion"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Are you sure you want to delete the following API token?<br />
|
||||||
|
<ul>
|
||||||
|
<li><strong>username</strong>: <code>{delToken?.username}</code></li>
|
||||||
|
<li><strong>type</strong>: <code>{delToken?.type}</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Dialogue>
|
||||||
|
</PageContent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiTokenList;
|
@ -12,9 +12,9 @@ function Secret({ value }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{show ? (
|
{show ? (
|
||||||
<input readOnly value={value} style={{ width: '240px' }} />
|
<input readOnly value={value} style={{ width: '250px' }} />
|
||||||
) : (
|
) : (
|
||||||
<span>***************************</span>
|
<span style={{ width: '250px', display: 'inline-block' }}>************************************</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
@ -16,7 +16,6 @@ const Dialogue = ({
|
|||||||
onClick,
|
onClick,
|
||||||
onClose,
|
onClose,
|
||||||
title,
|
title,
|
||||||
style,
|
|
||||||
primaryButtonText,
|
primaryButtonText,
|
||||||
disabledPrimaryButton = false,
|
disabledPrimaryButton = false,
|
||||||
secondaryButtonText,
|
secondaryButtonText,
|
||||||
|
@ -9,9 +9,9 @@ import { useStyles } from './styles';
|
|||||||
const PageContent = ({
|
const PageContent = ({
|
||||||
children,
|
children,
|
||||||
headerContent,
|
headerContent,
|
||||||
disablePadding,
|
disablePadding = false,
|
||||||
disableBorder,
|
disableBorder = false,
|
||||||
bodyClass,
|
bodyClass = undefined,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
@ -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;
|
35
frontend/src/hooks/api/getters/useApiTokens/useApiTokens.ts
Normal file
35
frontend/src/hooks/api/getters/useApiTokens/useApiTokens.ts
Normal file
@ -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;
|
@ -13,6 +13,7 @@ export interface IUiConfig {
|
|||||||
export interface IFlags {
|
export interface IFlags {
|
||||||
C: boolean;
|
C: boolean;
|
||||||
P: boolean;
|
P: boolean;
|
||||||
|
E: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -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 (
|
|
||||||
<div style={{ margin: '5px' }}>
|
|
||||||
<Dialogue
|
|
||||||
onClick={e => {
|
|
||||||
submit(e);
|
|
||||||
setShow(false);
|
|
||||||
}}
|
|
||||||
open={show}
|
|
||||||
primaryButtonText="Create new key"
|
|
||||||
onClose={toggle}
|
|
||||||
secondaryButtonText="Cancel"
|
|
||||||
title="Add new API key"
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
onSubmit={submit}
|
|
||||||
className={classnames(
|
|
||||||
styles.addApiKeyForm,
|
|
||||||
commonStyles.contentSpacing
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
value={username || ''}
|
|
||||||
name="username"
|
|
||||||
onChange={e => setUsername(e.target.value)}
|
|
||||||
label="Username"
|
|
||||||
style={{ width: '200px' }}
|
|
||||||
error={error !== undefined}
|
|
||||||
helperText={error}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
style={{ minWidth: '120px' }}
|
|
||||||
>
|
|
||||||
<InputLabel id="apikey_type" />
|
|
||||||
<Select
|
|
||||||
labelId="apikey_type"
|
|
||||||
id="apikey_select"
|
|
||||||
value={type}
|
|
||||||
onChange={e => setType(e.target.value)}
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
value="CLIENT"
|
|
||||||
key="apikey_client"
|
|
||||||
title="Client"
|
|
||||||
>
|
|
||||||
Client
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
value="ADMIN"
|
|
||||||
key="apikey_admin"
|
|
||||||
title="Admin"
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</MenuItem>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</form>
|
|
||||||
</Dialogue>
|
|
||||||
<Button onClick={toggle} variant="contained" color="primary">
|
|
||||||
Add new API key
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CreateApiKey.propTypes = {
|
|
||||||
addKey: PropTypes.func.isRequired,
|
|
||||||
setShow: PropTypes.func.isRequired,
|
|
||||||
show: PropTypes.bool.isRequired,
|
|
||||||
toggle: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CreateApiKey;
|
|
@ -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);
|
|
@ -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 (
|
|
||||||
<PageContent
|
|
||||||
headerContent={
|
|
||||||
<HeaderTitle
|
|
||||||
title="API Access"
|
|
||||||
actions={
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={hasAccess(CREATE_API_TOKEN)}
|
|
||||||
show={
|
|
||||||
<CreateApiKey
|
|
||||||
addKey={addKey}
|
|
||||||
setShow={setShow}
|
|
||||||
show={show}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Alert severity="info">
|
|
||||||
<p>
|
|
||||||
Read the{' '}
|
|
||||||
<a
|
|
||||||
href="https://docs.getunleash.io/docs"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Getting started guide
|
|
||||||
</a>{' '}
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<strong>API URL: </strong>{' '}
|
|
||||||
<pre style={{ display: 'inline' }}>{unleashUrl}/api/</pre>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<Table>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Created</TableCell>
|
|
||||||
<TableCell>Username</TableCell>
|
|
||||||
<TableCell>Access Type</TableCell>
|
|
||||||
<TableCell>Secret</TableCell>
|
|
||||||
<TableCell>Action</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{keys.map(item => (
|
|
||||||
<TableRow key={item.secret}>
|
|
||||||
<TableCell style={{ textAlign: 'left' }}>
|
|
||||||
{formatFullDateTimeWithLocale(
|
|
||||||
item.createdAt,
|
|
||||||
location.locale
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'left' }}>
|
|
||||||
{item.username}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'left' }}>
|
|
||||||
{item.type}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'left' }}>
|
|
||||||
<Secret value={item.secret} />
|
|
||||||
</TableCell>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={hasAccess(DELETE_API_TOKEN)}
|
|
||||||
show={
|
|
||||||
<TableCell
|
|
||||||
style={{ textAlign: 'right' }}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
setDelKey(item.secret);
|
|
||||||
setShowDelete(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Delete />
|
|
||||||
</IconButton>
|
|
||||||
</TableCell>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<Dialogue
|
|
||||||
open={showDelete}
|
|
||||||
onClick={deleteKey}
|
|
||||||
onClose={() => {
|
|
||||||
setShowDelete(false);
|
|
||||||
setDelKey(undefined);
|
|
||||||
}}
|
|
||||||
title="Really delete API key?"
|
|
||||||
>
|
|
||||||
<div>Are you sure you want to delete?</div>
|
|
||||||
</Dialogue>
|
|
||||||
</div>
|
|
||||||
</PageContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
@ -1,11 +1,11 @@
|
|||||||
import PropTypes from 'prop-types';
|
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 AdminMenu from '../admin-menu';
|
||||||
import usePermissions from '../../../hooks/usePermissions';
|
import usePermissions from '../../../hooks/usePermissions';
|
||||||
import ConditionallyRender from '../../../component/common/ConditionallyRender';
|
import ConditionallyRender from '../../../component/common/ConditionallyRender';
|
||||||
|
|
||||||
const ApiPage = ({ history }) => {
|
const ApiPage = ({ history, location }) => {
|
||||||
const { isAdmin } = usePermissions();
|
const { isAdmin } = usePermissions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -14,7 +14,7 @@ const ApiPage = ({ history }) => {
|
|||||||
condition={isAdmin()}
|
condition={isAdmin()}
|
||||||
show={<AdminMenu history={history} />}
|
show={<AdminMenu history={history} />}
|
||||||
/>
|
/>
|
||||||
<ApiKeyList />
|
<ApiTokenList location={location} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -22,6 +22,7 @@ const ApiPage = ({ history }) => {
|
|||||||
ApiPage.propTypes = {
|
ApiPage.propTypes = {
|
||||||
match: PropTypes.object.isRequired,
|
match: PropTypes.object.isRequired,
|
||||||
history: PropTypes.object.isRequired,
|
history: PropTypes.object.isRequired,
|
||||||
|
location: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ApiPage;
|
export default ApiPage;
|
||||||
|
@ -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));
|
|
||||||
}
|
|
@ -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,
|
|
||||||
};
|
|
@ -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;
|
|
@ -16,7 +16,6 @@ import uiConfig from './ui-config';
|
|||||||
import context from './context';
|
import context from './context';
|
||||||
import projects from './project';
|
import projects from './project';
|
||||||
import addons from './addons';
|
import addons from './addons';
|
||||||
import apiAdmin from './e-api-admin';
|
|
||||||
import authAdmin from './e-admin-auth';
|
import authAdmin from './e-admin-auth';
|
||||||
import apiCalls from './api-calls';
|
import apiCalls from './api-calls';
|
||||||
import invoiceAdmin from './e-admin-invoice';
|
import invoiceAdmin from './e-admin-invoice';
|
||||||
@ -40,7 +39,6 @@ const unleashStore = combineReducers({
|
|||||||
context,
|
context,
|
||||||
projects,
|
projects,
|
||||||
addons,
|
addons,
|
||||||
apiAdmin,
|
|
||||||
authAdmin,
|
authAdmin,
|
||||||
apiCalls,
|
apiCalls,
|
||||||
invoiceAdmin,
|
invoiceAdmin,
|
||||||
|
Loading…
Reference in New Issue
Block a user