1
0
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:
andreas-unleash 2023-02-17 12:15:36 +02:00 committed by GitHub
parent 683bf2faba
commit 350b55644a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1076 additions and 359 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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