mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +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 CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||||
import { ConditionallyRender } from '../../../common/ConditionallyRender/ConditionallyRender';
|
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 {
|
import {
|
||||||
StyledHelpText,
|
StyledHelpText,
|
||||||
StyledSelectAllFormControlLabel,
|
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 {
|
import { Alert, Link } from '@mui/material';
|
||||||
Alert,
|
import React, { ReactNode } from 'react';
|
||||||
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 useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { TokenType } from 'interfaces/token';
|
import { CancelButton, StyledBox, StyledForm } from './ApiTokenForm.styles';
|
||||||
|
|
||||||
interface IApiTokenFormProps {
|
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;
|
handleSubmit: (e: any) => void;
|
||||||
handleCancel: () => void;
|
handleCancel: () => void;
|
||||||
errors: { [key: string]: string };
|
|
||||||
mode: 'Create' | 'Edit';
|
mode: 'Create' | 'Edit';
|
||||||
clearErrors: (error?: ApiTokenFormErrorType) => void;
|
actions?: ReactNode;
|
||||||
disableProjectSelection?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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> = ({
|
const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
|
||||||
children,
|
children,
|
||||||
username,
|
actions,
|
||||||
type,
|
|
||||||
projects,
|
|
||||||
disableProjectSelection = false,
|
|
||||||
environment,
|
|
||||||
setUsername,
|
|
||||||
setTokenType,
|
|
||||||
setProjects,
|
|
||||||
setEnvironment,
|
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
errors,
|
|
||||||
clearErrors,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { uiConfig } = useUiConfig();
|
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);
|
const isUnleashCloud = Boolean(uiConfig?.flags?.UNLEASH_CLOUD);
|
||||||
|
|
||||||
@ -152,93 +35,9 @@ const ApiTokenForm: React.FC<IApiTokenFormProps> = ({
|
|||||||
</Alert>
|
</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>
|
|
||||||
<StyledBox>
|
|
||||||
{children}
|
{children}
|
||||||
|
<StyledBox>
|
||||||
|
{actions}
|
||||||
<CancelButton onClick={handleCancel}>Cancel</CancelButton>
|
<CancelButton onClick={handleCancel}>Cancel</CancelButton>
|
||||||
</StyledBox>
|
</StyledBox>
|
||||||
</StyledForm>
|
</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 { READ_API_TOKEN } from 'component/providers/AccessProvider/permissions';
|
||||||
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
||||||
import { ApiTokenTable } from 'component/admin/apiToken/ApiTokenTable/ApiTokenTable';
|
import { ApiTokenTable } from 'component/admin/apiToken/ApiTokenTable/ApiTokenTable';
|
||||||
|
import { useApiTokens } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||||
|
|
||||||
export const ApiTokenPage = () => {
|
export const ApiTokenPage = () => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const { tokens, loading } = useApiTokens();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={hasAccess(READ_API_TOKEN)}
|
condition={hasAccess(READ_API_TOKEN)}
|
||||||
show={() => <ApiTokenTable />}
|
show={() => <ApiTokenTable tokens={tokens} loading={loading} />}
|
||||||
elseShow={() => <AdminAlert />}
|
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 { useGlobalFilter, useSortBy, useTable } from 'react-table';
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
import {
|
import {
|
||||||
@ -35,32 +35,100 @@ const hiddenColumnsCompact = ['Icon', 'project', 'seenAt'];
|
|||||||
interface IApiTokenTableProps {
|
interface IApiTokenTableProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
filterForProject?: string;
|
filterForProject?: string;
|
||||||
|
tokens: IApiToken[];
|
||||||
|
loading: boolean;
|
||||||
}
|
}
|
||||||
export const ApiTokenTable = ({
|
export const ApiTokenTable = ({
|
||||||
compact = false,
|
compact = false,
|
||||||
filterForProject,
|
filterForProject,
|
||||||
|
tokens,
|
||||||
|
loading,
|
||||||
}: IApiTokenTableProps) => {
|
}: IApiTokenTableProps) => {
|
||||||
const { tokens, loading } = useApiTokens();
|
|
||||||
const initialState = useMemo(() => ({ sortBy: [{ id: 'createdAt' }] }), []);
|
const initialState = useMemo(() => ({ sortBy: [{ id: 'createdAt' }] }), []);
|
||||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
const filteredTokens = useMemo(() => {
|
const COLUMNS = useMemo(() => {
|
||||||
if (Boolean(filterForProject)) {
|
return [
|
||||||
return tokens.filter(token => {
|
{
|
||||||
if (token.projects) {
|
id: 'Icon',
|
||||||
if (token.projects?.length > 1) return false;
|
width: '1%',
|
||||||
if (
|
Cell: () => <IconCell icon={<Key color="disabled" />} />,
|
||||||
token.projects?.length === 1 &&
|
disableSortBy: true,
|
||||||
token.projects[0] === filterForProject
|
disableGlobalFilter: true,
|
||||||
)
|
},
|
||||||
return true;
|
{
|
||||||
}
|
Header: 'Username',
|
||||||
|
accessor: 'username',
|
||||||
return token.project === filterForProject;
|
Cell: HighlightCell,
|
||||||
});
|
},
|
||||||
}
|
{
|
||||||
return tokens;
|
Header: 'Type',
|
||||||
}, [tokens, filterForProject]);
|
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 {
|
const {
|
||||||
getTableProps,
|
getTableProps,
|
||||||
@ -74,7 +142,7 @@ export const ApiTokenTable = ({
|
|||||||
} = useTable(
|
} = useTable(
|
||||||
{
|
{
|
||||||
columns: COLUMNS as any,
|
columns: COLUMNS as any,
|
||||||
data: filteredTokens as any,
|
data: tokens as any,
|
||||||
initialState,
|
initialState,
|
||||||
sortTypes,
|
sortTypes,
|
||||||
autoResetHiddenColumns: false,
|
autoResetHiddenColumns: false,
|
||||||
@ -207,74 +275,3 @@ const tokenDescriptions = {
|
|||||||
title: 'Full access for managing Unleash',
|
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 { IApiToken } from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import { FileCopy } from '@mui/icons-material';
|
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 {
|
interface ICopyApiTokenButtonProps {
|
||||||
token: IApiToken;
|
token: IApiToken;
|
||||||
|
project?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CopyApiTokenButton = ({ token }: ICopyApiTokenButtonProps) => {
|
export const CopyApiTokenButton = ({
|
||||||
|
token,
|
||||||
|
project,
|
||||||
|
}: ICopyApiTokenButtonProps) => {
|
||||||
|
const { hasAccess, isAdmin } = useContext(AccessContext);
|
||||||
const { setToastData } = useToast();
|
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) => {
|
const copyToken = (value: string) => {
|
||||||
if (copy(value)) {
|
if (copy(value)) {
|
||||||
setToastData({
|
setToastData({
|
||||||
@ -21,10 +51,15 @@ export const CopyApiTokenButton = ({ token }: ICopyApiTokenButtonProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title="Copy token" arrow>
|
<PermissionIconButton
|
||||||
<IconButton onClick={() => copyToken(token.secret)} size="large">
|
permission={permission}
|
||||||
|
projectId={project}
|
||||||
|
tooltipProps={{ title: 'Copy token', arrow: true }}
|
||||||
|
onClick={() => copyToken(token.secret)}
|
||||||
|
size="large"
|
||||||
|
disabled={!canCopy()}
|
||||||
|
>
|
||||||
<FileCopy />
|
<FileCopy />
|
||||||
</IconButton>
|
</PermissionIconButton>
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -7,13 +7,21 @@ import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
|
|||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import { useApiTokenForm } from 'component/admin/apiToken/ApiTokenForm/useApiTokenForm';
|
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 { ConfirmToken } from '../ConfirmToken/ConfirmToken';
|
||||||
import { scrollToTop } from 'component/common/util';
|
import { scrollToTop } from 'component/common/util';
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
import { GO_BACK } from 'constants/navigate';
|
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';
|
const pageTitle = 'Create API token';
|
||||||
|
|
||||||
@ -46,11 +54,21 @@ export const CreateApiToken = ({
|
|||||||
clearErrors,
|
clearErrors,
|
||||||
} = useApiTokenForm(project);
|
} = useApiTokenForm(project);
|
||||||
|
|
||||||
const { createToken, loading } = useApiTokensApi();
|
const { createToken, loading: globalLoading } = useApiTokensApi();
|
||||||
|
const { createToken: createProjectToken, loading: projectLoading } =
|
||||||
|
useProjectApiTokensApi();
|
||||||
const { refetch } = useApiTokens();
|
const { refetch } = useApiTokens();
|
||||||
|
|
||||||
usePageTitle(pageTitle);
|
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) => {
|
const handleSubmit = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isValid()) {
|
if (!isValid()) {
|
||||||
@ -58,6 +76,15 @@ export const CreateApiToken = ({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload = getApiTokenPayload();
|
const payload = getApiTokenPayload();
|
||||||
|
if (project) {
|
||||||
|
await createProjectToken(payload, project)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(api => {
|
||||||
|
scrollToTop();
|
||||||
|
setToken(api.secret);
|
||||||
|
setShowConfirm(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await createToken(payload)
|
await createToken(payload)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(api => {
|
.then(api => {
|
||||||
@ -65,6 +92,7 @@ export const CreateApiToken = ({
|
|||||||
setToken(api.secret);
|
setToken(api.secret);
|
||||||
setShowConfirm(true);
|
setShowConfirm(true);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setToastApiError(formatUnknownError(error));
|
setToastApiError(formatUnknownError(error));
|
||||||
}
|
}
|
||||||
@ -79,7 +107,7 @@ export const CreateApiToken = ({
|
|||||||
const formatApiCode = () => {
|
const formatApiCode = () => {
|
||||||
return `curl --location --request POST '${
|
return `curl --location --request POST '${
|
||||||
uiConfig.unleashUrl
|
uiConfig.unleashUrl
|
||||||
}/api/admin/api-tokens' \\
|
}/${PATH}' \\
|
||||||
--header 'Authorization: INSERT_API_KEY' \\
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
--header 'Content-Type: application/json' \\
|
--header 'Content-Type: application/json' \\
|
||||||
--data-raw '${JSON.stringify(getApiTokenPayload(), undefined, 2)}'`;
|
--data-raw '${JSON.stringify(getApiTokenPayload(), undefined, 2)}'`;
|
||||||
@ -100,22 +128,36 @@ export const CreateApiToken = ({
|
|||||||
formatApiCode={formatApiCode}
|
formatApiCode={formatApiCode}
|
||||||
>
|
>
|
||||||
<ApiTokenForm
|
<ApiTokenForm
|
||||||
username={username}
|
|
||||||
type={type}
|
|
||||||
disableProjectSelection={Boolean(project)}
|
|
||||||
projects={projects}
|
|
||||||
environment={environment}
|
|
||||||
setEnvironment={setEnvironment}
|
|
||||||
setTokenType={setTokenType}
|
|
||||||
setUsername={setUsername}
|
|
||||||
setProjects={setProjects}
|
|
||||||
errors={errors}
|
|
||||||
handleSubmit={handleSubmit}
|
handleSubmit={handleSubmit}
|
||||||
handleCancel={handleCancel}
|
handleCancel={handleCancel}
|
||||||
mode="Create"
|
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>
|
</ApiTokenForm>
|
||||||
<ConfirmToken
|
<ConfirmToken
|
||||||
open={showConfirm}
|
open={showConfirm}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
|
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 { CREATE_API_TOKEN_BUTTON } from 'utils/testIds';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Add } from '@mui/icons-material';
|
import { Add } from '@mui/icons-material';
|
||||||
@ -11,13 +14,16 @@ export const CreateApiTokenButton = () => {
|
|||||||
const project = useOptionalPathParam('projectId');
|
const project = useOptionalPathParam('projectId');
|
||||||
|
|
||||||
const to = Boolean(project) ? 'create' : '/admin/api/create-token';
|
const to = Boolean(project) ? 'create' : '/admin/api/create-token';
|
||||||
|
const permission = Boolean(project)
|
||||||
|
? CREATE_PROJECT_API_TOKEN
|
||||||
|
: CREATE_API_TOKEN;
|
||||||
return (
|
return (
|
||||||
<ResponsiveButton
|
<ResponsiveButton
|
||||||
Icon={Add}
|
Icon={Add}
|
||||||
onClick={() => navigate(to)}
|
onClick={() => navigate(to)}
|
||||||
data-testid={CREATE_API_TOKEN_BUTTON}
|
data-testid={CREATE_API_TOKEN_BUTTON}
|
||||||
permission={CREATE_API_TOKEN}
|
permission={permission}
|
||||||
|
projectId={project}
|
||||||
maxWidth="700px"
|
maxWidth="700px"
|
||||||
>
|
>
|
||||||
New API token
|
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 { Delete } from '@mui/icons-material';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { styled } from '@mui/material';
|
||||||
import { IconButton, styled, Tooltip } from '@mui/material';
|
|
||||||
import {
|
import {
|
||||||
IApiToken,
|
IApiToken,
|
||||||
useApiTokens,
|
useApiTokens,
|
||||||
@ -11,6 +13,8 @@ import { useContext, useState } from 'react';
|
|||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
import useApiTokensApi from 'hooks/api/actions/useApiTokensApi/useApiTokensApi';
|
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')({
|
const StyledUl = styled('ul')({
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
@ -18,17 +22,45 @@ const StyledUl = styled('ul')({
|
|||||||
|
|
||||||
interface IRemoveApiTokenButtonProps {
|
interface IRemoveApiTokenButtonProps {
|
||||||
token: IApiToken;
|
token: IApiToken;
|
||||||
|
project?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RemoveApiTokenButton = ({ token }: IRemoveApiTokenButtonProps) => {
|
export const RemoveApiTokenButton = ({
|
||||||
const { hasAccess } = useContext(AccessContext);
|
token,
|
||||||
|
project,
|
||||||
|
}: IRemoveApiTokenButtonProps) => {
|
||||||
|
const { hasAccess, isAdmin } = useContext(AccessContext);
|
||||||
const { deleteToken } = useApiTokensApi();
|
const { deleteToken } = useApiTokensApi();
|
||||||
|
const { deleteToken: deleteProjectToken } = useProjectApiTokensApi();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { setToastData } = useToast();
|
const { setToastData } = useToast();
|
||||||
const { refetch } = useApiTokens();
|
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 () => {
|
const onRemove = async () => {
|
||||||
|
if (project) {
|
||||||
|
await deleteProjectToken(token.secret, project);
|
||||||
|
} else {
|
||||||
await deleteToken(token.secret);
|
await deleteToken(token.secret);
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
refetch();
|
refetch();
|
||||||
setToastData({
|
setToastData({
|
||||||
@ -39,16 +71,16 @@ export const RemoveApiTokenButton = ({ token }: IRemoveApiTokenButtonProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConditionallyRender
|
<PermissionIconButton
|
||||||
condition={hasAccess(DELETE_API_TOKEN)}
|
permission={permission}
|
||||||
show={
|
projectId={project}
|
||||||
<Tooltip title="Delete token" arrow>
|
tooltipProps={{ title: 'Delete token', arrow: true }}
|
||||||
<IconButton onClick={() => setOpen(true)} size="large">
|
onClick={() => setOpen(true)}
|
||||||
|
size="large"
|
||||||
|
disabled={!canRemove()}
|
||||||
|
>
|
||||||
<Delete />
|
<Delete />
|
||||||
</IconButton>
|
</PermissionIconButton>
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Dialogue
|
<Dialogue
|
||||||
open={open}
|
open={open}
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
|
@ -24,7 +24,7 @@ import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironmen
|
|||||||
import EnvironmentTypeSelector from 'component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector';
|
import EnvironmentTypeSelector from 'component/environments/EnvironmentForm/EnvironmentTypeSelector/EnvironmentTypeSelector';
|
||||||
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
|
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
|
||||||
import { EnvironmentProjectSelect } from './EnvironmentProjectSelect/EnvironmentProjectSelect';
|
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 useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||||
import useApiTokensApi, {
|
import useApiTokensApi, {
|
||||||
IApiTokenCreate,
|
IApiTokenCreate,
|
||||||
|
@ -11,7 +11,7 @@ import { caseInsensitiveSearch } from 'utils/search';
|
|||||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
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 }) => ({
|
const StyledOption = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -3,25 +3,27 @@ import { PageContent } from 'component/common/PageContent/PageContent';
|
|||||||
import { Alert } from '@mui/material';
|
import { Alert } from '@mui/material';
|
||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
import AccessContext from 'contexts/AccessContext';
|
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 { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
|
import { useProjectNameOrId } from 'hooks/api/getters/useProject/useProject';
|
||||||
import { ApiTokenTable } from '../../../admin/apiToken/ApiTokenTable/ApiTokenTable';
|
import { ApiTokenTable } from '../../../admin/apiToken/ApiTokenTable/ApiTokenTable';
|
||||||
|
import { useProjectApiTokens } from '../../../../hooks/api/getters/useProjectApiTokens/useProjectApiTokens';
|
||||||
|
|
||||||
export const ProjectApiAccess = () => {
|
export const ProjectApiAccess = () => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
const projectName = useProjectNameOrId(projectId);
|
const projectName = useProjectNameOrId(projectId);
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
const { tokens, loading } = useProjectApiTokens(projectId);
|
||||||
|
|
||||||
usePageTitle(`Project api access – ${projectName}`);
|
usePageTitle(`Project api access – ${projectName}`);
|
||||||
|
|
||||||
if (!hasAccess(UPDATE_PROJECT, projectId)) {
|
if (!hasAccess(READ_PROJECT_API_TOKEN, projectId)) {
|
||||||
return (
|
return (
|
||||||
<PageContent header={<PageHeader title="Api access" />}>
|
<PageContent header={<PageHeader title="Api access" />}>
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
You need project owner or admin permissions to access this
|
You need to be a member of the project or admin to access
|
||||||
section.
|
this section.
|
||||||
</Alert>
|
</Alert>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
@ -29,7 +31,12 @@ export const ProjectApiAccess = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', overflow: 'hidden' }}>
|
<div style={{ width: '100%', overflow: 'hidden' }}>
|
||||||
<ApiTokenTable compact filterForProject={projectId} />
|
<ApiTokenTable
|
||||||
|
tokens={tokens}
|
||||||
|
loading={loading}
|
||||||
|
compact
|
||||||
|
filterForProject={projectId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -47,7 +47,6 @@ export const hasAccess = (
|
|||||||
if (!permissions) {
|
if (!permissions) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return permissions.some(p => {
|
return permissions.some(p => {
|
||||||
return checkPermission(p, permission, project, environment);
|
return checkPermission(p, permission, project, environment);
|
||||||
});
|
});
|
||||||
@ -79,7 +78,7 @@ const checkPermission = (
|
|||||||
if (
|
if (
|
||||||
p.permission === permission &&
|
p.permission === permission &&
|
||||||
(p.project === project || p.project === '*') &&
|
(p.project === project || p.project === '*') &&
|
||||||
p.environment === null
|
!Boolean(p.environment)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -36,3 +36,6 @@ export const DELETE_SEGMENT = 'DELETE_SEGMENT';
|
|||||||
export const APPLY_CHANGE_REQUEST = 'APPLY_CHANGE_REQUEST';
|
export const APPLY_CHANGE_REQUEST = 'APPLY_CHANGE_REQUEST';
|
||||||
export const APPROVE_CHANGE_REQUEST = 'APPROVE_CHANGE_REQUEST';
|
export const APPROVE_CHANGE_REQUEST = 'APPROVE_CHANGE_REQUEST';
|
||||||
export const SKIP_CHANGE_REQUEST = 'SKIP_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,
|
projectOverviewSchema,
|
||||||
} from '../../../../lib/openapi';
|
} from '../../../../lib/openapi';
|
||||||
import { IArchivedQuery, IProjectParam } from '../../../types/model';
|
import { IArchivedQuery, IProjectParam } from '../../../types/model';
|
||||||
|
import { ProjectApiTokenController } from './api-token';
|
||||||
|
|
||||||
export default class ProjectApi extends Controller {
|
export default class ProjectApi extends Controller {
|
||||||
private projectService: ProjectService;
|
private projectService: ProjectService;
|
||||||
@ -68,6 +69,7 @@ export default class ProjectApi extends Controller {
|
|||||||
this.use('/', new EnvironmentsController(config, services).router);
|
this.use('/', new EnvironmentsController(config, services).router);
|
||||||
this.use('/', new ProjectHealthReport(config, services).router);
|
this.use('/', new ProjectHealthReport(config, services).router);
|
||||||
this.use('/', new VariantsController(config, services).router);
|
this.use('/', new VariantsController(config, services).router);
|
||||||
|
this.use('/', new ProjectApiTokenController(config, services).router);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProjects(
|
async getProjects(
|
||||||
|
@ -42,3 +42,6 @@ export const DELETE_SEGMENT = 'DELETE_SEGMENT';
|
|||||||
export const APPROVE_CHANGE_REQUEST = 'APPROVE_CHANGE_REQUEST';
|
export const APPROVE_CHANGE_REQUEST = 'APPROVE_CHANGE_REQUEST';
|
||||||
export const APPLY_CHANGE_REQUEST = 'APPLY_CHANGE_REQUEST';
|
export const APPLY_CHANGE_REQUEST = 'APPLY_CHANGE_REQUEST';
|
||||||
export const SKIP_CHANGE_REQUEST = 'SKIP_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": {
|
"/api/admin/projects/{projectId}/environments": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "addEnvironmentToProject",
|
"operationId": "addEnvironmentToProject",
|
||||||
|
Loading…
Reference in New Issue
Block a user