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>
{parsedToken ? (
<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.
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>
<Button variant='contained'>
) : (
<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],
},