diff --git a/frontend/src/component/playground/Playground/AdvancedPlayground.tsx b/frontend/src/component/playground/Playground/AdvancedPlayground.tsx index 6bb3557365..d0e00bb063 100644 --- a/frontend/src/component/playground/Playground/AdvancedPlayground.tsx +++ b/frontend/src/component/playground/Playground/AdvancedPlayground.tsx @@ -17,11 +17,11 @@ import { } from './playground.utils'; import { PlaygroundGuidance } from './PlaygroundGuidance/PlaygroundGuidance'; import { PlaygroundGuidancePopper } from './PlaygroundGuidancePopper/PlaygroundGuidancePopper'; -import Loader from '../../common/Loader/Loader'; +import Loader from 'component/common/Loader/Loader'; import { AdvancedPlaygroundResultsTable } from './AdvancedPlaygroundResultsTable/AdvancedPlaygroundResultsTable'; import { AdvancedPlaygroundResponseSchema } from 'openapi'; import { createLocalStorage } from 'utils/createLocalStorage'; -import { BadRequestError } from '../../../utils/apiUtils'; +import { BadRequestError } from 'utils/apiUtils'; const StyledAlert = styled(Alert)(({ theme }) => ({ marginBottom: theme.spacing(3), @@ -34,6 +34,7 @@ export const AdvancedPlayground: VFC<{ projects: string[]; environments: string[]; context?: string; + token?: string; } = { projects: [], environments: [] }; const { value, setValue } = createLocalStorage( 'AdvancedPlayground:v1', @@ -49,6 +50,7 @@ export const AdvancedPlayground: VFC<{ value.environments, ); const [projects, setProjects] = useState(value.projects); + const [token, setToken] = useState(value.token); const [context, setContext] = useState(value.context); const [results, setResults] = useState< AdvancedPlaygroundResponseSchema | undefined @@ -76,6 +78,7 @@ export const AdvancedPlayground: VFC<{ const environments = resolveEnvironmentsFromUrl(); const projects = resolveProjectsFromUrl(); const context = resolveContextFromUrl(); + const token = resolveTokenFromUrl(); const makePlaygroundRequest = async () => { if (environments && context) { await evaluatePlaygroundContext( @@ -124,6 +127,15 @@ export const AdvancedPlayground: VFC<{ return contextFromUrl; }; + const resolveTokenFromUrl = () => { + let tokenFromUrl = searchParams.get('token'); + if (tokenFromUrl) { + tokenFromUrl = decodeURI(tokenFromUrl); + setToken(tokenFromUrl); + } + return tokenFromUrl; + }; + const evaluatePlaygroundContext = async ( environments: string[] | string, projects: string[] | string, @@ -249,6 +261,8 @@ export const AdvancedPlayground: VFC<{ availableEnvironments={availableEnvironments} projects={projects} environments={environments} + token={token} + setToken={setToken} setProjects={setProjects} setEnvironments={setEnvironments} /> diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.test.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.test.tsx new file mode 100644 index 0000000000..cd0242d4e8 --- /dev/null +++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.test.tsx @@ -0,0 +1,150 @@ +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import { render } from 'utils/testRenderer'; +import { fireEvent, screen, within } from '@testing-library/react'; +import { PlaygroundConnectionFieldset } from './PlaygroundConnectionFieldset'; +import { useState } from 'react'; + +const server = testServerSetup(); + +beforeEach(() => { + testServerRoute(server, '/api/admin/ui-config', { + versionInfo: { + current: { oss: 'version', enterprise: 'version' }, + }, + flags: { + playgroundImprovements: true, + }, + }); + testServerRoute( + server, + '/api/admin/projects', + { + projects: [ + { + id: 'default', + name: 'Default', + }, + { + id: 'MyProject', + name: 'MyProject', + }, + ], + }, + 'get', + 200, + ); + testServerRoute( + server, + '/api/admin/api-tokens', + { + tokens: [ + { + secret: '[]:development.964a287e1b728cb5f4f3e0120df92cb5', + projects: ['default', 'MyProject'], + }, + ], + }, + 'get', + 200, + ); +}); + +const Component = () => { + const [environments, setEnvironments] = useState([]); + const [projects, setProjects] = useState([]); + const [token, setToken] = useState(); + + const availableEnvironments = ['development', 'production']; + + return ( + + ); +}; + +test('should parse project and environment from token input', async () => { + render(); + + const tokenInput = await screen.findByLabelText('Api token'); + fireEvent.change(tokenInput, { + target: { + value: 'default:development.964a287e1b728cb5f4f3e0120df92cb5', + }, + }); + + const projectAutocomplete = await screen.findByTestId( + 'PLAYGROUND_PROJECT_SELECT', + ); + const projectInput = within(projectAutocomplete).getByRole('combobox'); + + const environmentAutocomplete = await screen.findByTestId( + 'PLAYGROUND_ENVIRONMENT_SELECT', + ); + const environmentInput = within(environmentAutocomplete).getByRole( + 'combobox', + ); + + expect(projectInput).toBeDisabled(); + expect(environmentInput).toBeDisabled(); + await within(projectAutocomplete).findByText('Default'); + await within(environmentAutocomplete).findByText('development'); +}); + +test('should load projects from token definition if project is []', async () => { + render(); + + const tokenInput = await screen.findByLabelText('Api token'); + fireEvent.change(tokenInput, { + target: { value: '[]:development.964a287e1b728cb5f4f3e0120df92cb5' }, + }); + + const projectAutocomplete = await screen.findByTestId( + 'PLAYGROUND_PROJECT_SELECT', + ); + const projectInput = within(projectAutocomplete).getByRole('combobox'); + + const environmentAutocomplete = await screen.findByTestId( + 'PLAYGROUND_ENVIRONMENT_SELECT', + ); + const environmentInput = within(environmentAutocomplete).getByRole( + 'combobox', + ); + + expect(projectInput).toBeDisabled(); + expect(environmentInput).toBeDisabled(); + await within(projectAutocomplete).findByText('Default'); + await within(projectAutocomplete).findByText('MyProject'); + await within(environmentAutocomplete).findByText('development'); +}); + +test('should show an error when admin token', async () => { + render(); + + const tokenInput = await screen.findByLabelText('Api token'); + fireEvent.change(tokenInput, { + target: { value: '*:*.964a287e1b728cb5f4f3e0120df92cb5' }, + }); + + const projectAutocomplete = await screen.findByTestId( + 'PLAYGROUND_PROJECT_SELECT', + ); + const projectInput = within(projectAutocomplete).getByRole('combobox'); + + const environmentAutocomplete = await screen.findByTestId( + 'PLAYGROUND_ENVIRONMENT_SELECT', + ); + const environmentInput = within(environmentAutocomplete).getByRole( + 'combobox', + ); + + expect(projectInput).toBeDisabled(); + expect(environmentInput).toBeDisabled(); + await screen.findByText('Admin tokens are not supported in the playground'); +}); diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx index 9bb4b7de80..a8be4be041 100644 --- a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx @@ -1,19 +1,33 @@ -import React, { ComponentProps, VFC } from 'react'; +import React, { ComponentProps, useState, VFC } from 'react'; import { Autocomplete, Box, TextField, + Tooltip, Typography, useTheme, } from '@mui/material'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; import { renderOption } from '../renderOption'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useUiFlag } from 'hooks/useUiFlag'; +import { + IApiToken, + useApiTokens, +} from 'hooks/api/getters/useApiTokens/useApiTokens'; +import Input from 'component/common/Input/Input'; +import { + extractProjectEnvironmentFromToken, + validateTokenFormat, +} from '../../playground.utils'; interface IPlaygroundConnectionFieldsetProps { environments: string[]; projects: string[]; + token?: string; setProjects: (projects: string[]) => void; setEnvironments: (environments: string[]) => void; + setToken?: (token: string) => void; availableEnvironments: string[]; } @@ -29,11 +43,16 @@ export const PlaygroundConnectionFieldset: VFC< > = ({ environments, projects, + token, setProjects, setEnvironments, + setToken, availableEnvironments, }) => { const theme = useTheme(); + const playgroundImprovements = useUiFlag('playgroundImprovements'); + const { tokens } = useApiTokens(); + const [tokenError, setTokenError] = useState(); const { projects: availableProjects = [] } = useProjects(); const projectsOptions = [ @@ -96,12 +115,92 @@ export const PlaygroundConnectionFieldset: VFC< }; const isAllProjects = - projects.length === 0 || (projects.length === 1 && projects[0] === '*'); + projects && + (projects.length === 0 || + (projects.length === 1 && projects[0] === '*')); const envValue = environmentOptions.filter(({ id }) => environments.includes(id), ); + const onSetToken: ComponentProps['onChange'] = async ( + event, + ) => { + const tempToken = event.target.value; + setToken?.(tempToken); + + if (tempToken === '') { + resetTokenState(); + return; + } + + try { + validateTokenFormat(tempToken); + setTokenError(undefined); + processToken(tempToken); + } catch (e: any) { + setTokenError(e.message); + } + }; + + const processToken = (tempToken: string) => { + const [tokenProject, tokenEnvironment] = + extractProjectEnvironmentFromToken(tempToken); + setEnvironments([tokenEnvironment]); + + switch (tokenProject) { + case '[]': + handleTokenWithSomeProjects(tempToken); + break; + case '*': + handleTokenWithAllProjects(); + break; + default: + handleSpecificProjectToken(tokenProject); + } + }; + + const updateProjectsBasedOnValidToken = (validToken: IApiToken) => { + if (!validToken.projects || validToken.projects === '*') { + setProjects([allOption.id]); + } else if (typeof validToken.projects === 'string') { + setProjects([validToken.projects]); + } else if (Array.isArray(validToken.projects)) { + setProjects(validToken.projects); + } + }; + + const handleTokenWithSomeProjects = (tempToken: string) => { + const validToken = tokens.find(({ secret }) => secret === tempToken); + if (validToken) { + updateProjectsBasedOnValidToken(validToken); + } else { + setTokenError( + 'Invalid token. Ensure you use a valid token from this Unleash instance.', + ); + } + }; + + const handleTokenWithAllProjects = () => { + setProjects([allOption.id]); + }; + + const handleSpecificProjectToken = (tokenProject: string) => { + if ( + !projectsOptions.map((option) => option.id).includes(tokenProject) + ) { + setTokenError( + `Invalid token. Project ${tokenProject} does not exist.`, + ); + } else { + setProjects([tokenProject]); + } + }; + + const resetTokenState = () => { + setTokenError(undefined); + }; + return ( @@ -114,49 +213,83 @@ export const PlaygroundConnectionFieldset: VFC< - ( - - )} - renderOption={renderOption} - getOptionLabel={({ label }) => label} - disableCloseOnSelect={false} - size='small' - value={envValue} - onChange={onEnvironmentsChange} - data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'} - /> - ( - - )} - renderOption={renderOption} - getOptionLabel={({ label }) => label} - disableCloseOnSelect - size='small' - value={ - isAllProjects - ? allOption - : projectsOptions.filter(({ id }) => - projects.includes(id), - ) + + > + ( + + )} + renderOption={renderOption} + getOptionLabel={({ label }) => label} + disableCloseOnSelect={false} + size='small' + value={envValue} + onChange={onEnvironmentsChange} + disabled={Boolean(token)} + data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'} + /> + + + ( + + )} + renderOption={renderOption} + getOptionLabel={({ label }) => label} + disableCloseOnSelect + size='small' + value={ + isAllProjects + ? allOption + : projectsOptions.filter(({ id }) => + projects.includes(id), + ) + } + onChange={onProjectsChange} + disabled={Boolean(token)} + data-testid={'PLAYGROUND_PROJECT_SELECT'} + /> + + + } + /> ); }; diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx index 8a01617182..e8ffb362ae 100644 --- a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx @@ -9,6 +9,8 @@ interface IPlaygroundFormProps { onSubmit: (event: FormEvent) => void; environments: string | string[]; projects: string[]; + token?: string; + setToken?: React.Dispatch>; setProjects: React.Dispatch>; setEnvironments: React.Dispatch>; context: string | undefined; @@ -20,6 +22,8 @@ export const PlaygroundForm: VFC = ({ environments, onSubmit, projects, + token, + setToken, setProjects, setEnvironments, context, @@ -39,6 +43,8 @@ export const PlaygroundForm: VFC = ({ Array.isArray(environments) ? environments : [environments] } projects={projects} + token={token} + setToken={setToken} setEnvironments={setEnvironments} setProjects={setProjects} availableEnvironments={availableEnvironments.map( diff --git a/frontend/src/component/playground/Playground/playground.utils.test.ts b/frontend/src/component/playground/Playground/playground.utils.test.ts index 16e53201e7..153fd5a46f 100644 --- a/frontend/src/component/playground/Playground/playground.utils.test.ts +++ b/frontend/src/component/playground/Playground/playground.utils.test.ts @@ -1,6 +1,7 @@ import { normalizeCustomContextProperties, NormalizedContextProperties, + validateTokenFormat, } from './playground.utils'; test('should keep standard properties in their place', () => { @@ -76,3 +77,48 @@ test('should add multiple standard properties without breaking custom properties }, }); }); + +describe('validateTokenFormat', () => { + it('should throw an error for invalid token format without colon', () => { + const invalidToken = 'invalidToken'; + expect(() => validateTokenFormat(invalidToken)).toThrow( + 'Invalid token format', + ); + }); + + it('should not throw an error for invalid token format without period', () => { + const invalidToken = 'project:environment'; + expect(() => validateTokenFormat(invalidToken)).not.toThrow(); + }); + + it('should throw an error for tokens with an empty project', () => { + const invalidToken = ':environment.abc123'; + expect(() => validateTokenFormat(invalidToken)).toThrow( + 'Invalid token format', + ); + }); + + it('should throw an error for tokens with an empty environment', () => { + const invalidToken = 'project:.abc123'; + expect(() => validateTokenFormat(invalidToken)).toThrow( + 'Invalid token format', + ); + }); + + it('should throw an error for admin tokens', () => { + const adminToken = 'project:*.abc123'; + expect(() => validateTokenFormat(adminToken)).toThrow( + 'Admin tokens are not supported in the playground', + ); + }); + + it('should not throw an error for valid token formats', () => { + const validToken = 'project:environment.abc123'; + expect(() => validateTokenFormat(validToken)).not.toThrow(); + }); + + it('should not throw an error for valid token format and all projects', () => { + const validToken = '*:environment.abc123'; + expect(() => validateTokenFormat(validToken)).not.toThrow(); + }); +}); diff --git a/frontend/src/component/playground/Playground/playground.utils.ts b/frontend/src/component/playground/Playground/playground.utils.ts index 74a47f9533..a7220fdd59 100644 --- a/frontend/src/component/playground/Playground/playground.utils.ts +++ b/frontend/src/component/playground/Playground/playground.utils.ts @@ -125,3 +125,20 @@ export const normalizeCustomContextProperties = ( return output; }; + +export const validateTokenFormat = (token: string): void => { + const [projectEnvAccess] = token.split('.'); + const [project, environment] = projectEnvAccess.split(':'); + if (!project || !environment) { + throw new Error('Invalid token format'); + } + + if (environment === '*') { + throw new Error('Admin tokens are not supported in the playground'); + } +}; + +export const extractProjectEnvironmentFromToken = (token: string) => { + const [projectEnvAccess] = token.split('.'); + return projectEnvAccess.split(':'); +};