1
0
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:
Ivar Conradi Østhus 2021-09-26 22:41:38 +02:00 committed by GitHub
parent 34df8617d2
commit 1845eb95e6
18 changed files with 514 additions and 398 deletions

View File

@ -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;

View File

@ -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'
}
}));

View 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;

View File

@ -12,9 +12,9 @@ function Secret({ value }) {
return (
<div>
{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

View File

@ -16,7 +16,6 @@ const Dialogue = ({
onClick,
onClose,
title,
style,
primaryButtonText,
disabledPrimaryButton = false,
secondaryButtonText,

View File

@ -9,9 +9,9 @@ import { useStyles } from './styles';
const PageContent = ({
children,
headerContent,
disablePadding,
disableBorder,
bodyClass,
disablePadding = false,
disableBorder = false,
bodyClass = undefined,
...rest
}) => {
const styles = useStyles();

View File

@ -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;

View 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;

View File

@ -13,6 +13,7 @@ export interface IUiConfig {
export interface IFlags {
C: boolean;
P: boolean;
E: boolean;
}
export interface IVersionInfo {

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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={<AdminMenu history={history} />}
/>
<ApiKeyList />
<ApiTokenList location={location} />
</div>
);
};
@ -22,6 +22,7 @@ const ApiPage = ({ history }) => {
ApiPage.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
};
export default ApiPage;

View File

@ -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));
}

View File

@ -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,
};

View File

@ -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;

View File

@ -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,