diff --git a/frontend/src/component/onboarding/ConnectSDKDialog.tsx b/frontend/src/component/onboarding/ConnectSDKDialog.tsx index eaad1c0e9d..e5d597173a 100644 --- a/frontend/src/component/onboarding/ConnectSDKDialog.tsx +++ b/frontend/src/component/onboarding/ConnectSDKDialog.tsx @@ -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 = () => ( + + + + + Flags live in projects + + Projects are containers for feature flags. When you create a + feature flag it will belong to the project you create it in. + + + + + + + + Flags have configuration in environments + + + In Unleash you can have multiple environments. Each feature + flag will have different configuration in every environment. + + + + + + + + SDKs connect to Unleash to retrieve configuration + + + 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. + + + + +); + +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 ( + + + + + {project} + + : + + {environment} + + . + + {secret} + + + + {isLargeScreen ? ( + + + + The project this API key will retrieve feature + flags from + + + + + The environment the API key will retrieve + feature flag configuration from + + + + + The API key secret + + + + ) : null} + + + ); +}; + +const ChooseEnvironment = ({ + environments, + onSelect, + currentEnvironment, +}: { + environments: string[]; + currentEnvironment: string; + onSelect: (env: string) => void; +}) => { + const longestEnv = Math.max( + ...environments.map((environment) => environment.length), + ); + + return ( + ({ + label: environment, + value: environment, + }))} + onChange={(value: any) => { + onSelect(value); + }} + button={{ + label: currentEnvironment, + icon: , + 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 ( @@ -130,49 +350,54 @@ export const ConnectSDKDialog = ({ Connect an SDK to Unleash - + Environment The environment SDK will connect to in order to retrieve configuration. {environments.length > 0 ? ( - ({ - label: environment, - value: environment, - }), - )} - onChange={(value: any) => { - setEnvironment(value); - }} - button={{ - label: environment, - icon: , - labelWidth: `${longestEnv + 5}ch`, - }} - search={{ - label: 'Filter project mode options', - placeholder: 'Select project mode', - }} + ) : null} API Key - - 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. - - + {parsedToken ? ( + + Here is your generated API key. We will use + it to connect to the{' '} + {parsedToken.project} project in the{' '} + {parsedToken.environment}{' '} + environment. + + ) : ( + + 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. + + )} + {parsedToken ? ( + + ) : ( + + )} @@ -185,49 +410,8 @@ export const ConnectSDKDialog = ({ - - - - - - Flags live in projects - - - Projects are containers for feature flags. When - you create a feature flag it will belong to the - project you create it in. - - - - - - - - Flags have configuration in environments - - - In Unleash you can have multiple environments. - Each feature flag will have different - configuration in every environment. - - - - - - - - SDKs connect to Unleash to retrieve - configuration - - - 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. - - - - + + {isLargeScreen ? : null} ); diff --git a/frontend/src/component/onboarding/parseToken.test.ts b/frontend/src/component/onboarding/parseToken.test.ts new file mode 100644 index 0000000000..84e89fe67f --- /dev/null +++ b/frontend/src/component/onboarding/parseToken.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/component/onboarding/parseToken.ts b/frontend/src/component/onboarding/parseToken.ts new file mode 100644 index 0000000000..a3aa7846f3 --- /dev/null +++ b/frontend/src/component/onboarding/parseToken.ts @@ -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; +}; diff --git a/frontend/src/themes/theme.ts b/frontend/src/themes/theme.ts index 76037f524a..c1ec1b52f7 100644 --- a/frontend/src/themes/theme.ts +++ b/frontend/src/themes/theme.ts @@ -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], },