diff --git a/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx index a1492981f3..4a0625afee 100644 --- a/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx +++ b/frontend/src/component/addons/AddonForm/AddonMultiSelector/AddonMultiSelector.tsx @@ -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, diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.tsx new file mode 100644 index 0000000000..3e79fec8f5 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.styles.tsx @@ -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', +}); diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx index ffad8ce36b..567e4f71b0 100644 --- a/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/ApiTokenForm.tsx @@ -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>; - setProjects: React.Dispatch>; - setEnvironment: React.Dispatch>; 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 = ({ 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 = ({ } /> - - - What would you like to call this token? - - setUsername(e.target.value)} - label="Token name" - error={errors.username !== undefined} - errorText={errors.username} - onFocus={() => clearErrors('username')} - autoFocus - /> - - - What do you want to connect? - - setTokenType(value)} - > - {selectableTypes.map(({ key, label, title }) => ( - - } - label={ - - - {label} - - {title} - - - - } - /> - ))} - - - {!Boolean(disableProjectSelection) && ( - <> - - Which project do you want to give access to? - - clearErrors('projects')} - /> - - )} - - Which environment should the token have access to? - - - + {children} - {children} + {actions} Cancel diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/EnvironmentSelector/EnvironmentSelector.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/EnvironmentSelector/EnvironmentSelector.tsx new file mode 100644 index 0000000000..894dcec623 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/EnvironmentSelector/EnvironmentSelector.tsx @@ -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>; +} +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 ( + <> + + Which environment should the token have access to? + + + + ); +}; diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/ProjectSelector/ProjectSelector.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/ProjectSelector/ProjectSelector.tsx new file mode 100644 index 0000000000..7e78e35649 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/ProjectSelector/ProjectSelector.tsx @@ -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>; + 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 ( + <> + + Which project do you want to give access to? + + clearErrors('projects')} + /> + + ); +}; diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectAllButton/SelectAllButton.tsx similarity index 100% rename from frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectAllButton/SelectAllButton.tsx rename to frontend/src/component/admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectAllButton/SelectAllButton.tsx diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.test.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectProjectInput.test.tsx similarity index 100% rename from frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.test.tsx rename to frontend/src/component/admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectProjectInput.test.tsx diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectProjectInput.tsx similarity index 100% rename from frontend/src/component/admin/apiToken/ApiTokenForm/SelectProjectInput/SelectProjectInput.tsx rename to frontend/src/component/admin/apiToken/ApiTokenForm/ProjectSelector/SelectProjectInput/SelectProjectInput.tsx diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/TokenInfo/TokenInfo.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/TokenInfo/TokenInfo.tsx new file mode 100644 index 0000000000..86fbd6546e --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/TokenInfo/TokenInfo.tsx @@ -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>; + + errors: { [key: string]: string }; + clearErrors: (error?: ApiTokenFormErrorType) => void; +} +export const TokenInfo = ({ + username, + setUsername, + errors, + clearErrors, +}: ITokenInfoProps) => { + return ( + <> + + What would you like to call this token? + + setUsername(e.target.value)} + label="Token name" + error={errors.username !== undefined} + errorText={errors.username} + onFocus={() => clearErrors('username')} + autoFocus + /> + + ); +}; diff --git a/frontend/src/component/admin/apiToken/ApiTokenForm/TokenTypeSelector/TokenTypeSelector.tsx b/frontend/src/component/admin/apiToken/ApiTokenForm/TokenTypeSelector/TokenTypeSelector.tsx new file mode 100644 index 0000000000..ce4ca76e68 --- /dev/null +++ b/frontend/src/component/admin/apiToken/ApiTokenForm/TokenTypeSelector/TokenTypeSelector.tsx @@ -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 ( + + + + What do you want to connect? + + setType(value)} + > + {selectableTypes.map(({ key, label, title }) => ( + + } + label={ + + + {label} + + {title} + + + + } + /> + ))} + + + + ); +}; diff --git a/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx b/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx index f5d20b261d..6db09abf5d 100644 --- a/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx +++ b/frontend/src/component/admin/apiToken/ApiTokenPage/ApiTokenPage.tsx @@ -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 ( } + show={() => } elseShow={() => } /> ); diff --git a/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx b/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx index 18759da395..e86896416e 100644 --- a/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx +++ b/frontend/src/component/admin/apiToken/ApiTokenTable/ApiTokenTable.tsx @@ -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: () => } />, + disableSortBy: true, + disableGlobalFilter: true, + }, + { + Header: 'Username', + accessor: 'username', + Cell: HighlightCell, + }, + { + Header: 'Type', + accessor: 'type', + Cell: ({ + value, + }: { + value: 'admin' | 'client' | 'frontend'; + }) => ( + + ), + minWidth: 280, + }, + { + Header: 'Project', + accessor: 'project', + Cell: (props: any) => ( + + ), + 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) => ( + + + + + ), + }, + ]; + }, [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: () => } />, - disableSortBy: true, - disableGlobalFilter: true, - }, - { - Header: 'Username', - accessor: 'username', - Cell: HighlightCell, - }, - { - Header: 'Type', - accessor: 'type', - Cell: ({ value }: { value: 'admin' | 'client' | 'frontend' }) => ( - - ), - minWidth: 280, - }, - { - Header: 'Project', - accessor: 'project', - Cell: (props: any) => ( - - ), - 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) => ( - - - - - ), - }, -]; diff --git a/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx b/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx index e34e3501fb..62fa07cf0d 100644 --- a/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx +++ b/frontend/src/component/admin/apiToken/CopyApiTokenButton/CopyApiTokenButton.tsx @@ -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 ( - - copyToken(token.secret)} size="large"> - - - + copyToken(token.secret)} + size="large" + disabled={!canCopy()} + > + + ); }; diff --git a/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx index 391843903b..9a63d346cd 100644 --- a/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx +++ b/frontend/src/component/admin/apiToken/CreateApiToken/CreateApiToken.tsx @@ -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} > + } > - + + + + { 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 ( navigate(to)} data-testid={CREATE_API_TOKEN_BUTTON} - permission={CREATE_API_TOKEN} + permission={permission} + projectId={project} maxWidth="700px" > New API token diff --git a/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx b/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx index d71b0fdbff..d34a3b9320 100644 --- a/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx +++ b/frontend/src/component/admin/apiToken/RemoveApiTokenButton/RemoveApiTokenButton.tsx @@ -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 ( <> - - setOpen(true)} size="large"> - - - - } - /> + setOpen(true)} + size="large" + disabled={!canRemove()} + > + + ({ display: 'flex', diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectApiAccess.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectApiAccess.tsx index 815ee3c90a..729dbd4a38 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectApiAccess.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectApiAccess.tsx @@ -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 ( }> - 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. ); @@ -29,7 +31,12 @@ export const ProjectApiAccess = () => { return (
- +
); }; diff --git a/frontend/src/component/providers/AccessProvider/AccessProvider.tsx b/frontend/src/component/providers/AccessProvider/AccessProvider.tsx index ea1649e285..f5a306b048 100644 --- a/frontend/src/component/providers/AccessProvider/AccessProvider.tsx +++ b/frontend/src/component/providers/AccessProvider/AccessProvider.tsx @@ -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; } diff --git a/frontend/src/component/providers/AccessProvider/permissions.ts b/frontend/src/component/providers/AccessProvider/permissions.ts index 1e7ee2c0c4..cbffeb1d90 100644 --- a/frontend/src/component/providers/AccessProvider/permissions.ts +++ b/frontend/src/component/providers/AccessProvider/permissions.ts @@ -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'; diff --git a/frontend/src/hooks/api/actions/useProjectApiTokensApi/useProjectApiTokensApi.ts b/frontend/src/hooks/api/actions/useProjectApiTokensApi/useProjectApiTokensApi.ts new file mode 100644 index 0000000000..53baba7924 --- /dev/null +++ b/frontend/src/hooks/api/actions/useProjectApiTokensApi/useProjectApiTokensApi.ts @@ -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; diff --git a/frontend/src/hooks/api/getters/useProjectApiTokens/useProjectApiTokens.ts b/frontend/src/hooks/api/getters/useProjectApiTokens/useProjectApiTokens.ts new file mode 100644 index 0000000000..4908e41202 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectApiTokens/useProjectApiTokens.ts @@ -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(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 => { + const res = await fetch(path).then( + handleErrorResponses('Project Api tokens') + ); + const data = await res.json(); + return data.tokens; +}; diff --git a/src/lib/routes/admin-api/project/api-token.ts b/src/lib/routes/admin-api/project/api-token.ts new file mode 100644 index 0000000000..7afe8242d1 --- /dev/null +++ b/src/lib/routes/admin-api/project/api-token.ts @@ -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, + ): Promise { + 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, + ): Promise { + 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, + res: Response, + ): Promise { + 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 { + 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)), + ); + } +} diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts index af363ac3f0..c9f0c8d830 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/index.ts @@ -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( diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index 00b3a98b6c..75c6a3c561 100644 --- a/src/lib/types/permissions.ts +++ b/src/lib/types/permissions.ts @@ -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'; diff --git a/src/migrations/20230208084046-project-api-token-permissions.js b/src/migrations/20230208084046-project-api-token-permissions.js new file mode 100644 index 0000000000..33419711b7 --- /dev/null +++ b/src/migrations/20230208084046-project-api-token-permissions.js @@ -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, + ); +}; diff --git a/src/migrations/20230208093627-assign-project-api-token-permissions-editor.js b/src/migrations/20230208093627-assign-project-api-token-permissions-editor.js new file mode 100644 index 0000000000..4f332b59ac --- /dev/null +++ b/src/migrations/20230208093627-assign-project-api-token-permissions-editor.js @@ -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, + ); +}; diff --git a/src/migrations/20230208093750-assign-project-api-token-permissions-owner.js b/src/migrations/20230208093750-assign-project-api-token-permissions-owner.js new file mode 100644 index 0000000000..6dd62156f1 --- /dev/null +++ b/src/migrations/20230208093750-assign-project-api-token-permissions-owner.js @@ -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, + ); +}; diff --git a/src/migrations/20230208093942-assign-project-api-token-permissions-member.js b/src/migrations/20230208093942-assign-project-api-token-permissions-member.js new file mode 100644 index 0000000000..dc81037fea --- /dev/null +++ b/src/migrations/20230208093942-assign-project-api-token-permissions-member.js @@ -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, + ); +}; diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 3a1e50e5a6..b92f4d63f4 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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",