From 2e28f76488378fa90071287e372900e2d093036a Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 16 Dec 2024 23:48:26 +0530 Subject: [PATCH 1/3] feat: better AuthContext --- source/components/AuthProvider.tsx | 236 +++++++++++++++++++++++++---- source/hooks/useApiKeyApi.ts | 51 +++++++ 2 files changed, 258 insertions(+), 29 deletions(-) diff --git a/source/components/AuthProvider.tsx b/source/components/AuthProvider.tsx index 53eb47a..f712181 100644 --- a/source/components/AuthProvider.tsx +++ b/source/components/AuthProvider.tsx @@ -1,58 +1,236 @@ import React, { createContext, ReactNode, + useCallback, useContext, useEffect, useState, } from 'react'; import { Text } from 'ink'; import { loadAuthToken } from '../lib/auth.js'; +import Login from '../commands/login.js'; +import { ApiKeyScope, useApiKeyApi } from '../hooks/useApiKeyApi.js'; +import { ActiveState } from './EnvironmentSelection.js'; +import LoginFlow from './LoginFlow.js'; +import SelectOrganization from './SelectOrganization.js'; +import SelectProject from './SelectProject.js'; // Define the AuthContext type type AuthContextType = { authToken: string; loading: boolean; - error: string; + error?: string | null; + scope: ApiKeyScope; }; // Create an AuthContext with the correct type const AuthContext = createContext(undefined); -const useProvideAuth = () => { - const [authToken, setAuthToken] = useState(''); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - const fetchAuthToken = async () => { - try { - const token = await loadAuthToken(); - setAuthToken(token); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); +type AuthProviderProps = { + readonly children: ReactNode; + permit_key?: string | null; + scope?: 'organization' | 'project' | 'environment'; +}; + +export function AuthProvider({ + children, + permit_key: key, + scope, +}: AuthProviderProps) { + const { validateApiKeyScope, getApiKeyList, getApiKeyById, getApiKeyScope } = + useApiKeyApi(); + + const [internalAuthToken, setInternalAuthToken] = useState( + null, + ); + const [authToken, setAuthToken] = useState(''); + const [cookie, setCookie] = useState(null); + const [error, setError] = useState(null); + const [state, setState] = useState< + 'loading' | 'validate' | 'organization' | 'project' | 'login' | 'done' + >('loading'); + const [organization, setOrganization] = useState(null); + const [project, setProject] = useState(null); + const [loading, setLoading] = useState(true); + const [currentScope, setCurrentScope] = useState(null); + + useEffect(() => { + if (error) { + process.exit(1); } + }, [error]); - setLoading(false); - }; + useEffect(() => { + if (authToken.length !== 0 && currentScope) { + setLoading(false); + setState('done'); + } + }, [authToken, currentScope]); useEffect(() => { - fetchAuthToken(); - }, []); + const fetchAuthToken = async () => { + try { + const token = await loadAuthToken(); + setAuthToken(token); + const { response, error } = await getApiKeyScope(token); + if (error) { + setError(error); + return; + } + setCurrentScope(response); + } catch { + setState('login'); + } + }; - return { - authToken, - loading, - error, - }; -}; + if (scope) { + if (key) { + setState('validate'); + } else { + if (scope === 'environment') { + setState('login'); + } else if (scope === 'project') { + setState('project'); + } else if (scope === 'organization') { + setState('organization'); + } + } + } else { + if (key) { + setState('validate'); + } else { + fetchAuthToken(); + } + } + }, [getApiKeyScope, key, scope]); + + useEffect(() => { + if (state === 'validate') { + (async () => { + const { + valid, + scope: keyScope, + error, + } = await validateApiKeyScope(key ?? '', scope ?? 'environment'); + if (!valid || error) { + setError('Invalid Key Provided'); + } else { + setAuthToken(key ?? ''); + setCurrentScope(keyScope); + } + })(); + } + }, [key, scope, state, validateApiKeyScope]); + + useEffect(() => { + if ( + (state === 'organization' && organization) || + (state === 'project' && project) + ) { + (async () => { + const { response, error } = await getApiKeyList( + state === 'organization' ? 'org' : 'project', + internalAuthToken ?? '', + cookie, + project, + ); + if (error || response.data.length === 0) { + setError(error ?? 'No API Key found'); + return; + } + const apiKeyId = response.data[0]?.id ?? ''; + const { response: secret, error: err } = await getApiKeyById( + apiKeyId, + internalAuthToken ?? '', + cookie, + ); + if (err) { + setError(err); + return; + } + setAuthToken(secret.secret ?? ''); + setCurrentScope({ + organization_id: secret.organization_id, + project_id: secret.project_id ?? null, + environment_id: secret.environment_id ?? null, + }); + })(); + } + }, [ + cookie, + getApiKeyById, + getApiKeyList, + internalAuthToken, + organization, + project, + state, + ]); + + const handleLoginSuccess = useCallback( + ( + _organisation: ActiveState, + _project: ActiveState, + _environment: ActiveState, + secret: string, + ) => { + setAuthToken(secret); + setCurrentScope({ + organization_id: _organisation.value, + project_id: _project.value, + environment_id: _environment.value, + }); + }, + [], + ); + + const onLoginSuccess = useCallback((accessToken: string, cookie: string) => { + setInternalAuthToken(accessToken); + setCookie(cookie); + }, []); -export function AuthProvider({ children }: { readonly children: ReactNode }) { - const auth = useProvideAuth(); return ( - - {auth.loading && !auth.error && Loading Token} - {!auth.loading && auth.error && {auth.error.toString()}} - {!auth.loading && !auth.error && children} - + <> + {state === 'loading' && Loading Token} + {(state === 'organization' || state === 'project') && ( + <> + + {internalAuthToken && cookie && ( + setOrganization(organization.value)} + onError={setError} + cookie={cookie} + /> + )} + {state === 'project' && internalAuthToken && cookie && ( + setProject(project.value)} + cookie={cookie} + onError={setError} + /> + )} + + )} + {state === 'login' && ( + <> + + + )} + {state === 'done' && authToken && currentScope && ( + + {!loading && !error && children} + + )} + {error && {error}} + ); } diff --git a/source/hooks/useApiKeyApi.ts b/source/hooks/useApiKeyApi.ts index 4a69dcb..1ed6da4 100644 --- a/source/hooks/useApiKeyApi.ts +++ b/source/hooks/useApiKeyApi.ts @@ -8,6 +8,30 @@ export interface ApiKeyScope { environment_id: string | null; } +type MemberAccessObj = 'org' | 'project' | 'env'; +type MemberAccessLevel = 'admin' | 'write' | 'read' | 'no_access'; +type APIKeyOwnerType = 'pdp_config' | 'member' | 'elements'; + +interface ApiKeyResponse { + organization_id: string; // UUID + project_id?: string; // UUID (optional) + environment_id?: string; // UUID (optional) + object_type?: MemberAccessObj; // Default: "env" + access_level?: MemberAccessLevel; // Default: "admin" + owner_type: APIKeyOwnerType; + name?: string; + id: string; // UUID + secret?: string; + created_at: string; + last_used_at?: string; // date-time +} + +interface PaginatedApiKeyResponse { + data: ApiKeyResponse[]; + total_count: number; // >= 0 + page_count?: number; // Default: 0 +} + export const useApiKeyApi = () => { const getProjectEnvironmentApiKey = async ( projectId: string, @@ -26,6 +50,31 @@ export const useApiKeyApi = () => { return await apiCall(`v2/api-key/scope`, accessToken); }; + const getApiKeyList = async ( + objectType: MemberAccessObj, + accessToken: string, + cookie?: string | null, + projectId?: string | null, + ) => { + return await apiCall( + `v2/api-key?object_type=${objectType}${projectId ? '&proj_id=' + projectId : ''}`, + accessToken, + cookie ?? '', + ); + }; + + const getApiKeyById = async ( + apiKeyId: string, + accessToken: string, + cookie?: string | null, + ) => { + return await apiCall( + `v2/api-key/${apiKeyId}`, + accessToken, + cookie ?? '', + ); + }; + const validateApiKey = useCallback((apiKey: string) => { return apiKey && tokenType(apiKey) === TokenType.APIToken; }, []); @@ -80,6 +129,8 @@ export const useApiKeyApi = () => { () => ({ getProjectEnvironmentApiKey, getApiKeyScope, + getApiKeyList, + getApiKeyById, validateApiKeyScope, validateApiKey, }), From c4182dd1e06e2cfa9d1793bf68defd552f423624 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Tue, 17 Dec 2024 01:23:21 +0530 Subject: [PATCH 2/3] feat: env commands use AuthProvider --- source/commands/env/copy.tsx | 219 +++------------------- source/commands/env/member.tsx | 202 +++----------------- source/components/AuthProvider.tsx | 2 +- source/components/env/CopyComponent.tsx | 198 +++++++++++++++++++ source/components/env/MemberComponent.tsx | 190 +++++++++++++++++++ 5 files changed, 433 insertions(+), 378 deletions(-) create mode 100644 source/components/env/CopyComponent.tsx create mode 100644 source/components/env/MemberComponent.tsx diff --git a/source/commands/env/copy.tsx b/source/commands/env/copy.tsx index cea0d63..37e88ac 100644 --- a/source/commands/env/copy.tsx +++ b/source/commands/env/copy.tsx @@ -1,23 +1,20 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Text } from 'ink'; +import React from 'react'; import { option } from 'pastel'; -import { TextInput } from '@inkjs/ui'; import zod from 'zod'; import { type infer as zInfer } from 'zod'; -import { useApiKeyApi } from '../../hooks/useApiKeyApi.js'; -import { useEnvironmentApi } from '../../hooks/useEnvironmentApi.js'; -import EnvironmentSelection, { - ActiveState, -} from '../../components/EnvironmentSelection.js'; -import { cleanKey } from '../../lib/env/copy/utils.js'; +import { AuthProvider } from '../../components/AuthProvider.js'; +import CopyComponent from '../../components/env/CopyComponent.js'; export const options = zod.object({ - key: zod.string().describe( - option({ - description: - 'API Key to be used for the environment copying (should be at least a project level key)', - }), - ), + key: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: API Key to be used for the environment copying (should be at least a project level key). In case not set, CLI lets you select one', + }), + ), from: zod .string() .optional() @@ -70,192 +67,20 @@ type Props = { readonly options: zInfer; }; -interface EnvCopyBody { - existingEnvId?: string | null; - newEnvKey?: string | null; - newEnvName?: string | null; - newEnvDescription?: string | null; - conflictStrategy?: string | null; -} - export default function Copy({ - options: { - key: apiKey, - from, - to: envToId, - name, - description, - conflictStrategy, - }, + options: { key, from, to, name, description, conflictStrategy }, }: Props) { - const [error, setError] = React.useState(null); - const [authToken, setAuthToken] = React.useState(null); - const [state, setState] = useState< - | 'loading' - | 'selecting-env' - | 'selecting-name' - | 'selecting-description' - | 'copying' - | 'done' - >('loading'); - const [projectFrom, setProjectFrom] = useState(null); - const [envToName, setEnvToName] = useState(name); - const [envFrom, setEnvFrom] = useState(from); - const [envToDescription, setEnvToDescription] = useState( - description, - ); - - const { validateApiKeyScope } = useApiKeyApi(); - const { copyEnvironment } = useEnvironmentApi(); - - useEffect(() => { - if (error || state === 'done') { - process.exit(1); - } - }, [error, state]); - - useEffect(() => { - const handleEnvCopy = async (envCopyBody: EnvCopyBody) => { - let body = {}; - if (envCopyBody.existingEnvId) { - body = { - target_env: { existing: envCopyBody.existingEnvId }, - }; - } else if (envCopyBody.newEnvKey && envCopyBody.newEnvName) { - body = { - target_env: { - new: { - key: cleanKey(envCopyBody.newEnvKey), - name: envCopyBody.newEnvName, - description: envCopyBody.newEnvDescription ?? '', - }, - }, - }; - } - if (conflictStrategy) { - body = { - ...body, - conflict_strategy: envCopyBody.conflictStrategy ?? 'fail', - }; - } - const { error } = await copyEnvironment( - projectFrom ?? '', - envFrom ?? '', - apiKey, - null, - body, - ); - if (error) { - setError(`Error while copying Environment: ${error}`); - return; - } - setState('done'); - }; - - if ( - ((envToName && envToDescription && conflictStrategy) || envToId) && - envFrom && - projectFrom - ) { - setState('copying'); - handleEnvCopy({ - newEnvKey: envToName, - newEnvName: envToName, - newEnvDescription: envToDescription, - existingEnvId: envToId, - conflictStrategy: conflictStrategy, - }); - } - }, [ - apiKey, - conflictStrategy, - copyEnvironment, - envFrom, - envToDescription, - envToId, - envToName, - projectFrom, - ]); - - useEffect(() => { - // Step 1, we use the API Key provided by the user & - // checks if the api_key scope >= project_level & - // sets the apiKey and sets the projectFrom - - (async () => { - const { valid, scope, error } = await validateApiKeyScope( - apiKey, - 'project', - ); - if (!valid || error) { - setError(error); - return; - } else if (scope && valid) { - setProjectFrom(scope.project_id); - setAuthToken(apiKey); - } - })(); - }, [apiKey, validateApiKeyScope]); - - const handleEnvFromSelection = useCallback( - ( - _organisation_id: ActiveState, - _project_id: ActiveState, - environment_id: ActiveState, - ) => { - setEnvFrom(environment_id.value); - }, - [], - ); - - useEffect(() => { - if (!envFrom) { - setState('selecting-env'); - } else if (!envToName && !envToId) { - setState('selecting-name'); - } else if (!envToDescription && !envToId) { - setState('selecting-description'); - } - }, [envFrom, envToDescription, envToId, envToName]); - return ( <> - {state === 'selecting-env' && authToken && ( - <> - Select an existing Environment to copy from. - - - )} - {authToken && state === 'selecting-name' && ( - <> - Input the new Environment name to copy to. - { - setEnvToName(name); - }} - placeholder={'Enter name here...'} - /> - - )} - {authToken && state === 'selecting-description' && ( - <> - Input the new Environment Description. - { - setEnvToDescription(description); - }} - placeholder={'Enter description here...'} - /> - - )} - - {state === 'done' && Environment copied successfully} - {error && {error}} + + + ); } diff --git a/source/commands/env/member.tsx b/source/commands/env/member.tsx index 0cfbd6e..f2a31b0 100644 --- a/source/commands/env/member.tsx +++ b/source/commands/env/member.tsx @@ -1,34 +1,21 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Text } from 'ink'; -import Spinner from 'ink-spinner'; +import React from 'react'; import { option } from 'pastel'; -import { TextInput } from '@inkjs/ui'; import zod from 'zod'; import { type infer as zInfer } from 'zod'; -import { ApiKeyScope, useApiKeyApi } from '../../hooks/useApiKeyApi.js'; -import SelectInput from 'ink-select-input'; -import { useMemberApi } from '../../hooks/useMemberApi.js'; -import EnvironmentSelection, { - ActiveState, -} from '../../components/EnvironmentSelection.js'; - -const rolesOptions = [ - { label: 'Owner', value: 'admin' }, - { label: 'Editor', value: 'write' }, - { - label: 'Viewer', - value: 'read', - }, -]; +import { AuthProvider } from '../../components/AuthProvider.js'; +import MemberComponent from '../../components/env/MemberComponent.js'; export const options = zod.object({ - key: zod.string().describe( - option({ - description: - 'An API key to perform the invite. A project or organization level API key is required to invite members to the account.', - }), - ), + key: zod + .string() + .optional() + .describe( + option({ + description: + 'Optional: An API key to perform the invite. A project or organization level API key is required to invite members to the account. In case not set, CLI lets you select one', + }), + ), environment: zod .string() .optional() @@ -56,7 +43,7 @@ export const options = zod.object({ }), ), role: zod - .enum(rolesOptions.map(role => role.value) as [string, ...string[]]) + .enum(['admin', 'write', 'read']) .optional() .describe( option({ @@ -69,164 +56,19 @@ type Props = { readonly options: zInfer; }; -interface MemberInviteResult { - memberEmail: string; - memberRole: string; -} - export default function Member({ - options: { key, environment, project, email: emailP, role: roleP }, + options: { key, environment, project, email, role }, }: Props) { - const [error, setError] = React.useState(null); - const [state, setState] = useState< - 'loading' | 'selecting' | 'input-email' | 'input-role' | 'inviting' | 'done' - >('loading'); - const [keyScope, setKeyScope] = useState({ - environment_id: environment ?? null, - organization_id: '', - project_id: project ?? null, - }); - const [email, setEmail] = useState(emailP); - const [role, setRole] = useState(roleP); - const [apiKey, setApiKey] = useState(null); - - const { validateApiKeyScope } = useApiKeyApi(); - const { inviteNewMember } = useMemberApi(); - - useEffect(() => { - // console.log(error, state); - if (error || state === 'done') { - process.exit(1); - } - }, [error, state]); - - useEffect(() => { - (async () => { - const { valid, scope, error } = await validateApiKeyScope(key, 'project'); - // console.log({ valid, scope, error }); - if (error || !valid) { - setError(error); - return; - } else if (valid && scope) { - setApiKey(key); - } - - if (valid && scope && environment) { - if (!scope.project_id && !project) { - setError( - 'Please pass the project key, or use a project level Api Key', - ); - } - setKeyScope(prev => ({ - ...prev, - organization_id: scope.organization_id, - project_id: scope.project_id ?? project ?? null, - })); - } - })(); - }, [environment, key, project, validateApiKeyScope]); - - const handleMemberInvite = useCallback( - async (memberInvite: MemberInviteResult) => { - const requestBody = { - email: memberInvite.memberEmail, - permissions: [ - { - ...keyScope, - object_type: 'env', - access_level: memberInvite.memberRole, - }, - ], - }; - - const { error } = await inviteNewMember(apiKey ?? '', requestBody); - if (error) { - setError(error); - return; - } - setState('done'); - }, - [apiKey, inviteNewMember, keyScope], - ); - - const onEnvironmentSelectSuccess = useCallback( - ( - organisation: ActiveState, - project: ActiveState, - environment: ActiveState, - ) => { - // console.log(environment); - if (keyScope && keyScope.environment_id !== environment.value) { - setKeyScope({ - organization_id: organisation.value, - project_id: project.value, - environment_id: environment.value, - }); - } - }, - [keyScope], - ); - - useEffect(() => { - // console.log({ email, environment, handleMemberInvite, keyScope, role }); - if (!apiKey) return; - if (!environment && !keyScope?.environment_id) { - setState('selecting'); - } else if (!email) { - setState('input-email'); - } else if (!role) { - setState('input-role'); - } else if (keyScope && keyScope.environment_id && email && role) { - setState('inviting'); - handleMemberInvite({ - memberEmail: email, - memberRole: role, - }); - } - }, [apiKey, email, environment, handleMemberInvite, keyScope, role]); - return ( <> - {state === 'loading' && ( - - - Loading your environment - - )} - {apiKey && state === 'selecting' && ( - <> - Select Environment to add member to - - - )} - {apiKey && state === 'input-email' && ( - <> - User email: - { - setEmail(email_input); - }} - /> - - )} - {apiKey && state === 'input-role' && ( - <> - Select a scope - { - setRole(role.value); - }} - /> - - )} - {state === 'done' && User Invited Successfully !} - {error && {error}} + + + ); } diff --git a/source/components/AuthProvider.tsx b/source/components/AuthProvider.tsx index f712181..d4a968c 100644 --- a/source/components/AuthProvider.tsx +++ b/source/components/AuthProvider.tsx @@ -113,7 +113,7 @@ export function AuthProvider({ error, } = await validateApiKeyScope(key ?? '', scope ?? 'environment'); if (!valid || error) { - setError('Invalid Key Provided'); + setError(error ?? 'Invalid Key Provided'); } else { setAuthToken(key ?? ''); setCurrentScope(keyScope); diff --git a/source/components/env/CopyComponent.tsx b/source/components/env/CopyComponent.tsx new file mode 100644 index 0000000..0cbab51 --- /dev/null +++ b/source/components/env/CopyComponent.tsx @@ -0,0 +1,198 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Text } from 'ink'; +import { TextInput } from '@inkjs/ui'; +import { useEnvironmentApi } from '../../hooks/useEnvironmentApi.js'; +import EnvironmentSelection, { + ActiveState, +} from '../../components/EnvironmentSelection.js'; +import { cleanKey } from '../../lib/env/copy/utils.js'; +import { useAuth } from '../AuthProvider.js'; + +type Props = { + from?: string; + name?: string; + description?: string; + to?: string; + conflictStrategy?: 'fail' | 'overwrite'; +}; + +interface EnvCopyBody { + existingEnvId?: string | null; + newEnvKey?: string | null; + newEnvName?: string | null; + newEnvDescription?: string | null; + conflictStrategy?: string | null; +} + +export default function CopyComponent({ + from, + to: envToId, + name, + description, + conflictStrategy, +}: Props) { + const [error, setError] = React.useState(null); + const [authToken, setAuthToken] = React.useState(null); + const [state, setState] = useState< + | 'loading' + | 'selecting-env' + | 'selecting-name' + | 'selecting-description' + | 'copying' + | 'done' + >('loading'); + const [projectFrom, setProjectFrom] = useState(null); + const [envToName, setEnvToName] = useState(name); + const [envFrom, setEnvFrom] = useState(from); + const [envToDescription, setEnvToDescription] = useState( + description, + ); + + // const { validateApiKeyScope } = useApiKeyApi(); + const { copyEnvironment } = useEnvironmentApi(); + + const auth = useAuth(); + + useEffect(() => { + if (auth.error) { + setError(auth.error); + return; + } + if (!auth.loading) { + setProjectFrom(auth.scope.project_id); + setAuthToken(auth.authToken); + } + }, [auth]); + + useEffect(() => { + if (error || state === 'done') { + process.exit(1); + } + }, [error, state]); + + useEffect(() => { + const handleEnvCopy = async (envCopyBody: EnvCopyBody) => { + let body = {}; + if (envCopyBody.existingEnvId) { + body = { + target_env: { existing: envCopyBody.existingEnvId }, + }; + } else if (envCopyBody.newEnvKey && envCopyBody.newEnvName) { + body = { + target_env: { + new: { + key: cleanKey(envCopyBody.newEnvKey), + name: envCopyBody.newEnvName, + description: envCopyBody.newEnvDescription ?? '', + }, + }, + }; + } + if (conflictStrategy) { + body = { + ...body, + conflict_strategy: envCopyBody.conflictStrategy ?? 'fail', + }; + } + const { error } = await copyEnvironment( + projectFrom ?? '', + envFrom ?? '', + authToken ?? '', + null, + body, + ); + if (error) { + setError(`Error while copying Environment: ${error}`); + return; + } + setState('done'); + }; + + if ( + ((envToName && envToDescription && conflictStrategy) || envToId) && + envFrom && + projectFrom && + authToken + ) { + setState('copying'); + handleEnvCopy({ + newEnvKey: envToName, + newEnvName: envToName, + newEnvDescription: envToDescription, + existingEnvId: envToId, + conflictStrategy: conflictStrategy, + }); + } + }, [ + authToken, + conflictStrategy, + copyEnvironment, + envFrom, + envToDescription, + envToId, + envToName, + projectFrom, + ]); + + const handleEnvFromSelection = useCallback( + ( + _organisation_id: ActiveState, + _project_id: ActiveState, + environment_id: ActiveState, + ) => { + setEnvFrom(environment_id.value); + }, + [], + ); + + useEffect(() => { + if (!envFrom) { + setState('selecting-env'); + } else if (!envToName && !envToId) { + setState('selecting-name'); + } else if (!envToDescription && !envToId) { + setState('selecting-description'); + } + }, [envFrom, envToDescription, envToId, envToName]); + + return ( + <> + {state === 'selecting-env' && authToken && ( + <> + Select an existing Environment to copy from. + + + )} + {authToken && state === 'selecting-name' && ( + <> + Input the new Environment name to copy to. + { + setEnvToName(name); + }} + placeholder={'Enter name here...'} + /> + + )} + {authToken && state === 'selecting-description' && ( + <> + Input the new Environment Description. + { + setEnvToDescription(description); + }} + placeholder={'Enter description here...'} + /> + + )} + + {state === 'done' && Environment copied successfully} + {error && {error}} + + ); +} diff --git a/source/components/env/MemberComponent.tsx b/source/components/env/MemberComponent.tsx new file mode 100644 index 0000000..88d9091 --- /dev/null +++ b/source/components/env/MemberComponent.tsx @@ -0,0 +1,190 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Text } from 'ink'; +import Spinner from 'ink-spinner'; +import { TextInput } from '@inkjs/ui'; + +import { ApiKeyScope } from '../../hooks/useApiKeyApi.js'; +import SelectInput from 'ink-select-input'; +import { useMemberApi } from '../../hooks/useMemberApi.js'; +import EnvironmentSelection, { + ActiveState, +} from '../../components/EnvironmentSelection.js'; +import { useAuth } from '../AuthProvider.js'; + +const rolesOptions = [ + { label: 'Owner', value: 'admin' }, + { label: 'Editor', value: 'write' }, + { + label: 'Viewer', + value: 'read', + }, +]; + +type Props = { + environment?: string; + project?: string; + email?: string; + role?: 'admin' | 'write' | 'read'; +}; + +interface MemberInviteResult { + memberEmail: string; + memberRole: string; +} + +export default function MemberComponent({ + environment, + project, + email: emailP, + role: roleP, +}: Props) { + const [error, setError] = React.useState(null); + const [state, setState] = useState< + 'loading' | 'selecting' | 'input-email' | 'input-role' | 'inviting' | 'done' + >('loading'); + const [keyScope, setKeyScope] = useState({ + environment_id: environment ?? null, + organization_id: '', + project_id: project ?? null, + }); + const [email, setEmail] = useState(emailP); + const [role, setRole] = useState(roleP); + const [apiKey, setApiKey] = useState(null); + + const { inviteNewMember } = useMemberApi(); + + useEffect(() => { + // console.log(error, state); + if (error || state === 'done') { + process.exit(1); + } + }, [error, state]); + + const auth = useAuth(); + + useEffect(() => { + if (auth.error) { + setError(auth.error); + } + if (!auth.loading) { + setApiKey(auth.authToken); + + if (auth.scope && environment) { + if (!auth.scope.project_id && !project) { + setError( + 'Please pass the project key, or use a project level Api Key', + ); + } + setKeyScope(prev => ({ + ...prev, + organization_id: auth.scope.organization_id, + project_id: auth.scope.project_id ?? project ?? null, + })); + } + } + }, [auth, environment, project]); + + const handleMemberInvite = useCallback( + async (memberInvite: MemberInviteResult) => { + const requestBody = { + email: memberInvite.memberEmail, + permissions: [ + { + ...keyScope, + object_type: 'env', + access_level: memberInvite.memberRole, + }, + ], + }; + + const { error } = await inviteNewMember(apiKey ?? '', requestBody); + if (error) { + setError(error); + return; + } + setState('done'); + }, + [apiKey, inviteNewMember, keyScope], + ); + + const onEnvironmentSelectSuccess = useCallback( + ( + organisation: ActiveState, + project: ActiveState, + environment: ActiveState, + ) => { + // console.log(environment); + if (keyScope && keyScope.environment_id !== environment.value) { + setKeyScope({ + organization_id: organisation.value, + project_id: project.value, + environment_id: environment.value, + }); + } + }, + [keyScope], + ); + + useEffect(() => { + // console.log({ email, environment, handleMemberInvite, keyScope, role }); + if (!apiKey) return; + if (!environment && !keyScope?.environment_id) { + setState('selecting'); + } else if (!email) { + setState('input-email'); + } else if (!role) { + setState('input-role'); + } else if (keyScope && keyScope.environment_id && email && role) { + setState('inviting'); + handleMemberInvite({ + memberEmail: email, + memberRole: role, + }); + } + }, [apiKey, email, environment, handleMemberInvite, keyScope, role]); + + return ( + <> + {state === 'loading' && ( + + + Loading your environment + + )} + {apiKey && state === 'selecting' && ( + <> + Select Environment to add member to + + + )} + {apiKey && state === 'input-email' && ( + <> + User email: + { + setEmail(email_input); + }} + /> + + )} + {apiKey && state === 'input-role' && ( + <> + Select a scope + { + setRole(role.value); + }} + /> + + )} + {state === 'done' && User Invited Successfully !} + {error && {error}} + + ); +} From d32fda290aea7ad14e72cecdd75dfa4bd7cf0cc0 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Tue, 17 Dec 2024 01:33:57 +0530 Subject: [PATCH 3/3] fix: fix --- tests/EnvMember.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/EnvMember.test.tsx b/tests/EnvMember.test.tsx index 39f8efa..ca9faa5 100644 --- a/tests/EnvMember.test.tsx +++ b/tests/EnvMember.test.tsx @@ -67,7 +67,7 @@ describe('Member Component', () => { const { lastFrame, stdin } = render(); - await delay(50); // Allow environment selection + await delay(100); // Allow environment selection stdin.write('user@example.com\n'); await delay(50);