diff --git a/web-server/public/assets/FST_permissions.png b/web-server/public/assets/FST_permissions.png new file mode 100644 index 000000000..66cf91ae7 Binary files /dev/null and b/web-server/public/assets/FST_permissions.png differ diff --git a/web-server/src/constants/style.ts b/web-server/src/constants/style.ts new file mode 100644 index 000000000..d51da88ce --- /dev/null +++ b/web-server/src/constants/style.ts @@ -0,0 +1,15 @@ +export const ClassicStyles = [ + { height: '42px', top: '230px' }, + { height: '120px', top: '378px' }, + { height: '120px', top: '806px' } +]; + +export const FineGrainedStyles = [ + { height: '123px', top: '185px' }, + { height: '125px', top: '750px' }, + { height: '52px', top: '1106px' }, + { height: '125px', top: '1675px' }, + { height: '52px', top: '2030px' }, + { height: '52px', top: '2516px' }, + { height: '125px', top: '3225px' } +]; diff --git a/web-server/src/content/Dashboards/ConfigureGithubModalBody.tsx b/web-server/src/content/Dashboards/ConfigureGithubModalBody.tsx index e1c36692c..5ef51a60f 100644 --- a/web-server/src/content/Dashboards/ConfigureGithubModalBody.tsx +++ b/web-server/src/content/Dashboards/ConfigureGithubModalBody.tsx @@ -1,5 +1,12 @@ import { LoadingButton } from '@mui/lab'; -import { Divider, Link, TextField, alpha } from '@mui/material'; +import { + Divider, + Link, + TextField, + alpha, + ToggleButton, + ToggleButtonGroup +} from '@mui/material'; import Image from 'next/image'; import { useSnackbar } from 'notistack'; import { FC, useCallback, useMemo } from 'react'; @@ -7,15 +14,19 @@ import { FC, useCallback, useMemo } from 'react'; import { FlexBox } from '@/components/FlexBox'; import { Line } from '@/components/Text'; import { Integration } from '@/constants/integrations'; +import { ClassicStyles, FineGrainedStyles } from '@/constants/style'; import { useAuth } from '@/hooks/useAuth'; import { useBoolState, useEasyState } from '@/hooks/useEasyState'; import { fetchCurrentOrg } from '@/slices/auth'; import { fetchTeams } from '@/slices/team'; import { useDispatch } from '@/store'; +import { GithubTokenType } from '@/types/resources'; import { checkGitHubValidity, linkProvider, - getMissingPATScopes + getMissingPATScopes, + getMissingFineGrainedScopes, + getTokenType } from '@/utils/auth'; import { checkDomainWithRegex } from '@/utils/domainCheck'; import { depFn } from '@/utils/fn'; @@ -29,6 +40,8 @@ export const ConfigureGithubModalBody: FC<{ const customDomain = useEasyState(''); const dispatch = useDispatch(); const isLoading = useBoolState(); + const tokenType = useEasyState(GithubTokenType.CLASSIC); + const isTokenValid = useBoolState(false); const showError = useEasyState(''); const showDomainError = useEasyState(''); @@ -49,13 +62,34 @@ export const ConfigureGithubModalBody: FC<{ const handleChange = (e: string) => { token.set(e); - showError.set(''); + const detectedType = getTokenType(e); + + if (detectedType === 'unknown') { + setError('Invalid token format'); + isTokenValid.false(); + } else if (detectedType !== tokenType.value) { + setError( + `Token format doesn't match selected type. Expected ${tokenType.value} token.` + ); + isTokenValid.false(); + } else { + showError.set(''); + isTokenValid.true(); + } }; + const handleDomainChange = (e: string) => { customDomain.set(e); showDomainError.set(''); }; + const handleTokenTypeChange = (value: GithubTokenType) => { + tokenType.set(value); + token.set(''); // Reset token when switching token types + showError.set(''); + isTokenValid.false(); + }; + const handleSubmission = useCallback(async () => { if (!token.value) { setError('Please enter a valid token'); @@ -80,17 +114,25 @@ export const ConfigureGithubModalBody: FC<{ return; } - const missingScopes = await getMissingPATScopes( - token.value, - customDomain.valueRef.current - ); + const missingScopes = + tokenType.value === GithubTokenType.CLASSIC + ? await getMissingPATScopes( + token.value, + customDomain.valueRef.current + ) + : await getMissingFineGrainedScopes( + token.value, + customDomain.valueRef.current + ); + if (missingScopes.length > 0) { setError(`Token is missing scopes: ${missingScopes.join(', ')}`); return; } await linkProvider(token.value, orgId, Integration.GITHUB, { - custom_domain: customDomain.valueRef.current + custom_domain: customDomain.valueRef.current, + token_type: tokenType.value }); dispatch(fetchCurrentOrg()); @@ -109,6 +151,7 @@ export const ConfigureGithubModalBody: FC<{ }, [ token.value, customDomain.value, + tokenType.value, dispatch, enqueueSnackbar, isLoading.false, @@ -123,15 +166,27 @@ export const ConfigureGithubModalBody: FC<{ const focusDomainInput = useCallback(() => { if (!customDomain.value) - document.getElementById('gitlab-custom-domain')?.focus(); - else handleSubmission(); - }, [customDomain.value, handleSubmission]); + document.getElementById('github-custom-domain')?.focus(); + }, [customDomain.value]); return ( - Enter you Github token below. + Enter your Github token below. + value && handleTokenTypeChange(value)} + sx={{ mb: 2 }} + > + + Classic Token + + + Fine Grained Token + + { if (e.key === 'Enter') { @@ -149,7 +204,11 @@ export const ConfigureGithubModalBody: FC<{ onChange={(e) => { handleChange(e.currentTarget.value); }} - label="Github Personal Access Token" + label={`Github ${ + tokenType.value === GithubTokenType.CLASSIC + ? 'Personal Access Token' + : 'Fine Grained Token' + }`} type="password" /> @@ -158,7 +217,11 @@ export const ConfigureGithubModalBody: FC<{ @@ -168,7 +231,11 @@ export const ConfigureGithubModalBody: FC<{ textUnderlineOffset: '2px' }} > - Generate new classic token + Generate new{' '} + {tokenType.value === GithubTokenType.CLASSIC + ? 'classic' + : 'fine-grained'}{' '} + token {' ->'} @@ -217,10 +284,16 @@ export const ConfigureGithubModalBody: FC<{ Learn more about Github - Personal Access Token (PAT) + {tokenType.value === GithubTokenType.CLASSIC + ? 'Personal Access Token (PAT)' + : 'Fine Grained Token (FGT)'} @@ -231,6 +304,7 @@ export const ConfigureGithubModalBody: FC<{ @@ -240,12 +314,14 @@ export const ConfigureGithubModalBody: FC<{ - + ); }; -const TokenPermissions = () => { +const TokenPermissions: FC<{ tokenType: GithubTokenType }> = ({ + tokenType +}) => { const imageLoaded = useBoolState(false); const expandedStyles = useMemo(() => { @@ -254,33 +330,16 @@ const TokenPermissions = () => { transition: 'all 0.8s ease', borderRadius: '12px', opacity: 1, - width: '240px', + width: tokenType === GithubTokenType.CLASSIC ? '250px' : '790px', position: 'absolute', maxWidth: 'calc(100% - 48px)', left: '24px' }; - return [ - { - height: '170px', - top: '58px' - }, - { - height: '42px', - top: '230px' - }, - { - height: '120px', - - top: '378px' - }, - { - height: '120px', - - top: '806px' - } - ].map((item) => ({ ...item, ...baseStyles })); - }, []); + const styles = + tokenType === GithubTokenType.CLASSIC ? ClassicStyles : FineGrainedStyles; + return styles.map((style) => ({ ...style, ...baseStyles })); + }, [tokenType]); return ( @@ -304,10 +363,18 @@ const TokenPermissions = () => { transition: 'all 0.8s ease', opacity: !imageLoaded.value ? 0 : 1 }} - src="/assets/PAT_permissions.png" + src={ + tokenType === GithubTokenType.CLASSIC + ? '/assets/PAT_permissions.png' + : '/assets/FST_permissions.png' + } width={816} - height={1257} - alt="PAT_permissions" + height={tokenType === GithubTokenType.CLASSIC ? 1257 : 3583} + alt={ + tokenType === GithubTokenType.CLASSIC + ? 'PAT_permissions' + : 'FST_permissions' + } /> {imageLoaded.value && diff --git a/web-server/src/types/resources.ts b/web-server/src/types/resources.ts index ba65b0b2b..aaf65372b 100644 --- a/web-server/src/types/resources.ts +++ b/web-server/src/types/resources.ts @@ -922,6 +922,11 @@ export enum DeploymentSources { WORKFLOW = 'WORKFLOW' } +export enum GithubTokenType { + CLASSIC = 'classic', + FINE_GRAINED = 'fine-grained' +} + export type DeploymentSourceResponse = { team_id: ID; is_active: boolean; diff --git a/web-server/src/utils/auth.ts b/web-server/src/utils/auth.ts index 22852b373..aa9a8985e 100644 --- a/web-server/src/utils/auth.ts +++ b/web-server/src/utils/auth.ts @@ -30,16 +30,17 @@ export const linkProvider = async ( export async function checkGitHubValidity( good_stuff: string, - customDomain?: string + customDomain?: string, + tokenType: 'classic' | 'fine-grained' = 'classic' ): Promise { try { - // if customDomain is provded, the host will be customDomain/api/v3 - // else it will be api.github.com(default) const baseUrl = customDomain ? `${customDomain}/api/v3` : DEFAULT_GH_URL; + const authHeader = + tokenType === 'classic' ? `token ${good_stuff}` : `Bearer ${good_stuff}`; await axios.get(`${baseUrl}/user`, { headers: { - Authorization: `token ${good_stuff}` + Authorization: authHeader } }); return true; @@ -49,6 +50,13 @@ export async function checkGitHubValidity( } const PAT_SCOPES = ['read:org', 'read:user', 'repo', 'workflow']; +const FINE_GRAINED_SCOPES = [ + 'contents:read', + 'metadata:read', + 'pull_requests:read', + 'workflows:read' +]; + export const getMissingPATScopes = async ( pat: string, customDomain?: string @@ -71,6 +79,59 @@ export const getMissingPATScopes = async ( } }; +export const getMissingFineGrainedScopes = async ( + token: string, + customDomain?: string +): Promise => { + const baseUrl = customDomain ? `${customDomain}/api/v3` : DEFAULT_GH_URL; + const headers = { Authorization: `Bearer ${token}` }; + + try { + const [userResponse, reposResponse] = await Promise.all([ + axios.get(`${baseUrl}/user`, { headers }), + axios.get(`${baseUrl}/user/repos?per_page=1`, { headers }) + ]); + + const owner = userResponse.data.login; + const repo = reposResponse.data[0]?.name; + if (!repo) { + throw new Error('No repositories found'); + } + + const permissionTests = [ + { + scope: 'pull_requests:read', + endpoint: `/repos/${owner}/${repo}/pulls?per_page=1` + }, + { + scope: 'workflows:read', + endpoint: `/repos/${owner}/${repo}/actions/runs?per_page=1` + } + ]; + + const missingScopes = await Promise.all( + permissionTests.map(async ({ scope, endpoint }) => { + try { + await axios.get(`${baseUrl}${endpoint}`, { headers }); + return null; + } catch (error: unknown) { + const err = error as { response?: { status?: number } }; + return err.response?.status === 403 ? scope : null; + } + }) + ); + + return missingScopes.filter(Boolean) as string[]; + } catch (error: unknown) { + const err = error as { response?: { status?: number } }; + if (err.response?.status === 403) { + return ['metadata:read', 'contents:read']; + } + console.warn('Error verifying token scopes', error); + return FINE_GRAINED_SCOPES; + } +}; + // Gitlab functions export const checkGitLabValidity = async ( @@ -99,3 +160,11 @@ export const getMissingGitLabScopes = (scopes: string[]): string[] => { ); return missingScopes; }; + +export const getTokenType = ( + token: string +): 'classic' | 'fine-grained' | 'unknown' => { + if (token.startsWith('ghp_')) return 'classic'; + if (token.startsWith('github_pat_')) return 'fine-grained'; + return 'unknown'; +};