1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: add input for api token in playground (#5130)

Adds a token input in playground.

In the case of tokens that span multiple projects ie
`[]:development.etc` it will look into the token definitions to find the
token and get the projects

Otherwise it will try to parse the project and environment from the
token itself (without checking for it being a valid token)

Also, does not support admin tokens `*:*.etc`

Closes #
[1-1507](https://linear.app/unleash/issue/1-1507/create-a-token-input-in-the-playground-form)
<img width="1661" alt="Screenshot 2023-10-23 at 16 38 11"
src="https://github.com/Unleash/unleash/assets/104830839/f2d4fb6e-962f-4cc1-b5e4-817fd2de18ff">
<img width="1673" alt="Screenshot 2023-10-23 at 16 38 33"
src="https://github.com/Unleash/unleash/assets/104830839/27645955-d651-41e6-be02-4381c4f00551">

<img width="1377" alt="Screenshot 2023-10-25 at 17 06 43"
src="https://github.com/Unleash/unleash/assets/104830839/c7638366-3634-4521-af65-4f68a4f3b330">

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2023-10-25 17:55:33 +03:00 committed by GitHub
parent de540e09f3
commit 8e3863a27e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 411 additions and 45 deletions

View File

@ -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<string[]>(value.projects);
const [token, setToken] = useState<string | undefined>(value.token);
const [context, setContext] = useState<string | undefined>(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}
/>

View File

@ -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<string[]>([]);
const [projects, setProjects] = useState<string[]>([]);
const [token, setToken] = useState<string>();
const availableEnvironments = ['development', 'production'];
return (
<PlaygroundConnectionFieldset
environments={environments}
projects={projects}
token={token}
setToken={setToken}
setEnvironments={setEnvironments}
setProjects={setProjects}
availableEnvironments={availableEnvironments}
/>
);
};
test('should parse project and environment from token input', async () => {
render(<Component />);
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(<Component />);
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(<Component />);
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');
});

View File

@ -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<string | undefined>();
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<typeof TextField>['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 (
<Box sx={{ pb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
@ -114,6 +213,13 @@ export const PlaygroundConnectionFieldset: VFC<
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Tooltip
title={
token
? 'Environment is automatically selected because you are using a token'
: 'Select environments to use in the playground'
}
>
<Autocomplete
disablePortal
limitTags={3}
@ -130,8 +236,17 @@ export const PlaygroundConnectionFieldset: VFC<
size='small'
value={envValue}
onChange={onEnvironmentsChange}
disabled={Boolean(token)}
data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'}
/>
</Tooltip>
<Tooltip
title={
token
? 'Project is automatically selected because you are using a token'
: 'Select projects to use in the playground'
}
>
<Autocomplete
disablePortal
id='projects'
@ -154,9 +269,27 @@ export const PlaygroundConnectionFieldset: VFC<
)
}
onChange={onProjectsChange}
disabled={Boolean(token)}
data-testid={'PLAYGROUND_PROJECT_SELECT'}
/>
</Tooltip>
</Box>
<ConditionallyRender
condition={Boolean(playgroundImprovements)}
show={
<Input
sx={{ mt: 2, width: '50%', pr: 1 }}
label='Api token'
value={token || ''}
onChange={onSetToken}
type={'text'}
error={Boolean(tokenError)}
errorText={tokenError}
placeholder={'Enter your api token'}
data-testid={'PLAYGROUND_TOKEN_INPUT'}
/>
}
/>
</Box>
);
};

View File

@ -9,6 +9,8 @@ interface IPlaygroundFormProps {
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
environments: string | string[];
projects: string[];
token?: string;
setToken?: React.Dispatch<React.SetStateAction<string | undefined>>;
setProjects: React.Dispatch<React.SetStateAction<string[]>>;
setEnvironments: React.Dispatch<React.SetStateAction<string[]>>;
context: string | undefined;
@ -20,6 +22,8 @@ export const PlaygroundForm: VFC<IPlaygroundFormProps> = ({
environments,
onSubmit,
projects,
token,
setToken,
setProjects,
setEnvironments,
context,
@ -39,6 +43,8 @@ export const PlaygroundForm: VFC<IPlaygroundFormProps> = ({
Array.isArray(environments) ? environments : [environments]
}
projects={projects}
token={token}
setToken={setToken}
setEnvironments={setEnvironments}
setProjects={setProjects}
availableEnvironments={availableEnvironments.map(

View File

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

View File

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