1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: Onboarding connect api token generation (#8054)

This commit is contained in:
Mateusz Kwasniewski 2024-09-03 11:28:16 +02:00 committed by GitHub
parent 29af716952
commit 6a51a0b14a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 346 additions and 83 deletions

View File

@ -5,12 +5,20 @@ import {
styled,
type Theme,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { SingleSelectConfigButton } from '../common/DialogFormTemplate/ConfigButtons/SingleSelectConfigButton';
import EnvironmentsIcon from '@mui/icons-material/CloudCircle';
import { useEffect, useState } from 'react';
import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
import CodeIcon from '@mui/icons-material/Code';
import { useProjectApiTokens } from 'hooks/api/getters/useProjectApiTokens/useProjectApiTokens';
import { ArcherContainer, ArcherElement } from 'react-archer';
import useProjectApiTokensApi from 'hooks/api/actions/useProjectApiTokensApi/useProjectApiTokensApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast';
import { parseToken } from './parseToken';
interface IConnectSDKDialogProps {
open: boolean;
@ -19,10 +27,11 @@ interface IConnectSDKDialogProps {
environments: string[];
}
const ConceptsDefinitions = styled('div')(({ theme }) => ({
const ConceptsDefinitionsWrapper = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.background.sidebar,
padding: theme.spacing(8),
flexBasis: '30%',
padding: theme.spacing(6),
flex: 0,
minWidth: '400px',
}));
const IconStyle = ({ theme }: { theme: Theme }) => ({
@ -59,7 +68,7 @@ export const APIKeyGeneration = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.background.paper,
display: 'flex',
flexDirection: 'column',
flexBasis: '70%',
flex: 1,
}));
export const SpacedContainer = styled('div')(({ theme }) => ({
@ -106,15 +115,198 @@ const NextStepSectionSpacedContainer = styled('div')(({ theme }) => ({
padding: theme.spacing(3, 8, 3, 8),
}));
const SecretExplanation = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.background.elevation1,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(3),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}));
const SecretExplanationDescription = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.primary.contrastText,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(2),
flex: 1,
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
}));
const ConceptsDefinitions = () => (
<ConceptsDefinitionsWrapper>
<ConceptItem>
<StyledProjectIcon />
<Box>
<ConceptSummary>Flags live in projects</ConceptSummary>
<ConceptDetails>
Projects are containers for feature flags. When you create a
feature flag it will belong to the project you create it in.
</ConceptDetails>
</Box>
</ConceptItem>
<ConceptItem>
<StyledEnvironmentsIcon />
<Box>
<ConceptSummary>
Flags have configuration in environments
</ConceptSummary>
<ConceptDetails>
In Unleash you can have multiple environments. Each feature
flag will have different configuration in every environment.
</ConceptDetails>
</Box>
</ConceptItem>
<ConceptItem>
<StyledCodeIcon />
<Box>
<ConceptSummary>
SDKs connect to Unleash to retrieve configuration
</ConceptSummary>
<ConceptDetails>
When you connect an SDK to Unleash it will use the API key
to deduce which feature flags to retrieve and from which
environment to retrieve configuration.
</ConceptDetails>
</Box>
</ConceptItem>
</ConceptsDefinitionsWrapper>
);
const TokenExplanationBox = styled(Box)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(2),
alignItems: 'flex-start',
marginTop: theme.spacing(8),
flexWrap: 'wrap',
}));
const TokenExplanation = ({
project,
environment,
secret,
}: { project: string; environment: string; secret: string }) => {
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('lg'));
return (
<ArcherContainer
strokeColor={theme.palette.secondary.border}
endMarker={false}
lineStyle='curve'
>
<SecretExplanation>
<Box sx={{ wordBreak: 'break-all' }}>
<ArcherElement id='project'>
<span>{project}</span>
</ArcherElement>
:
<ArcherElement id='environment'>
<span>{environment}</span>
</ArcherElement>
.
<ArcherElement id='secret'>
<span>{secret}</span>
</ArcherElement>
</Box>
{isLargeScreen ? (
<TokenExplanationBox>
<ArcherElement
id='project-description'
relations={[
{
targetId: 'project',
targetAnchor: 'bottom',
sourceAnchor: 'top',
},
]}
>
<SecretExplanationDescription>
The project this API key will retrieve feature
flags from
</SecretExplanationDescription>
</ArcherElement>
<ArcherElement
id='environment-description'
relations={[
{
targetId: 'environment',
targetAnchor: 'bottom',
sourceAnchor: 'top',
},
]}
>
<SecretExplanationDescription>
The environment the API key will retrieve
feature flag configuration from
</SecretExplanationDescription>
</ArcherElement>
<ArcherElement
id='secreat-description'
relations={[
{
targetId: 'secret',
targetAnchor: 'bottom',
sourceAnchor: 'top',
},
]}
>
<SecretExplanationDescription>
The API key secret
</SecretExplanationDescription>
</ArcherElement>
</TokenExplanationBox>
) : null}
</SecretExplanation>
</ArcherContainer>
);
};
const ChooseEnvironment = ({
environments,
onSelect,
currentEnvironment,
}: {
environments: string[];
currentEnvironment: string;
onSelect: (env: string) => void;
}) => {
const longestEnv = Math.max(
...environments.map((environment) => environment.length),
);
return (
<SingleSelectConfigButton
tooltip={{ header: '' }}
description='Select the environment where API key will be created'
options={environments.map((environment) => ({
label: environment,
value: environment,
}))}
onChange={(value: any) => {
onSelect(value);
}}
button={{
label: currentEnvironment,
icon: <EnvironmentsIcon />,
labelWidth: `${longestEnv + 5}ch`,
}}
search={{
label: 'Filter project mode options',
placeholder: 'Select project mode',
}}
/>
);
};
export const ConnectSDKDialog = ({
open,
onClose,
environments,
project,
}: IConnectSDKDialogProps) => {
const [environment, setEnvironment] = useState('');
const longestEnv = Math.max(
...environments.map((environment) => environment.length),
);
useEffect(() => {
if (environments.length > 0) {
@ -122,6 +314,34 @@ export const ConnectSDKDialog = ({
}
}, [JSON.stringify(environments)]);
const { tokens, refetch: refreshTokens } = useProjectApiTokens(project);
const { createToken, loading: creatingToken } = useProjectApiTokensApi();
const currentEnvironmentToken = tokens.find(
(token) => token.environment === environment,
);
const parsedToken = parseToken(currentEnvironmentToken?.secret);
const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('lg'));
const { setToastApiError } = useToast();
const generateAPIKey = async () => {
try {
await createToken(
{
environment,
type: 'CLIENT',
projects: [project],
username: 'onboarding-api-key',
},
project,
);
refreshTokens();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return (
<StyledDialog open={open} onClose={onClose}>
<Box sx={{ display: 'flex' }}>
@ -130,49 +350,54 @@ export const ConnectSDKDialog = ({
<Typography variant='h2'>
Connect an SDK to Unleash
</Typography>
<Box>
<Box sx={{ mt: 4 }}>
<SectionHeader>Environment</SectionHeader>
<SectionDescription>
The environment SDK will connect to in order to
retrieve configuration.
</SectionDescription>
{environments.length > 0 ? (
<SingleSelectConfigButton
tooltip={{ header: '' }}
description='Select the environment where API key will be created'
options={environments.map(
(environment) => ({
label: environment,
value: environment,
}),
)}
onChange={(value: any) => {
setEnvironment(value);
}}
button={{
label: environment,
icon: <EnvironmentsIcon />,
labelWidth: `${longestEnv + 5}ch`,
}}
search={{
label: 'Filter project mode options',
placeholder: 'Select project mode',
}}
<ChooseEnvironment
environments={environments}
currentEnvironment={environment}
onSelect={setEnvironment}
/>
) : null}
</Box>
<Box sx={{ mt: 3 }}>
<SectionHeader>API Key</SectionHeader>
<SectionDescription>
You currently have no active API keys for this
project/environment combination. You'll need to
generate and API key in order to proceed with
connecting your SDK.
</SectionDescription>
<Button variant='contained'>
Generate API Key
</Button>
{parsedToken ? (
<SectionDescription>
Here is your generated API key. We will use
it to connect to the{' '}
<b>{parsedToken.project}</b> project in the{' '}
<b>{parsedToken.environment}</b>{' '}
environment.
</SectionDescription>
) : (
<SectionDescription>
You currently have no active API keys for
this project/environment combination. You'll
need to generate and API key in order to
proceed with connecting your SDK.
</SectionDescription>
)}
{parsedToken ? (
<TokenExplanation
project={parsedToken.project}
environment={parsedToken.environment}
secret={parsedToken.secret}
/>
) : (
<Button
variant='contained'
disabled={creatingToken}
onClick={generateAPIKey}
>
Generate API Key
</Button>
)}
</Box>
</SpacedContainer>
@ -185,49 +410,8 @@ export const ConnectSDKDialog = ({
</NextStepSectionSpacedContainer>
</NextStepSection>
</APIKeyGeneration>
<ConceptsDefinitions>
<ConceptItem>
<StyledProjectIcon />
<Box>
<ConceptSummary>
Flags live in projects
</ConceptSummary>
<ConceptDetails>
Projects are containers for feature flags. When
you create a feature flag it will belong to the
project you create it in.
</ConceptDetails>
</Box>
</ConceptItem>
<ConceptItem>
<StyledEnvironmentsIcon />
<Box>
<ConceptSummary>
Flags have configuration in environments
</ConceptSummary>
<ConceptDetails>
In Unleash you can have multiple environments.
Each feature flag will have different
configuration in every environment.
</ConceptDetails>
</Box>
</ConceptItem>
<ConceptItem>
<StyledCodeIcon />
<Box>
<ConceptSummary>
SDKs connect to Unleash to retrieve
configuration
</ConceptSummary>
<ConceptDetails>
When you connect an SDK to Unleash it will use
the API key to deduce which feature flags to
retrieve and from which environment to retrieve
configuration.
</ConceptDetails>
</Box>
</ConceptItem>
</ConceptsDefinitions>
{isLargeScreen ? <ConceptsDefinitions /> : null}
</Box>
</StyledDialog>
);

View File

@ -0,0 +1,59 @@
import { parseToken } from './parseToken';
describe('parseToken', () => {
test('should return null if token is undefined', () => {
const result = parseToken(undefined);
expect(result).toBeNull();
});
test('should return null if token is an empty string', () => {
const result = parseToken('');
expect(result).toBeNull();
});
test('should return the correct object when a valid token is provided', () => {
const token = 'project123:env456.secret789';
const result = parseToken(token);
expect(result).toEqual({
project: 'project123',
environment: 'env456',
secret: 'secret789',
});
});
test('should return null if the token does not have a colon', () => {
const token = 'project123env456.secret789';
const result = parseToken(token);
expect(result).toBeNull();
});
test('should return null if the token does not have a period', () => {
const token = 'project123:env456secret789';
const result = parseToken(token);
expect(result).toBeNull();
});
test('should return null if the token has an incomplete project part', () => {
const token = ':env456.secret789';
const result = parseToken(token);
expect(result).toBeNull();
});
test('should return null if the token has an incomplete environment part', () => {
const token = 'project123:.secret789';
const result = parseToken(token);
expect(result).toBeNull();
});
test('should return null if the token has an incomplete secret part', () => {
const token = 'project123:env456.';
const result = parseToken(token);
expect(result).toBeNull();
});
test('should return null if the token has extra parts', () => {
const token = 'project123:env456.secret789.extra';
const result = parseToken(token);
expect(result).toBeNull();
});
});

View File

@ -0,0 +1,20 @@
export const parseToken = (
token?: string,
): { project: string; environment: string; secret: string } | null => {
if (!token) return null;
const [project, rest] = token.split(':', 2);
if (!rest) return null;
const [environment, secret, ...extra] = rest.split('.');
if (project && environment && secret && extra.length === 0) {
return {
project,
environment,
secret,
};
}
return null;
};

View File

@ -94,7 +94,7 @@ export const theme = {
contrastText: colors.grey[50], // Color used for content when primary.main is used as a background
},
secondary: {
// Used for purple badges and puple light elements
// Used for purple badges and purple light elements
main: colors.purple[800],
light: colors.purple[50],
dark: colors.purple[900], // Color used for text
@ -150,7 +150,7 @@ export const theme = {
default: colors.grey[50],
application: colors.grey[300],
sidebar: colors.purple[800],
alternative: colors.purple[800], // used on the dark theme to shwitch primary main to a darker shade
alternative: colors.purple[800], // used on the dark theme to switch primary main to a darker shade
elevation1: colors.grey[100],
elevation2: colors.grey[200],
},