mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
Feat/project api token permissions (#3065)
<!-- Thanks for creating a PR! To make it easier for reviewers and
everyone else to understand what your changes relate to, please add some
relevant content to the headings below. Feel free to ignore or delete
sections that you don't think are relevant. Thank you! ❤️ -->
## About the changes
<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->
Define and implements Project api token permissions
Assign permissions to existing roles
Adjust UI to support them
Adjust BE to implement
---------
Signed-off-by: andreas-unleash <andreas@getunleash.ai>
Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
parent
683bf2faba
commit
350b55644a
@ -17,7 +17,7 @@ import {
|
||||
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender';
|
||||
import { SelectAllButton } from '../../../admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton';
|
||||
import { SelectAllButton } from '../../../admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectAllButton/SelectAllButton';
|
||||
import {
|
||||
StyledHelpText,
|
||||
StyledSelectAllFormControlLabel,
|
||||
|
@ -0,0 +1,44 @@
|
||||
import { Box, Button, styled } from '@mui/material';
|
||||
import Input from '../../../common/Input/Input';
|
||||
import GeneralSelect from '../../../common/GeneralSelect/GeneralSelect';
|
||||
|
||||
export const StyledContainer = styled('div')(() => ({
|
||||
maxWidth: '400px',
|
||||
}));
|
||||
|
||||
export const StyledForm = styled('form')(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}));
|
||||
|
||||
export const StyledInput = styled(Input)(({ theme }) => ({
|
||||
width: '100%',
|
||||
marginBottom: theme.spacing(2),
|
||||
}));
|
||||
|
||||
export const StyledSelectInput = styled(GeneralSelect)(({ theme }) => ({
|
||||
marginBottom: theme.spacing(2),
|
||||
minWidth: '400px',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
minWidth: '379px',
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledInputDescription = styled('p')(({ theme }) => ({
|
||||
marginBottom: theme.spacing(1),
|
||||
}));
|
||||
|
||||
export const StyledInputLabel = styled('label')(({ theme }) => ({
|
||||
marginBottom: theme.spacing(1),
|
||||
}));
|
||||
|
||||
export const CancelButton = styled(Button)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(3),
|
||||
}));
|
||||
|
||||
export const StyledBox = styled(Box)({
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
});
|
@ -1,140 +1,23 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Link,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
styled,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { KeyboardArrowDownOutlined } from '@mui/icons-material';
|
||||
import React from 'react';
|
||||
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
|
||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||
import Input from 'component/common/Input/Input';
|
||||
import { Alert, Link } from '@mui/material';
|
||||
import React, { ReactNode } from 'react';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput';
|
||||
import { ApiTokenFormErrorType } from './useApiTokenForm';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { TokenType } from 'interfaces/token';
|
||||
import { CancelButton, StyledBox, StyledForm } from './ApiTokenForm.styles';
|
||||
|
||||
interface IApiTokenFormProps {
|
||||
username: string;
|
||||
type: string;
|
||||
projects: string[];
|
||||
environment?: string;
|
||||
setTokenType: (value: string) => void;
|
||||
setUsername: React.Dispatch<React.SetStateAction<string>>;
|
||||
setProjects: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setEnvironment: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
handleSubmit: (e: any) => void;
|
||||
handleCancel: () => void;
|
||||
errors: { [key: string]: string };
|
||||
mode: 'Create' | 'Edit';
|
||||
clearErrors: (error?: ApiTokenFormErrorType) => void;
|
||||
disableProjectSelection?: boolean;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
const StyledContainer = styled('div')(() => ({
|
||||
maxWidth: '400px',
|
||||
}));
|
||||
|
||||
const StyledForm = styled('form')(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}));
|
||||
|
||||
const StyledInput = styled(Input)(({ theme }) => ({
|
||||
width: '100%',
|
||||
marginBottom: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledSelectInput = styled(GeneralSelect)(({ theme }) => ({
|
||||
marginBottom: theme.spacing(2),
|
||||
minWidth: '400px',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
minWidth: '379px',
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledInputDescription = styled('p')(({ theme }) => ({
|
||||
marginBottom: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledInputLabel = styled('label')(({ theme }) => ({
|
||||
marginBottom: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const CancelButton = styled(Button)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(3),
|
||||
}));
|
||||
|
||||
const StyledBox = styled(Box)({
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
});
|
||||
|
||||
const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
|
||||
children,
|
||||
username,
|
||||
type,
|
||||
projects,
|
||||
disableProjectSelection = false,
|
||||
environment,
|
||||
setUsername,
|
||||
setTokenType,
|
||||
setProjects,
|
||||
setEnvironment,
|
||||
actions,
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
errors,
|
||||
clearErrors,
|
||||
}) => {
|
||||
const { uiConfig } = useUiConfig();
|
||||
const { environments } = useEnvironments();
|
||||
const { projects: availableProjects } = useProjects();
|
||||
|
||||
const selectableTypes = [
|
||||
{
|
||||
key: TokenType.CLIENT,
|
||||
label: `Server-side SDK (${TokenType.CLIENT})`,
|
||||
title: 'Connect server-side SDK or Unleash Proxy',
|
||||
},
|
||||
{
|
||||
key: TokenType.ADMIN,
|
||||
label: TokenType.ADMIN,
|
||||
title: 'Full access for managing Unleash',
|
||||
},
|
||||
];
|
||||
|
||||
if (uiConfig.flags.embedProxyFrontend) {
|
||||
selectableTypes.splice(1, 0, {
|
||||
key: TokenType.FRONTEND,
|
||||
label: `Client-side SDK (${TokenType.FRONTEND})`,
|
||||
title: 'Connect web and mobile SDK directly to Unleash',
|
||||
});
|
||||
}
|
||||
|
||||
const selectableProjects = availableProjects.map(project => ({
|
||||
value: project.id,
|
||||
label: project.name,
|
||||
}));
|
||||
|
||||
const selectableEnvs =
|
||||
type === TokenType.ADMIN
|
||||
? [{ key: '*', label: 'ALL' }]
|
||||
: environments.map(environment => ({
|
||||
key: environment.name,
|
||||
label: environment.name,
|
||||
title: environment.name,
|
||||
disabled: !environment.enabled,
|
||||
}));
|
||||
|
||||
const isUnleashCloud = Boolean(uiConfig?.flags?.UNLEASH_CLOUD);
|
||||
|
||||
@ -152,93 +35,9 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
|
||||
</Alert>
|
||||
}
|
||||
/>
|
||||
<StyledContainer>
|
||||
<StyledInputDescription>
|
||||
What would you like to call this token?
|
||||
</StyledInputDescription>
|
||||
<StyledInput
|
||||
value={username}
|
||||
name="username"
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
label="Token name"
|
||||
error={errors.username !== undefined}
|
||||
errorText={errors.username}
|
||||
onFocus={() => clearErrors('username')}
|
||||
autoFocus
|
||||
/>
|
||||
<FormControl sx={{ mb: 2, width: '100%' }}>
|
||||
<StyledInputLabel id="token-type">
|
||||
What do you want to connect?
|
||||
</StyledInputLabel>
|
||||
<RadioGroup
|
||||
aria-labelledby="token-type"
|
||||
defaultValue="CLIENT"
|
||||
name="radio-buttons-group"
|
||||
value={type}
|
||||
onChange={(event, value) => setTokenType(value)}
|
||||
>
|
||||
{selectableTypes.map(({ key, label, title }) => (
|
||||
<FormControlLabel
|
||||
key={key}
|
||||
value={key}
|
||||
sx={{ mb: 1 }}
|
||||
control={
|
||||
<Radio
|
||||
sx={{
|
||||
ml: 0.75,
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Box>
|
||||
<Typography>{label}</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
{!Boolean(disableProjectSelection) && (
|
||||
<>
|
||||
<StyledInputDescription>
|
||||
Which project do you want to give access to?
|
||||
</StyledInputDescription>
|
||||
<SelectProjectInput
|
||||
disabled={type === TokenType.ADMIN}
|
||||
options={selectableProjects}
|
||||
defaultValue={projects}
|
||||
onChange={setProjects}
|
||||
error={errors?.projects}
|
||||
onFocus={() => clearErrors('projects')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<StyledInputDescription>
|
||||
Which environment should the token have access to?
|
||||
</StyledInputDescription>
|
||||
<StyledSelectInput
|
||||
disabled={type === TokenType.ADMIN}
|
||||
options={selectableEnvs}
|
||||
value={environment}
|
||||
onChange={setEnvironment}
|
||||
label="Environment"
|
||||
id="api_key_environment"
|
||||
name="environment"
|
||||
IconComponent={KeyboardArrowDownOutlined}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledContainer>
|
||||
{children}
|
||||
<StyledBox>
|
||||
{children}
|
||||
{actions}
|
||||
<CancelButton onClick={handleCancel}>Cancel</CancelButton>
|
||||
</StyledBox>
|
||||
</StyledForm>
|
||||
|
@ -0,0 +1,49 @@
|
||||
import { TokenType } from '../../../../../interfaces/token';
|
||||
import { KeyboardArrowDownOutlined } from '@mui/icons-material';
|
||||
import React from 'react';
|
||||
import {
|
||||
StyledInputDescription,
|
||||
StyledSelectInput,
|
||||
} from '../ApiTokenForm.styles';
|
||||
import { useEnvironments } from '../../../../../hooks/api/getters/useEnvironments/useEnvironments';
|
||||
|
||||
interface IEnvironmentSelectorProps {
|
||||
type: string;
|
||||
environment?: string;
|
||||
setEnvironment: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}
|
||||
export const EnvironmentSelector = ({
|
||||
type,
|
||||
environment,
|
||||
setEnvironment,
|
||||
}: IEnvironmentSelectorProps) => {
|
||||
const { environments } = useEnvironments();
|
||||
const selectableEnvs =
|
||||
type === TokenType.ADMIN
|
||||
? [{ key: '*', label: 'ALL' }]
|
||||
: environments.map(environment => ({
|
||||
key: environment.name,
|
||||
label: environment.name,
|
||||
title: environment.name,
|
||||
disabled: !environment.enabled,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledInputDescription>
|
||||
Which environment should the token have access to?
|
||||
</StyledInputDescription>
|
||||
<StyledSelectInput
|
||||
disabled={type === TokenType.ADMIN}
|
||||
options={selectableEnvs}
|
||||
value={environment}
|
||||
onChange={setEnvironment}
|
||||
label="Environment"
|
||||
id="api_key_environment"
|
||||
name="environment"
|
||||
IconComponent={KeyboardArrowDownOutlined}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,50 @@
|
||||
import { SelectProjectInput } from './SelectProjectInput/SelectProjectInput';
|
||||
import { TokenType } from '../../../../../interfaces/token';
|
||||
import React from 'react';
|
||||
import { StyledInputDescription } from '../ApiTokenForm.styles';
|
||||
import useProjects from '../../../../../hooks/api/getters/useProjects/useProjects';
|
||||
import { ApiTokenFormErrorType } from '../useApiTokenForm';
|
||||
import { useOptionalPathParam } from '../../../../../hooks/useOptionalPathParam';
|
||||
|
||||
interface IProjectSelectorProps {
|
||||
type: string;
|
||||
projects: string[];
|
||||
setProjects: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
errors: { [key: string]: string };
|
||||
clearErrors: (error?: ApiTokenFormErrorType) => void;
|
||||
}
|
||||
export const ProjectSelector = ({
|
||||
type,
|
||||
projects,
|
||||
setProjects,
|
||||
errors,
|
||||
clearErrors,
|
||||
}: IProjectSelectorProps) => {
|
||||
const projectId = useOptionalPathParam('projectId');
|
||||
const { projects: availableProjects } = useProjects();
|
||||
|
||||
const selectableProjects = availableProjects.map(project => ({
|
||||
value: project.id,
|
||||
label: project.name,
|
||||
}));
|
||||
|
||||
if (projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledInputDescription>
|
||||
Which project do you want to give access to?
|
||||
</StyledInputDescription>
|
||||
<SelectProjectInput
|
||||
disabled={type === TokenType.ADMIN}
|
||||
options={selectableProjects}
|
||||
defaultValue={projects}
|
||||
onChange={setProjects}
|
||||
error={errors?.projects}
|
||||
onFocus={() => clearErrors('projects')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { StyledInput, StyledInputDescription } from '../ApiTokenForm.styles';
|
||||
import { ApiTokenFormErrorType } from '../useApiTokenForm';
|
||||
|
||||
interface ITokenInfoProps {
|
||||
username: string;
|
||||
setUsername: React.Dispatch<React.SetStateAction<string>>;
|
||||
|
||||
errors: { [key: string]: string };
|
||||
clearErrors: (error?: ApiTokenFormErrorType) => void;
|
||||
}
|
||||
export const TokenInfo = ({
|
||||
username,
|
||||
setUsername,
|
||||
errors,
|
||||
clearErrors,
|
||||
}: ITokenInfoProps) => {
|
||||
return (
|
||||
<>
|
||||
<StyledInputDescription>
|
||||
What would you like to call this token?
|
||||
</StyledInputDescription>
|
||||
<StyledInput
|
||||
value={username}
|
||||
name="username"
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
label="Token name"
|
||||
error={errors.username !== undefined}
|
||||
errorText={errors.username}
|
||||
onFocus={() => clearErrors('username')}
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,94 @@
|
||||
import { StyledContainer, StyledInputLabel } from '../ApiTokenForm.styles';
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import React from 'react';
|
||||
import { TokenType } from '../../../../../interfaces/token';
|
||||
import useUiConfig from '../../../../../hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { useOptionalPathParam } from '../../../../../hooks/useOptionalPathParam';
|
||||
|
||||
interface ITokenTypeSelectorProps {
|
||||
type: string;
|
||||
setType: (value: string) => void;
|
||||
}
|
||||
export const TokenTypeSelector = ({
|
||||
type,
|
||||
setType,
|
||||
}: ITokenTypeSelectorProps) => {
|
||||
const projectId = useOptionalPathParam('projectId');
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const selectableTypes = [
|
||||
{
|
||||
key: TokenType.CLIENT,
|
||||
label: `Server-side SDK (${TokenType.CLIENT})`,
|
||||
title: 'Connect server-side SDK or Unleash Proxy',
|
||||
},
|
||||
];
|
||||
|
||||
if (!projectId) {
|
||||
selectableTypes.push({
|
||||
key: TokenType.ADMIN,
|
||||
label: TokenType.ADMIN,
|
||||
title: 'Full access for managing Unleash',
|
||||
});
|
||||
}
|
||||
|
||||
if (uiConfig.flags.embedProxyFrontend) {
|
||||
selectableTypes.splice(1, 0, {
|
||||
key: TokenType.FRONTEND,
|
||||
label: `Client-side SDK (${TokenType.FRONTEND})`,
|
||||
title: 'Connect web and mobile SDK directly to Unleash',
|
||||
});
|
||||
}
|
||||
return (
|
||||
<StyledContainer>
|
||||
<FormControl sx={{ mb: 2, width: '100%' }}>
|
||||
<StyledInputLabel id="token-type">
|
||||
What do you want to connect?
|
||||
</StyledInputLabel>
|
||||
<RadioGroup
|
||||
aria-labelledby="token-type"
|
||||
defaultValue="CLIENT"
|
||||
name="radio-buttons-group"
|
||||
value={type}
|
||||
onChange={(event, value) => setType(value)}
|
||||
>
|
||||
{selectableTypes.map(({ key, label, title }) => (
|
||||
<FormControlLabel
|
||||
key={key}
|
||||
value={key}
|
||||
sx={{ mb: 1 }}
|
||||
control={
|
||||
<Radio
|
||||
sx={{
|
||||
ml: 0.75,
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Box>
|
||||
<Typography>{label}</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
@ -4,14 +4,16 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
|
||||
import { READ_API_TOKEN } from 'component/providers/AccessProvider/permissions';
|
||||
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
||||
import { ApiTokenTable } from 'component/admin/apiToken/ApiTokenTable/ApiTokenTable';
|
||||
import { useApiTokens } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||
|
||||
export const ApiTokenPage = () => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { tokens, loading } = useApiTokens();
|
||||
|
||||
return (
|
||||
<ConditionallyRender
|
||||
condition={hasAccess(READ_API_TOKEN)}
|
||||
show={() => <ApiTokenTable />}
|
||||
show={() => <ApiTokenTable tokens={tokens} loading={loading} />}
|
||||
elseShow={() => <AdminAlert />}
|
||||
/>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useApiTokens } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||
import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||
import { useGlobalFilter, useSortBy, useTable } from 'react-table';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import {
|
||||
@ -35,32 +35,100 @@ const hiddenColumnsCompact = ['Icon', 'project', 'seenAt'];
|
||||
interface IApiTokenTableProps {
|
||||
compact?: boolean;
|
||||
filterForProject?: string;
|
||||
tokens: IApiToken[];
|
||||
loading: boolean;
|
||||
}
|
||||
export const ApiTokenTable = ({
|
||||
compact = false,
|
||||
filterForProject,
|
||||
tokens,
|
||||
loading,
|
||||
}: IApiTokenTableProps) => {
|
||||
const { tokens, loading } = useApiTokens();
|
||||
const initialState = useMemo(() => ({ sortBy: [{ id: 'createdAt' }] }), []);
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
const filteredTokens = useMemo(() => {
|
||||
if (Boolean(filterForProject)) {
|
||||
return tokens.filter(token => {
|
||||
if (token.projects) {
|
||||
if (token.projects?.length > 1) return false;
|
||||
if (
|
||||
token.projects?.length === 1 &&
|
||||
token.projects[0] === filterForProject
|
||||
)
|
||||
return true;
|
||||
}
|
||||
|
||||
return token.project === filterForProject;
|
||||
});
|
||||
}
|
||||
return tokens;
|
||||
}, [tokens, filterForProject]);
|
||||
const COLUMNS = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
id: 'Icon',
|
||||
width: '1%',
|
||||
Cell: () => <IconCell icon={<Key color="disabled" />} />,
|
||||
disableSortBy: true,
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
{
|
||||
Header: 'Username',
|
||||
accessor: 'username',
|
||||
Cell: HighlightCell,
|
||||
},
|
||||
{
|
||||
Header: 'Type',
|
||||
accessor: 'type',
|
||||
Cell: ({
|
||||
value,
|
||||
}: {
|
||||
value: 'admin' | 'client' | 'frontend';
|
||||
}) => (
|
||||
<HighlightCell
|
||||
value={tokenDescriptions[value].label}
|
||||
subtitle={tokenDescriptions[value].title}
|
||||
/>
|
||||
),
|
||||
minWidth: 280,
|
||||
},
|
||||
{
|
||||
Header: 'Project',
|
||||
accessor: 'project',
|
||||
Cell: (props: any) => (
|
||||
<ProjectsList
|
||||
project={props.row.original.project}
|
||||
projects={props.row.original.projects}
|
||||
/>
|
||||
),
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
Header: 'Environment',
|
||||
accessor: 'environment',
|
||||
Cell: HighlightCell,
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
Header: 'Created',
|
||||
accessor: 'createdAt',
|
||||
Cell: DateCell,
|
||||
minWidth: 150,
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
{
|
||||
Header: 'Last seen',
|
||||
accessor: 'seenAt',
|
||||
Cell: TimeAgoCell,
|
||||
minWidth: 150,
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
{
|
||||
Header: 'Actions',
|
||||
id: 'Actions',
|
||||
align: 'center',
|
||||
width: '1%',
|
||||
disableSortBy: true,
|
||||
disableGlobalFilter: true,
|
||||
Cell: (props: any) => (
|
||||
<ActionCell>
|
||||
<CopyApiTokenButton
|
||||
token={props.row.original}
|
||||
project={filterForProject}
|
||||
/>
|
||||
<RemoveApiTokenButton
|
||||
token={props.row.original}
|
||||
project={filterForProject}
|
||||
/>
|
||||
</ActionCell>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [filterForProject]);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
@ -74,7 +142,7 @@ export const ApiTokenTable = ({
|
||||
} = useTable(
|
||||
{
|
||||
columns: COLUMNS as any,
|
||||
data: filteredTokens as any,
|
||||
data: tokens as any,
|
||||
initialState,
|
||||
sortTypes,
|
||||
autoResetHiddenColumns: false,
|
||||
@ -207,74 +275,3 @@ const tokenDescriptions = {
|
||||
title: 'Full access for managing Unleash',
|
||||
},
|
||||
};
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
id: 'Icon',
|
||||
width: '1%',
|
||||
Cell: () => <IconCell icon={<Key color="disabled" />} />,
|
||||
disableSortBy: true,
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
{
|
||||
Header: 'Username',
|
||||
accessor: 'username',
|
||||
Cell: HighlightCell,
|
||||
},
|
||||
{
|
||||
Header: 'Type',
|
||||
accessor: 'type',
|
||||
Cell: ({ value }: { value: 'admin' | 'client' | 'frontend' }) => (
|
||||
<HighlightCell
|
||||
value={tokenDescriptions[value].label}
|
||||
subtitle={tokenDescriptions[value].title}
|
||||
/>
|
||||
),
|
||||
minWidth: 280,
|
||||
},
|
||||
{
|
||||
Header: 'Project',
|
||||
accessor: 'project',
|
||||
Cell: (props: any) => (
|
||||
<ProjectsList
|
||||
project={props.row.original.project}
|
||||
projects={props.row.original.projects}
|
||||
/>
|
||||
),
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
Header: 'Environment',
|
||||
accessor: 'environment',
|
||||
Cell: HighlightCell,
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
Header: 'Created',
|
||||
accessor: 'createdAt',
|
||||
Cell: DateCell,
|
||||
minWidth: 150,
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
{
|
||||
Header: 'Last seen',
|
||||
accessor: 'seenAt',
|
||||
Cell: TimeAgoCell,
|
||||
minWidth: 150,
|
||||
disableGlobalFilter: true,
|
||||
},
|
||||
{
|
||||
Header: 'Actions',
|
||||
id: 'Actions',
|
||||
align: 'center',
|
||||
width: '1%',
|
||||
disableSortBy: true,
|
||||
disableGlobalFilter: true,
|
||||
Cell: (props: any) => (
|
||||
<ActionCell>
|
||||
<CopyApiTokenButton token={props.row.original} />
|
||||
<RemoveApiTokenButton token={props.row.original} />
|
||||
</ActionCell>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
@ -1,16 +1,46 @@
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||
import useToast from 'hooks/useToast';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { FileCopy } from '@mui/icons-material';
|
||||
import {
|
||||
READ_API_TOKEN,
|
||||
READ_PROJECT_API_TOKEN,
|
||||
} from 'component/providers/AccessProvider/permissions';
|
||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||
import { useContext } from 'react';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
|
||||
interface ICopyApiTokenButtonProps {
|
||||
token: IApiToken;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
export const CopyApiTokenButton = ({ token }: ICopyApiTokenButtonProps) => {
|
||||
export const CopyApiTokenButton = ({
|
||||
token,
|
||||
project,
|
||||
}: ICopyApiTokenButtonProps) => {
|
||||
const { hasAccess, isAdmin } = useContext(AccessContext);
|
||||
const { setToastData } = useToast();
|
||||
|
||||
const permission = Boolean(project)
|
||||
? READ_PROJECT_API_TOKEN
|
||||
: READ_API_TOKEN;
|
||||
|
||||
const canCopy = () => {
|
||||
if (isAdmin) {
|
||||
return true;
|
||||
}
|
||||
if (token && token.projects && project && permission) {
|
||||
const { projects } = token;
|
||||
for (const tokenProject of projects) {
|
||||
if (!hasAccess(permission, tokenProject)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const copyToken = (value: string) => {
|
||||
if (copy(value)) {
|
||||
setToastData({
|
||||
@ -21,10 +51,15 @@ export const CopyApiTokenButton = ({ token }: ICopyApiTokenButtonProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title="Copy token" arrow>
|
||||
<IconButton onClick={() => copyToken(token.secret)} size="large">
|
||||
<FileCopy />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<PermissionIconButton
|
||||
permission={permission}
|
||||
projectId={project}
|
||||
tooltipProps={{ title: 'Copy token', arrow: true }}
|
||||
onClick={() => copyToken(token.secret)}
|
||||
size="large"
|
||||
disabled={!canCopy()}
|
||||
>
|
||||
<FileCopy />
|
||||
</PermissionIconButton>
|
||||
);
|
||||
};
|
||||
|
@ -7,13 +7,21 @@ import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { useApiTokenForm } from 'component/admin/apiToken/ApiTokenForm/useApiTokenForm';
|
||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||
import {
|
||||
CREATE_API_TOKEN,
|
||||
CREATE_PROJECT_API_TOKEN,
|
||||
} from 'component/providers/AccessProvider/permissions';
|
||||
import { ConfirmToken } from '../ConfirmToken/ConfirmToken';
|
||||
import { scrollToTop } from 'component/common/util';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import { GO_BACK } from 'constants/navigate';
|
||||
import { useApiTokens } from '../../../../hooks/api/getters/useApiTokens/useApiTokens';
|
||||
import { useApiTokens } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||
import useProjectApiTokensApi from 'hooks/api/actions/useProjectApiTokensApi/useProjectApiTokensApi';
|
||||
import { TokenInfo } from '../ApiTokenForm/TokenInfo/TokenInfo';
|
||||
import { TokenTypeSelector } from '../ApiTokenForm/TokenTypeSelector/TokenTypeSelector';
|
||||
import { ProjectSelector } from '../ApiTokenForm/ProjectSelector/ProjectSelector';
|
||||
import { EnvironmentSelector } from '../ApiTokenForm/EnvironmentSelector/EnvironmentSelector';
|
||||
|
||||
const pageTitle = 'Create API token';
|
||||
|
||||
@ -46,11 +54,21 @@ export const CreateApiToken = ({
|
||||
clearErrors,
|
||||
} = useApiTokenForm(project);
|
||||
|
||||
const { createToken, loading } = useApiTokensApi();
|
||||
const { createToken, loading: globalLoading } = useApiTokensApi();
|
||||
const { createToken: createProjectToken, loading: projectLoading } =
|
||||
useProjectApiTokensApi();
|
||||
const { refetch } = useApiTokens();
|
||||
|
||||
usePageTitle(pageTitle);
|
||||
|
||||
const PATH = Boolean(project)
|
||||
? `api/admin/project/${project}/api-tokens`
|
||||
: 'api/admin/api-tokens';
|
||||
const permission = Boolean(project)
|
||||
? CREATE_PROJECT_API_TOKEN
|
||||
: CREATE_API_TOKEN;
|
||||
const loading = globalLoading || projectLoading;
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!isValid()) {
|
||||
@ -58,13 +76,23 @@ export const CreateApiToken = ({
|
||||
}
|
||||
try {
|
||||
const payload = getApiTokenPayload();
|
||||
await createToken(payload)
|
||||
.then(res => res.json())
|
||||
.then(api => {
|
||||
scrollToTop();
|
||||
setToken(api.secret);
|
||||
setShowConfirm(true);
|
||||
});
|
||||
if (project) {
|
||||
await createProjectToken(payload, project)
|
||||
.then(res => res.json())
|
||||
.then(api => {
|
||||
scrollToTop();
|
||||
setToken(api.secret);
|
||||
setShowConfirm(true);
|
||||
});
|
||||
} else {
|
||||
await createToken(payload)
|
||||
.then(res => res.json())
|
||||
.then(api => {
|
||||
scrollToTop();
|
||||
setToken(api.secret);
|
||||
setShowConfirm(true);
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
@ -79,7 +107,7 @@ export const CreateApiToken = ({
|
||||
const formatApiCode = () => {
|
||||
return `curl --location --request POST '${
|
||||
uiConfig.unleashUrl
|
||||
}/api/admin/api-tokens' \\
|
||||
}/${PATH}' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '${JSON.stringify(getApiTokenPayload(), undefined, 2)}'`;
|
||||
@ -100,22 +128,36 @@ export const CreateApiToken = ({
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<ApiTokenForm
|
||||
username={username}
|
||||
type={type}
|
||||
disableProjectSelection={Boolean(project)}
|
||||
projects={projects}
|
||||
environment={environment}
|
||||
setEnvironment={setEnvironment}
|
||||
setTokenType={setTokenType}
|
||||
setUsername={setUsername}
|
||||
setProjects={setProjects}
|
||||
errors={errors}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancel={handleCancel}
|
||||
mode="Create"
|
||||
clearErrors={clearErrors}
|
||||
actions={
|
||||
<CreateButton
|
||||
name="token"
|
||||
permission={permission}
|
||||
projectId={project}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CreateButton name="token" permission={ADMIN} />
|
||||
<TokenInfo
|
||||
username={username}
|
||||
setUsername={setUsername}
|
||||
errors={errors}
|
||||
clearErrors={clearErrors}
|
||||
/>
|
||||
<TokenTypeSelector type={type} setType={setTokenType} />
|
||||
<ProjectSelector
|
||||
type={type}
|
||||
projects={projects}
|
||||
setProjects={setProjects}
|
||||
errors={errors}
|
||||
clearErrors={clearErrors}
|
||||
/>
|
||||
<EnvironmentSelector
|
||||
type={type}
|
||||
environment={environment}
|
||||
setEnvironment={setEnvironment}
|
||||
/>
|
||||
</ApiTokenForm>
|
||||
<ConfirmToken
|
||||
open={showConfirm}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
|
||||
import { CREATE_API_TOKEN } from 'component/providers/AccessProvider/permissions';
|
||||
import {
|
||||
CREATE_API_TOKEN,
|
||||
CREATE_PROJECT_API_TOKEN,
|
||||
} from 'component/providers/AccessProvider/permissions';
|
||||
import { CREATE_API_TOKEN_BUTTON } from 'utils/testIds';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Add } from '@mui/icons-material';
|
||||
@ -11,13 +14,16 @@ export const CreateApiTokenButton = () => {
|
||||
const project = useOptionalPathParam('projectId');
|
||||
|
||||
const to = Boolean(project) ? 'create' : '/admin/api/create-token';
|
||||
|
||||
const permission = Boolean(project)
|
||||
? CREATE_PROJECT_API_TOKEN
|
||||
: CREATE_API_TOKEN;
|
||||
return (
|
||||
<ResponsiveButton
|
||||
Icon={Add}
|
||||
onClick={() => navigate(to)}
|
||||
data-testid={CREATE_API_TOKEN_BUTTON}
|
||||
permission={CREATE_API_TOKEN}
|
||||
permission={permission}
|
||||
projectId={project}
|
||||
maxWidth="700px"
|
||||
>
|
||||
New API token
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { DELETE_API_TOKEN } from 'component/providers/AccessProvider/permissions';
|
||||
import {
|
||||
DELETE_API_TOKEN,
|
||||
DELETE_PROJECT_API_TOKEN,
|
||||
} from 'component/providers/AccessProvider/permissions';
|
||||
import { Delete } from '@mui/icons-material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { IconButton, styled, Tooltip } from '@mui/material';
|
||||
import { styled } from '@mui/material';
|
||||
import {
|
||||
IApiToken,
|
||||
useApiTokens,
|
||||
@ -11,6 +13,8 @@ import { useContext, useState } from 'react';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import useToast from 'hooks/useToast';
|
||||
import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
|
||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||
import useProjectApiTokensApi from '../../../../hooks/api/actions/useProjectApiTokensApi/useProjectApiTokensApi';
|
||||
|
||||
const StyledUl = styled('ul')({
|
||||
marginBottom: 0,
|
||||
@ -18,17 +22,45 @@ const StyledUl = styled('ul')({
|
||||
|
||||
interface IRemoveApiTokenButtonProps {
|
||||
token: IApiToken;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
export const RemoveApiTokenButton = ({ token }: IRemoveApiTokenButtonProps) => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
export const RemoveApiTokenButton = ({
|
||||
token,
|
||||
project,
|
||||
}: IRemoveApiTokenButtonProps) => {
|
||||
const { hasAccess, isAdmin } = useContext(AccessContext);
|
||||
const { deleteToken } = useApiTokensApi();
|
||||
const { deleteToken: deleteProjectToken } = useProjectApiTokensApi();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { setToastData } = useToast();
|
||||
const { refetch } = useApiTokens();
|
||||
|
||||
const permission = Boolean(project)
|
||||
? DELETE_PROJECT_API_TOKEN
|
||||
: DELETE_API_TOKEN;
|
||||
|
||||
const canRemove = () => {
|
||||
if (isAdmin) {
|
||||
return true;
|
||||
}
|
||||
if (token && token.projects && project && permission) {
|
||||
const { projects } = token;
|
||||
for (const tokenProject of projects) {
|
||||
if (!hasAccess(permission, tokenProject)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async () => {
|
||||
await deleteToken(token.secret);
|
||||
if (project) {
|
||||
await deleteProjectToken(token.secret, project);
|
||||
} else {
|
||||
await deleteToken(token.secret);
|
||||
}
|
||||
setOpen(false);
|
||||
refetch();
|
||||
setToastData({
|
||||
@ -39,16 +71,16 @@ export const RemoveApiTokenButton = ({ token }: IRemoveApiTokenButtonProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={hasAccess(DELETE_API_TOKEN)}
|
||||
show={
|
||||
<Tooltip title="Delete token" arrow>
|
||||
<IconButton onClick={() => setOpen(true)} size="large">
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<PermissionIconButton
|
||||
permission={permission}
|
||||
projectId={project}
|
||||
tooltipProps={{ title: 'Delete token', arrow: true }}
|
||||
onClick={() => setOpen(true)}
|
||||
size="large"
|
||||
disabled={!canRemove()}
|
||||
>
|
||||
<Delete />
|
||||
</PermissionIconButton>
|
||||
<Dialogue
|
||||
open={open}
|
||||
onClick={onRemove}
|
||||
|
@ -24,7 +24,7 @@ import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironmen
|
||||
import EnvironmentTypeSelector from 'component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector';
|
||||
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
|
||||
import { EnvironmentProjectSelect } from './EnvironmentProjectSelect/EnvironmentProjectSelect';
|
||||
import { SelectProjectInput } from 'component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput';
|
||||
import { SelectProjectInput } from 'component/admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectProjectInput';
|
||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||
import useApiTokensApi, {
|
||||
IApiTokenCreate,
|
||||
|
@ -11,7 +11,7 @@ import { caseInsensitiveSearch } from 'utils/search';
|
||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||
import { Fragment } from 'react';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { SelectAllButton } from 'component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton';
|
||||
import { SelectAllButton } from 'component/admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectAllButton/SelectAllButton';
|
||||
|
||||
const StyledOption = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
|
@ -3,25 +3,27 @@ import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { Alert } from '@mui/material';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
||||
import { READ_PROJECT_API_TOKEN } from 'component/providers/AccessProvider/permissions';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
|
||||
import { ApiTokenTable } from '../../../admin/apiToken/ApiTokenTable/ApiTokenTable';
|
||||
import { useProjectApiTokens } from '../../../../hooks/api/getters/useProjectApiTokens/useProjectApiTokens';
|
||||
|
||||
export const ProjectApiAccess = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const projectName = useProjectNameOrId(projectId);
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
const { tokens, loading } = useProjectApiTokens(projectId);
|
||||
|
||||
usePageTitle(`Project api access – ${projectName}`);
|
||||
|
||||
if (!hasAccess(UPDATE_PROJECT, projectId)) {
|
||||
if (!hasAccess(READ_PROJECT_API_TOKEN, projectId)) {
|
||||
return (
|
||||
<PageContent header={<PageHeader title="Api access" />}>
|
||||
<Alert severity="error">
|
||||
You need project owner or admin permissions to access this
|
||||
section.
|
||||
You need to be a member of the project or admin to access
|
||||
this section.
|
||||
</Alert>
|
||||
</PageContent>
|
||||
);
|
||||
@ -29,7 +31,12 @@ export const ProjectApiAccess = () => {
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', overflow: 'hidden' }}>
|
||||
<ApiTokenTable compact filterForProject={projectId} />
|
||||
<ApiTokenTable
|
||||
tokens={tokens}
|
||||
loading={loading}
|
||||
compact
|
||||
filterForProject={projectId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -47,7 +47,6 @@ export const hasAccess = (
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return permissions.some(p => {
|
||||
return checkPermission(p, permission, project, environment);
|
||||
});
|
||||
@ -79,7 +78,7 @@ const checkPermission = (
|
||||
if (
|
||||
p.permission === permission &&
|
||||
(p.project === project || p.project === '*') &&
|
||||
p.environment === null
|
||||
!Boolean(p.environment)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
@ -36,3 +36,6 @@ export const DELETE_SEGMENT = 'DELETE_SEGMENT';
|
||||
export const APPLY_CHANGE_REQUEST = 'APPLY_CHANGE_REQUEST';
|
||||
export const APPROVE_CHANGE_REQUEST = 'APPROVE_CHANGE_REQUEST';
|
||||
export const SKIP_CHANGE_REQUEST = 'SKIP_CHANGE_REQUEST';
|
||||
export const READ_PROJECT_API_TOKEN = 'READ_PROJECT_API_TOKEN';
|
||||
export const CREATE_PROJECT_API_TOKEN = 'CREATE_PROJECT_API_TOKEN';
|
||||
export const DELETE_PROJECT_API_TOKEN = 'DELETE_PROJECT_API_TOKEN';
|
||||
|
@ -0,0 +1,47 @@
|
||||
import useAPI from '../useApi/useApi';
|
||||
|
||||
export interface IApiTokenCreate {
|
||||
username: string;
|
||||
type: string;
|
||||
environment?: string;
|
||||
projects: string[];
|
||||
}
|
||||
|
||||
const useProjectApiTokensApi = () => {
|
||||
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||
propagateErrors: true,
|
||||
});
|
||||
|
||||
const deleteToken = async (secret: string, project: string) => {
|
||||
const path = `api/admin/projects/${project}/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, project: string) => {
|
||||
const path = `api/admin/project/${project}/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, loading };
|
||||
};
|
||||
|
||||
export default useProjectApiTokensApi;
|
@ -0,0 +1,36 @@
|
||||
import useSWR, { SWRConfiguration } from 'swr';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { formatApiPath } from 'utils/formatPath';
|
||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||
import { IApiToken } from '../useApiTokens/useApiTokens';
|
||||
|
||||
export const useProjectApiTokens = (
|
||||
project: string,
|
||||
options: SWRConfiguration = {}
|
||||
) => {
|
||||
const path = formatApiPath(`api/admin/projects/${project}/api-tokens`);
|
||||
const { data, error, mutate } = useSWR<IApiToken[]>(path, fetcher, options);
|
||||
|
||||
const tokens = useMemo(() => {
|
||||
return data ?? [];
|
||||
}, [data]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
mutate().catch(console.warn);
|
||||
}, [mutate]);
|
||||
|
||||
return {
|
||||
tokens,
|
||||
error,
|
||||
loading: !error && !data,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
const fetcher = async (path: string): Promise<IApiToken[]> => {
|
||||
const res = await fetch(path).then(
|
||||
handleErrorResponses('Project Api tokens')
|
||||
);
|
||||
const data = await res.json();
|
||||
return data.tokens;
|
||||
};
|
218
src/lib/routes/admin-api/project/api-token.ts
Normal file
218
src/lib/routes/admin-api/project/api-token.ts
Normal file
@ -0,0 +1,218 @@
|
||||
import {
|
||||
ApiTokenSchema,
|
||||
apiTokenSchema,
|
||||
ApiTokensSchema,
|
||||
apiTokensSchema,
|
||||
createRequestSchema,
|
||||
createResponseSchema,
|
||||
emptyResponse,
|
||||
resourceCreatedResponseSchema,
|
||||
} from '../../../openapi';
|
||||
import User from '../../../types/user';
|
||||
import {
|
||||
ADMIN,
|
||||
CREATE_PROJECT_API_TOKEN,
|
||||
DELETE_PROJECT_API_TOKEN,
|
||||
IUnleashConfig,
|
||||
IUnleashServices,
|
||||
READ_PROJECT_API_TOKEN,
|
||||
serializeDates,
|
||||
} from '../../../types';
|
||||
import { ApiTokenType, IApiToken } from '../../../types/models/api-token';
|
||||
import {
|
||||
AccessService,
|
||||
ApiTokenService,
|
||||
OpenApiService,
|
||||
ProxyService,
|
||||
} from '../../../services';
|
||||
import { extractUsername } from '../../../util';
|
||||
import { IAuthRequest } from '../../unleash-types';
|
||||
import Controller from '../../controller';
|
||||
import { Logger } from '../../../logger';
|
||||
import { Response } from 'express';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
interface ProjectTokenParam {
|
||||
token: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const PATH = '/:projectId/api-tokens';
|
||||
const PATH_TOKEN = `${PATH}/:token`;
|
||||
export class ProjectApiTokenController extends Controller {
|
||||
private apiTokenService: ApiTokenService;
|
||||
|
||||
private accessService: AccessService;
|
||||
|
||||
private proxyService: ProxyService;
|
||||
|
||||
private openApiService: OpenApiService;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
config: IUnleashConfig,
|
||||
{
|
||||
apiTokenService,
|
||||
accessService,
|
||||
proxyService,
|
||||
openApiService,
|
||||
}: Pick<
|
||||
IUnleashServices,
|
||||
| 'apiTokenService'
|
||||
| 'accessService'
|
||||
| 'proxyService'
|
||||
| 'openApiService'
|
||||
>,
|
||||
) {
|
||||
super(config);
|
||||
this.apiTokenService = apiTokenService;
|
||||
this.accessService = accessService;
|
||||
this.proxyService = proxyService;
|
||||
this.openApiService = openApiService;
|
||||
this.logger = config.getLogger('project-api-token-controller.js');
|
||||
|
||||
this.route({
|
||||
method: 'get',
|
||||
path: PATH,
|
||||
handler: this.getProjectApiTokens,
|
||||
permission: READ_PROJECT_API_TOKEN,
|
||||
middleware: [
|
||||
openApiService.validPath({
|
||||
tags: ['Projects'],
|
||||
operationId: 'getProjectApiTokens',
|
||||
responses: {
|
||||
200: createResponseSchema('apiTokensSchema'),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
this.route({
|
||||
method: 'post',
|
||||
path: PATH,
|
||||
handler: this.createProjectApiToken,
|
||||
permission: CREATE_PROJECT_API_TOKEN,
|
||||
middleware: [
|
||||
openApiService.validPath({
|
||||
tags: ['Projects'],
|
||||
operationId: 'createProjectApiToken',
|
||||
requestBody: createRequestSchema('createApiTokenSchema'),
|
||||
responses: {
|
||||
201: resourceCreatedResponseSchema('apiTokenSchema'),
|
||||
400: emptyResponse,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
this.route({
|
||||
method: 'delete',
|
||||
path: PATH_TOKEN,
|
||||
handler: this.deleteProjectApiToken,
|
||||
acceptAnyContentType: true,
|
||||
permission: DELETE_PROJECT_API_TOKEN,
|
||||
middleware: [
|
||||
openApiService.validPath({
|
||||
tags: ['Projects'],
|
||||
operationId: 'deleteProjectApiToken',
|
||||
responses: {
|
||||
200: emptyResponse,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getProjectApiTokens(
|
||||
req: IAuthRequest,
|
||||
res: Response<ApiTokensSchema>,
|
||||
): Promise<void> {
|
||||
const { user } = req;
|
||||
const { projectId } = req.params;
|
||||
const projectTokens = await this.accessibleTokens(user, projectId);
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
apiTokensSchema.$id,
|
||||
{ tokens: serializeDates(projectTokens) },
|
||||
);
|
||||
}
|
||||
|
||||
async createProjectApiToken(
|
||||
req: IAuthRequest,
|
||||
res: Response<ApiTokenSchema>,
|
||||
): Promise<any> {
|
||||
const createToken = req.body;
|
||||
const { projectId } = req.params;
|
||||
if (!createToken.project) {
|
||||
createToken.project = projectId;
|
||||
}
|
||||
|
||||
if (
|
||||
createToken.projects.length === 1 &&
|
||||
createToken.projects[0] === projectId
|
||||
) {
|
||||
const token = await this.apiTokenService.createApiToken(
|
||||
createToken,
|
||||
extractUsername(req),
|
||||
);
|
||||
this.openApiService.respondWithValidation(
|
||||
201,
|
||||
res,
|
||||
apiTokenSchema.$id,
|
||||
serializeDates(token),
|
||||
{ location: `api-tokens` },
|
||||
);
|
||||
} else {
|
||||
res.statusMessage =
|
||||
'Project level tokens can only be created for one project';
|
||||
res.status(400);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteProjectApiToken(
|
||||
req: IAuthRequest<ProjectTokenParam>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { user } = req;
|
||||
const { projectId, token } = req.params;
|
||||
const storedToken = (await this.accessibleTokens(user, projectId)).find(
|
||||
(currentToken) => this.tokenEquals(currentToken.secret, token),
|
||||
);
|
||||
if (
|
||||
storedToken &&
|
||||
(storedToken.project === projectId ||
|
||||
(storedToken.projects.length === 1 &&
|
||||
storedToken.project[0] === projectId))
|
||||
) {
|
||||
await this.apiTokenService.delete(token, extractUsername(req));
|
||||
this.proxyService.deleteClientForProxyToken(token);
|
||||
res.status(200).end();
|
||||
}
|
||||
}
|
||||
|
||||
private tokenEquals(token1: string, token2: string) {
|
||||
return (
|
||||
token1.length === token2.length &&
|
||||
timingSafeEqual(Buffer.from(token1), Buffer.from(token2))
|
||||
);
|
||||
}
|
||||
|
||||
private async accessibleTokens(
|
||||
user: User,
|
||||
project: string,
|
||||
): Promise<IApiToken[]> {
|
||||
const allTokens = await this.apiTokenService.getAllTokens();
|
||||
|
||||
if (user.isAPI && user.permissions.includes(ADMIN)) {
|
||||
return allTokens;
|
||||
}
|
||||
|
||||
return allTokens.filter(
|
||||
(token) =>
|
||||
token.type !== ApiTokenType.ADMIN &&
|
||||
(token.project === project || token.projects.includes(project)),
|
||||
);
|
||||
}
|
||||
}
|
@ -21,6 +21,7 @@ import {
|
||||
projectOverviewSchema,
|
||||
} from '../../../../lib/openapi';
|
||||
import { IArchivedQuery, IProjectParam } from '../../../types/model';
|
||||
import { ProjectApiTokenController } from './api-token';
|
||||
|
||||
export default class ProjectApi extends Controller {
|
||||
private projectService: ProjectService;
|
||||
@ -68,6 +69,7 @@ export default class ProjectApi extends Controller {
|
||||
this.use('/', new EnvironmentsController(config, services).router);
|
||||
this.use('/', new ProjectHealthReport(config, services).router);
|
||||
this.use('/', new VariantsController(config, services).router);
|
||||
this.use('/', new ProjectApiTokenController(config, services).router);
|
||||
}
|
||||
|
||||
async getProjects(
|
||||
|
@ -42,3 +42,6 @@ export const DELETE_SEGMENT = 'DELETE_SEGMENT';
|
||||
export const APPROVE_CHANGE_REQUEST = 'APPROVE_CHANGE_REQUEST';
|
||||
export const APPLY_CHANGE_REQUEST = 'APPLY_CHANGE_REQUEST';
|
||||
export const SKIP_CHANGE_REQUEST = 'SKIP_CHANGE_REQUEST';
|
||||
export const READ_PROJECT_API_TOKEN = 'READ_PROJECT_API_TOKEN';
|
||||
export const CREATE_PROJECT_API_TOKEN = 'CREATE_PROJECT_API_TOKEN';
|
||||
export const DELETE_PROJECT_API_TOKEN = 'DELETE_PROJECT_API_TOKEN';
|
||||
|
@ -0,0 +1,21 @@
|
||||
exports.up = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
INSERT INTO permissions (permission, display_name, type) VALUES ('READ_PROJECT_API_TOKEN', 'Read api tokens for a specific project', 'project');
|
||||
INSERT INTO permissions (permission, display_name, type) VALUES ('CREATE_PROJECT_API_TOKEN', 'Create api tokens for a specific project', 'project');
|
||||
INSERT INTO permissions (permission, display_name, type) VALUES ('DELETE_PROJECT_API_TOKEN', 'Delete api tokens for a specific project', 'project');
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
DELETE FROM permissions WHERE permission = 'READ_PROJECT_API_TOKEN';
|
||||
DELETE FROM permissions WHERE permission = 'CREATE_PROJECT_API_TOKEN';
|
||||
DELETE FROM permissions WHERE permission = 'DELETE_PROJECT_API_TOKEN';
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
@ -0,0 +1,28 @@
|
||||
exports.up = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
INSERT INTO role_permission (role_id, permission_id)
|
||||
SELECT (SELECT id as role_id from roles WHERE name = 'Editor' LIMIT 1),
|
||||
p.id as permission_id
|
||||
FROM permissions p
|
||||
WHERE p.permission IN
|
||||
('READ_PROJECT_API_TOKEN',
|
||||
'CREATE_PROJECT_API_TOKEN',
|
||||
'DELETE_PROJECT_API_TOKEN');
|
||||
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
DELETE FROM role_permission
|
||||
WHERE permission_id IN (SELECT id from permissions WHERE permission IN ('READ_PROJECT_API_TOKEN'))
|
||||
AND role_id = (SELECT id as role_id from roles WHERE name = 'Editor' LIMIT 1)
|
||||
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
@ -0,0 +1,29 @@
|
||||
exports.up = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
INSERT INTO role_permission (role_id, permission_id)
|
||||
SELECT (SELECT id as role_id from roles WHERE name = 'Owner' LIMIT 1),
|
||||
p.id as permission_id
|
||||
FROM permissions p
|
||||
WHERE p.permission IN
|
||||
('READ_PROJECT_API_TOKEN',
|
||||
'CREATE_PROJECT_API_TOKEN',
|
||||
'DELETE_PROJECT_API_TOKEN');
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
DELETE FROM role_permission
|
||||
WHERE permission_id IN (SELECT id from permissions WHERE permission IN ('READ_PROJECT_API_TOKEN',
|
||||
'CREATE_PROJECT_API_TOKEN',
|
||||
'DELETE_PROJECT_API_TOKEN'))
|
||||
AND role_id = (SELECT id as role_id from roles WHERE name = 'Owner' LIMIT 1)
|
||||
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
@ -0,0 +1,27 @@
|
||||
exports.up = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
INSERT INTO role_permission (role_id, permission_id)
|
||||
SELECT (SELECT id as role_id from roles WHERE name = 'Member' LIMIT 1),
|
||||
p.id as permission_id
|
||||
FROM permissions p
|
||||
WHERE p.permission IN
|
||||
('READ_PROJECT_API_TOKEN',
|
||||
'CREATE_PROJECT_API_TOKEN',
|
||||
'DELETE_PROJECT_API_TOKEN');
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
DELETE FROM role_permission
|
||||
WHERE permission_id IN (SELECT id from permissions WHERE permission IN ('READ_PROJECT_API_TOKEN'))
|
||||
AND role_id = (SELECT id as role_id from roles WHERE name = 'Member' LIMIT 1)
|
||||
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
@ -5777,6 +5777,118 @@ If the provided project does not exist, the list of events will be empty.",
|
||||
],
|
||||
},
|
||||
},
|
||||
"/api/admin/projects/{projectId}/api-tokens": {
|
||||
"get": {
|
||||
"operationId": "getProjectApiTokens",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "projectId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/apiTokensSchema",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "apiTokensSchema",
|
||||
},
|
||||
},
|
||||
"tags": [
|
||||
"Projects",
|
||||
],
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createProjectApiToken",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "projectId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/createApiTokenSchema",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "createApiTokenSchema",
|
||||
"required": true,
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/apiTokenSchema",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "The resource was successfully created.",
|
||||
"headers": {
|
||||
"location": {
|
||||
"description": "The location of the newly created resource.",
|
||||
"schema": {
|
||||
"format": "uri",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
"description": "This response has no body.",
|
||||
},
|
||||
},
|
||||
"tags": [
|
||||
"Projects",
|
||||
],
|
||||
},
|
||||
},
|
||||
"/api/admin/projects/{projectId}/api-tokens/{token}": {
|
||||
"delete": {
|
||||
"operationId": "deleteProjectApiToken",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "projectId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "This response has no body.",
|
||||
},
|
||||
},
|
||||
"tags": [
|
||||
"Projects",
|
||||
],
|
||||
},
|
||||
},
|
||||
"/api/admin/projects/{projectId}/environments": {
|
||||
"post": {
|
||||
"operationId": "addEnvironmentToProject",
|
||||
|
Loading…
Reference in New Issue
Block a user