diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1102e45..c75d55d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -79,3 +79,132 @@ Before submitting your contribution, ensure the following:
- [ ] Documentation updated (if applicable)
- [ ] Code formatted and linted
- [ ] Changes thoroughly explained in the PR description
+
+
+
+# AuthProvider Component Documentation
+
+## **Overview**
+The `AuthProvider` component is a React context provider designed to manage authentication, authorization, and API key handling for CLI-based workflows. It handles multiple flows, such as login, API key validation, token management, and organization/project/environment selection.
+
+This component is the backbone of authentication for the CLI app. It ensures a smooth and secure authentication process by abstracting away the complexities of API key creation, validation, and token management.
+
+---
+
+## **What It Does**
+### **Authentication Flows**
+1. **Token Validation:**
+ - It attempts to load an existing authentication token using `loadAuthToken()`.
+ - If a token exists but has the wrong scope or is invalid, the user is redirected to reauthenticate.
+
+2. **API Key Validation:**
+ - The component validates the provided `--key` (if supplied) against the required scope (e.g., organization, project, or environment).
+ - If invalid, it throws an error.
+
+3. **Token Creation and Management:**
+ - If no key is provided or no stored key is found, we take the user through appropriate selection flow, and get a token of that scope.
+ - If while fetching the token, no valid key with name (`CLI_API_Key`) is found, the component handles the creation of a new API key (`CLI_API_Key`), ensuring it is scoped appropriately.
+
+### **User Prompts**
+- Prompts users to select an organization or project if required and dynamically handles state transitions based on the user's input.
+
+### **Error Handling**
+- Any error in the authentication flow (e.g., invalid token, API failure) is captured and displayed to the user. If an error is critical, the CLI exits with a non-zero status.
+
+---
+
+## **Key Features**
+1. **`--key<-->permitKey` Functionality:**
+ - Users can pass a `--key` flag to provide an API key directly to the `permitKey` prop of `AuthProvider`. The component validates the key and uses it if valid + has a valid scope.
+ - If not passed, the component tries to load a previously stored token or guides the user through a scope based selection and key creation flow.
+
+2. **Scope Handling:**
+ - The `scope` prop defines the required level of access (e.g., `organization`, `project`, or `environment`).
+ - Based on the scope, the component dynamically fetches or validates the key.
+
+3. **Key Creation:**
+ - If on an organization/project scope level, we fetch the key, and we don't find a `CLI_API_Key`, we create one for the user and notify them that it's a secure token and not to edit it in any way.
+
+4. **Error and Loading Indicators:**
+ - Displays appropriate messages during loading or error states to ensure users are informed about what’s happening.
+
+---
+
+## **How to Use It**
+
+- Any component that is wrapped with AuthProvider can use `useAuth()` to access:
+```tsx
+type AuthContextType = {
+ authToken: string;
+ loading: boolean;
+ error?: string | null;
+ scope: ApiKeyScope;
+};
+```
+
+1. **Wrap Your Application:**
+ - The `AuthProvider` should wrap the root of your CLI app. It ensures that authentication is initialized before the app runs.
+ ```tsx
+ import { AuthProvider } from './context/AuthProvider';
+
+ const App = () => (
+ // The scope here is optional and defaults to environment
+
+
+ );
+ ```
+
+2. **Access Authentication Context:**
+ - Use the `useAuth` hook to access the `authToken` and other authentication states in your components.
+ ```tsx
+ import { useAuth } from './context/AuthProvider';
+
+ const MyCommand = () => {
+ const { authToken, loading, error, scope } = useAuth();
+
+ if (loading) {
+ return Loading...;
+ }
+
+ if (error) {
+ return Error: {error};
+ }
+
+ return Authenticated with token: {authToken};
+ };
+ ```
+
+3. **Customizing Behavior:**
+ - Pass the `permit_key` or `scope` prop to customize the authentication flow.
+ - Example:
+ ```tsx
+
+
+
+ ```
+
+---
+
+## **What Happens Inside**
+### **Step-by-Step Breakdown**
+1. **Initialization:**
+ - Checks if a `permit_key` or token is already available.
+ - Validates the token against the required `scope`.
+
+2. **Token Validation:**
+ - If invalid or missing, it guides the user through organization/project/environment selection.
+
+3. **API Key Handling:**
+ - Searches for an existing `CLI_API_Key` scoped to the organization/project.
+ - Creates one if it doesn’t exist and retrieves its secret.
+
+4. **State Transition:**
+ - Handles transitions between `loading`, `login`, `organization`, `project`, and `done` states based on user input and validation results.
+
+5. **Error Handling:**
+ - Displays errors and exits the process if authentication fails irrecoverably.
+
+---
+
+
+
diff --git a/eslint.config.js b/eslint.config.js
index f92e870..a062163 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -7,6 +7,7 @@ import reactHooksPlugin from 'eslint-plugin-react-hooks';
import prettierPlugin from 'eslint-plugin-prettier';
import js from '@eslint/js';
import prettierConfig from 'eslint-config-prettier';
+import globals from "globals";
const compat = new FlatCompat({
baseDirectory: import.meta.url,
@@ -31,6 +32,7 @@ export default [
},
},
globals: {
+ ...globals.browser,
Headers: 'readonly',
RequestInit: 'readonly',
fetch: 'readonly',
diff --git a/package-lock.json b/package-lock.json
index b77be75..c80c96c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
+ "@scaleway/random-name": "^5.1.1",
"clipboardy": "^4.0.0",
"delay": "^6.0.0",
"fuse.js": "^7.0.0",
@@ -58,7 +59,7 @@
"vitest": "^2.1.8"
},
"engines": {
- "node": ">=16"
+ "node": ">=22"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
@@ -3363,6 +3364,14 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@scaleway/random-name": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@scaleway/random-name/-/random-name-5.1.1.tgz",
+ "integrity": "sha512-DelRK+56UBUEoRr8pQGwTE7oyC019pExis6iG6zZPcjuz4nLbMB5LIosmclnsvajfIBEQiD1Dv9JI/MgnPwygQ==",
+ "engines": {
+ "node": ">=20.x"
+ }
+ },
"node_modules/@scure/base": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.1.tgz",
diff --git a/package.json b/package.json
index 1785fec..5fc8174 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"dist"
],
"dependencies": {
+ "@scaleway/random-name": "^5.1.1",
"clipboardy": "^4.0.0",
"delay": "^6.0.0",
"fuse.js": "^7.0.0",
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..5bcc849 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,11 +43,27 @@ export const options = zod.object({
}),
),
role: zod
- .enum(rolesOptions.map(role => role.value) as [string, ...string[]])
+ .enum(['admin', 'write', 'read'])
.optional()
.describe(
option({
- description: 'Optional: Role of the user',
+ description: 'Optional: Environment role for the user',
+ }),
+ ),
+ inviterEmail: zod
+ .string()
+ .optional()
+ .describe(
+ option({
+ description: 'Optional: Inviter email address',
+ }),
+ ),
+ inviterName: zod
+ .string()
+ .optional()
+ .describe(
+ option({
+ description: 'Optional: Inviter name',
}),
),
});
@@ -69,164 +72,29 @@ 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,
+ inviterName,
+ inviterEmail,
+ },
}: 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/commands/env/select.tsx b/source/commands/env/select.tsx
index e3685b4..7a29878 100644
--- a/source/commands/env/select.tsx
+++ b/source/commands/env/select.tsx
@@ -1,16 +1,11 @@
-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 { saveAuthToken } from '../../lib/auth.js';
-import EnvironmentSelection, {
- ActiveState,
-} from '../../components/EnvironmentSelection.js';
import zod from 'zod';
import { type infer as zInfer } from 'zod';
-import Login from '../login.js';
-import { useApiKeyApi } from '../../hooks/useApiKeyApi.js';
+import { AuthProvider } from '../../components/AuthProvider.js';
+import SelectComponent from '../../components/env/SelectComponent.js';
export const options = zod.object({
key: zod
@@ -24,89 +19,14 @@ export const options = zod.object({
),
});
-type Props = {
+export type EnvSelectProps = {
readonly options: zInfer;
};
-export default function Select({ options: { key: authToken } }: Props) {
- const [error, setError] = React.useState(null);
- // const [authToken, setAuthToken] = React.useState(apiKey);
- const [state, setState] = useState<
- 'loading' | 'login' | 'selecting' | 'done'
- >('loading');
- const [environment, setEnvironment] = useState(null);
-
- const { validateApiKey } = useApiKeyApi();
-
- useEffect(() => {
- if (error || (state === 'done' && environment)) {
- process.exit(1);
- }
- }, [error, state, environment]);
-
- useEffect(() => {
- if (!authToken) {
- setState('login');
- } else if (!validateApiKey(authToken)) {
- setError('Invalid API Key. Please provide a valid API Key.');
- return;
- } else {
- setState('selecting');
- }
- }, [authToken, validateApiKey]);
-
- const onEnvironmentSelectSuccess = async (
- _organisation: ActiveState,
- _project: ActiveState,
- environment: ActiveState,
- secret: string,
- ) => {
- try {
- await saveAuthToken(secret);
- } catch (error: unknown) {
- setError(error as string);
- }
- setEnvironment(environment.label);
- setState('done');
- };
-
- const loginSuccess = useCallback(
- (
- _organisation: ActiveState,
- _project: ActiveState,
- environment: ActiveState,
- ) => {
- setEnvironment(environment.label);
- setState('done');
- },
- [],
- );
-
+export default function Select({ options }: EnvSelectProps) {
return (
- <>
- {state === 'loading' && (
-
-
- Loading your environment
-
- )}
- {state === 'login' && (
- <>
- No Key provided, Redirecting to Login...
-
- >
- )}
- {authToken && state === 'selecting' && (
-
- )}
- {state === 'done' && environment && (
- Environment: {environment} selected successfully
- )}
- {error && {error}}
- >
+
+
+
);
}
diff --git a/source/commands/gitops/create/github.tsx b/source/commands/gitops/create/github.tsx
index 58e4098..f720cfc 100644
--- a/source/commands/gitops/create/github.tsx
+++ b/source/commands/gitops/create/github.tsx
@@ -35,7 +35,7 @@ type Props = {
export default function GitHub({ options }: Props) {
return (
-
+
('login');
+ const [state, setState] = useState<'login' | 'signup' | 'env' | 'done'>(
+ 'login',
+ );
const [accessToken, setAccessToken] = useState('');
const [cookie, setCookie] = useState('');
const [error, setError] = useState(null);
@@ -68,9 +71,18 @@ export default function Login({
[loginSuccess],
);
+ const onSignupSuccess = useCallback(() => {
+ setState('env');
+ }, []);
+
useEffect(() => {
- if (error || state === 'done') {
- process.exit(1);
+ if (error === 'NO_ORGANIZATIONS') {
+ setState('signup');
+ setError(null);
+ } else if (error || state === 'done') {
+ setTimeout(() => {
+ process.exit(1);
+ }, 100);
}
}, [error, state]);
@@ -94,12 +106,21 @@ export default function Login({
workspace={workspace}
/>
)}
+ {state === 'signup' && (
+ <>
+
+ >
+ )}
{state === 'done' && (
Logged in to {organization} with selected environment as {environment}
)}
- {error && {error}}
+ {error && state !== 'signup' && {error}}
>
);
}
diff --git a/source/commands/opa/policy.tsx b/source/commands/opa/policy.tsx
index 61f710a..e53cb79 100644
--- a/source/commands/opa/policy.tsx
+++ b/source/commands/opa/policy.tsx
@@ -1,13 +1,8 @@
import React from 'react';
-import { Box, Newline, Text } from 'ink';
import zod from 'zod';
import { option } from 'pastel';
-import Spinner from 'ink-spinner';
-import { keyAccountOption } from '../../options/keychain.js';
-import { inspect } from 'util';
-import { loadAuthToken } from '../../lib/auth.js';
-import { TextInput, Select } from '@inkjs/ui';
-import Fuse from 'fuse.js';
+import { AuthProvider } from '../../components/AuthProvider.js';
+import OPAPolicyComponent from '../../components/opa/OPAPolicyComponent.js';
export const options = zod.object({
serverUrl: zod
@@ -28,133 +23,18 @@ export const options = zod.object({
'The API key for the OPA Server and Permit env, project or Workspace',
}),
),
- keyAccount: keyAccountOption,
});
-type Props = {
+export type OpaPolicyProps = {
options: zod.infer;
};
-interface PolicyItem {
- id: string;
- name?: string;
-}
-
-interface Option {
- label: string;
- value: string;
-}
-
-interface QueryResult {
- result: { result: PolicyItem[] };
- status: number;
-}
-
-export default function Policy({ options }: Props) {
- const [error, setError] = React.useState(null);
- const [res, setRes] = React.useState({
- result: { result: [] },
- status: 0,
- });
- const [selection, setSelection] = React.useState(
- undefined,
- );
- const [selectionFilter, setSelectionFilter] = React.useState('');
-
- const queryOPA = async (apiKey: string, path?: string) => {
- const document = path ? `/${path}` : '';
- const response = await fetch(
- `${options.serverUrl}/v1/policies${document}`,
- { headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {} },
- );
- const data = await response.json();
- setRes({ result: data, status: response.status });
- };
-
- React.useEffect(() => {
- const performQuery = async () => {
- const apiKey = options.apiKey || (await loadAuthToken());
- await queryOPA(apiKey);
- };
- performQuery().catch(err => setError(err));
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [options.apiKey, options.serverUrl]);
-
- const policyItems: Option[] = res.result.result.map(i => ({
- label: i.id,
- value: i.id,
- }));
-
- const fuse = new Fuse(policyItems, {
- keys: ['label', 'id'],
- minMatchCharLength: 0,
- });
- const filtered = fuse.search(selectionFilter).map(i => i.item);
- const view = filtered.length === 0 ? policyItems : filtered;
-
- const handleSelection = (selectedValue: string) => {
- const selectedPolicy = res.result.result.find(p => p.id === selectedValue);
- setSelection(selectedPolicy);
- };
-
+export default function Policy({ options }: OpaPolicyProps) {
return (
<>
-
- Listing Policies on Opa Server={options.serverUrl}
-
- {res.status === 0 && error === null && }
- {res.status === 200 && (
- <>
- {!selection && (
- <>
-
- Showing {view.length} of {policyItems.length} policies:
-
-
-
- {
- const selectedPolicy = res.result.result.find(
- p => p.id === value,
- );
- setSelection(selectedPolicy);
- }}
- onChange={setSelectionFilter}
- suggestions={policyItems.map(i => i.label)}
- />
-
-
-
-
- >
- )}
- {!!selection && (
-
-
- {inspect(selection, {
- colors: true,
- depth: null,
- maxArrayLength: Infinity,
- })}
-
-
- )}
- >
- )}
- {error && (
-
- Request failed: {JSON.stringify(error)}
-
-
- {inspect(res, {
- colors: true,
- depth: null,
- maxArrayLength: Infinity,
- })}
-
-
- )}
+
+
+
>
);
}
diff --git a/source/commands/pdp/check.tsx b/source/commands/pdp/check.tsx
index fc111b8..b0d7b33 100644
--- a/source/commands/pdp/check.tsx
+++ b/source/commands/pdp/check.tsx
@@ -1,13 +1,8 @@
import React from 'react';
-import { Box, Newline, Text } from 'ink';
import zod, { string } from 'zod';
import { option } from 'pastel';
-import { CLOUD_PDP_URL, KEYSTORE_PERMIT_SERVICE_NAME } from '../../config.js';
-import Spinner from 'ink-spinner';
-import { keyAccountOption } from '../../options/keychain.js';
-import * as keytar from 'keytar';
-import { inspect } from 'util';
-import { parseAttributes } from '../../utils/attributes.js';
+import PDPCheckComponent from '../../components/pdp/PDPCheckComponent.js';
+import { AuthProvider } from '../../components/AuthProvider.js';
export const options = zod.object({
user: zod
@@ -86,126 +81,18 @@ export const options = zod.object({
'The API key for the Permit env, project or Workspace (Optional)',
}),
),
- keyAccount: keyAccountOption,
});
-type Props = {
+export type PDPCheckProps = {
options: zod.infer;
};
-interface AllowedResult {
- allow?: boolean;
-}
-
-export default function Check({ options }: Props) {
- const [error, setError] = React.useState('');
- const [res, setRes] = React.useState({ allow: undefined });
-
- const queryPDP = async (apiKey: string) => {
- try {
- const userAttrs = options.userAttributes
- ? parseAttributes(options.userAttributes)
- : {};
- const resourceAttrs = options.resourceAttributes
- ? parseAttributes(options.resourceAttributes)
- : {};
-
- const response = await fetch(
- `${options.pdpurl || CLOUD_PDP_URL}/allowed`,
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
- },
- body: JSON.stringify({
- user: {
- key: options.user,
- ...userAttrs,
- },
- resource: {
- type: options.resource.includes(':')
- ? options.resource.split(':')[0]
- : options.resource,
- key: options.resource.includes(':')
- ? options.resource.split(':')[1]
- : '',
- tenant: options.tenant,
- ...resourceAttrs,
- },
- action: options.action,
- }),
- },
- );
-
- if (!response.ok) {
- const errorText = await response.text();
- setError(errorText);
- return;
- }
-
- setRes(await response.json());
- } catch (err) {
- if (err instanceof Error) {
- setError(err.message);
- } else {
- setError(String(err));
- }
- }
- };
-
- React.useEffect(() => {
- keytar
- .getPassword(KEYSTORE_PERMIT_SERVICE_NAME, options.keyAccount)
- .then(value => {
- const apiKey = value || '';
- queryPDP(apiKey);
- })
- .catch(reason => {
- if (reason instanceof Error) {
- setError(reason.message);
- } else {
- setError(String(reason));
- }
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [options.keyAccount]);
-
+export default function Check({ options }: PDPCheckProps) {
return (
<>
- {/* The following text adheres to react/no-unescaped-entities rule */}
-
- Checking user="{options.user}"
- {options.userAttributes && ` with attributes=${options.userAttributes}`}
- action={options.action} resource=
- {options.resource}
- {options.resourceAttributes &&
- ` with attributes=${options.resourceAttributes}`}
- at tenant={options.tenant}
-
- {res.allow === true && (
- <>
- ALLOWED
-
-
- {inspect(res, {
- colors: true,
- depth: null,
- maxArrayLength: Infinity,
- })}
-
-
- >
- )}
- {res.allow === false && DENIED}
- {res.allow === undefined && error === '' && }
- {error && (
-
- Request failed: {error}
-
- {JSON.stringify(res)}
-
- )}
+
+
+
>
);
}
diff --git a/source/commands/pdp/run.tsx b/source/commands/pdp/run.tsx
index 161aacf..7e511c6 100644
--- a/source/commands/pdp/run.tsx
+++ b/source/commands/pdp/run.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { AuthProvider } from '../../components/AuthProvider.js';
-import PDPCommand from '../../components/PDPCommand.js';
+import PDPRunComponent from '../../components/pdp/PDPRunComponent.js';
import { type infer as zInfer, number, object } from 'zod';
import { option } from 'pastel';
@@ -17,7 +17,7 @@ type Props = {
export default function Run({ options: { opa } }: Props) {
return (
-
+
);
}
diff --git a/source/components/AuthProvider.tsx b/source/components/AuthProvider.tsx
index 53eb47a..bbb3d5f 100644
--- a/source/components/AuthProvider.tsx
+++ b/source/components/AuthProvider.tsx
@@ -1,58 +1,353 @@
+/**
+ * AuthProvider: A React context provider for managing authentication and authorization states.
+ *
+ * This component handles authentication flows, API key validation, and token management.
+ */
+
import React, {
createContext,
ReactNode,
+ useCallback,
useContext,
useEffect,
useState,
} from 'react';
-import { Text } from 'ink';
+import { Text, Newline } 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';
+import { useAuthApi } from '../hooks/useAuthApi.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, createApiKey } =
+ useApiKeyApi();
+ const { authSwitchOrgs } = useAuthApi();
+
+ const [internalAuthToken, setInternalAuthToken] = useState(
+ null,
+ );
+ const [authToken, setAuthToken] = useState('');
+ const [cookie, setCookie] = useState(null);
+ const [newCookie, setNewCookie] = useState(null);
+ const [error, setError] = useState(null);
+ const [state, setState] = useState<
+ | 'loading'
+ | 'validate'
+ | 'creating-key'
+ | '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);
+ const [keyCreated, setKeyCreated] = useState(false);
+
+ // Handles all the error
+ useEffect(() => {
+ if (error) {
+ process.exit(1);
}
+ }, [error]);
- setLoading(false);
- };
+ // Step 4 If we have collected all the data needed by auth provider we set the state to 'done'
+ useEffect(() => {
+ if (authToken.length !== 0 && currentScope) {
+ setLoading(false);
+ setState('done');
+ }
+ }, [authToken, currentScope]);
+ // Step: 1, This useEffect is the heart of AuthProvider, it decides which flow to choose based on the props passed.
useEffect(() => {
- fetchAuthToken();
- }, []);
+ // Loads the token stored on our system if any, if no token is found or if the scope of the token is not right,
+ // we redirect user to login.
+ const fetchAuthToken = async (
+ redirect_scope: 'organization' | 'project' | 'login',
+ ) => {
+ try {
+ const token = await loadAuthToken();
+ const {
+ valid,
+ scope: keyScope,
+ error,
+ } = await validateApiKeyScope(
+ token,
+ redirect_scope === 'login' ? 'environment' : redirect_scope,
+ );
+ if (error || !valid) {
+ throw Error('Invalid token scope, redirecting to login of choice');
+ }
+ setAuthToken(token);
+ setCurrentScope(keyScope);
+ } catch {
+ setState(redirect_scope);
+ }
+ };
- return {
- authToken,
- loading,
- error,
- };
-};
+ // If user passes the scope
+ // and passes the key, we go to step 2
+ // otherwise we call auth token with the provided scope
+ // If scope is not passed, it is defaulted to 'environment'
+ // and if key is provided we redirect to step 2
+ // otherwise we redirect to fetchAuthToken
+ if (scope) {
+ // If scope is given then
+ if (key) {
+ setState('validate');
+ } else {
+ if (scope === 'environment') {
+ fetchAuthToken('login');
+ } else if (scope === 'project' || scope === 'organization') {
+ fetchAuthToken(scope);
+ }
+ }
+ } else {
+ if (key) {
+ setState('validate');
+ } else {
+ fetchAuthToken('login');
+ }
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, scope]);
+
+ // Step 2, Validates the api key and matches it with the scope
+ useEffect(() => {
+ if (state === 'validate') {
+ (async () => {
+ const {
+ valid,
+ scope: keyScope,
+ error,
+ } = await validateApiKeyScope(key ?? '', scope ?? 'environment');
+ if (!valid || error) {
+ setError(error ?? 'Invalid Key Provided');
+ } else {
+ setAuthToken(key ?? '');
+ setCurrentScope(keyScope);
+ }
+ })();
+ }
+ }, [key, scope, state, validateApiKeyScope]);
+
+ const switchActiveOrganization = useCallback(
+ async (organization_id: string) => {
+ const { headers, error } = await authSwitchOrgs(
+ organization_id,
+ internalAuthToken,
+ cookie,
+ );
+
+ if (error) {
+ setError(`Error while selecting active workspace: ${error}`);
+ return;
+ }
+
+ let newCookie = headers.getSetCookie()[0] ?? '';
+ setNewCookie(newCookie);
+ },
+ [authSwitchOrgs, internalAuthToken, cookie],
+ );
+
+ // Step 3, if we don't find the key in our system, or if it's invalid we prompt the user to select the respective
+ // organization & project based on the scope, and then finally redirect user to Step 4.
+ useEffect(() => {
+ if (
+ (state === 'organization' && organization) ||
+ (state === 'project' && project)
+ ) {
+ (async () => {
+ const { response, error } = await getApiKeyList(
+ state === 'organization' ? 'org' : 'project',
+ internalAuthToken ?? '',
+ newCookie,
+ project,
+ );
+ if (error) {
+ setError(`Error while getting api key list ${error}`);
+ return;
+ }
+
+ let cliApiKey = response.data.find(
+ apiKey => apiKey.name === 'CLI_API_Key',
+ );
+
+ if (!cliApiKey) {
+ setState('creating-key');
+ let body = {
+ organization_id: organization,
+ name: 'CLI_API_Key',
+ object_type: 'org',
+ };
+ if (state === 'project') {
+ body.object_type = 'project';
+ // @ts-expect-error custom param addition
+ body.project_id = project;
+ }
+ const { response: creationResponse, error: creationError } =
+ await createApiKey(
+ internalAuthToken ?? '',
+ JSON.stringify(body),
+ newCookie,
+ );
+ if (creationError) {
+ setError(`Error while creating Key: ${creationError}`);
+ }
+ cliApiKey = creationResponse;
+ setKeyCreated(true);
+ }
+ const apiKeyId = cliApiKey?.id ?? '';
+ const { response: secret, error: err } = await getApiKeyById(
+ apiKeyId,
+ internalAuthToken ?? '',
+ newCookie,
+ );
+ if (err) {
+ setError(`Error while getting api key by id: ${err}`);
+ return;
+ }
+ setAuthToken(secret.secret ?? '');
+ setCurrentScope({
+ organization_id: secret.organization_id,
+ project_id: secret.project_id ?? null,
+ environment_id: secret.environment_id ?? null,
+ });
+ })();
+ }
+ }, [
+ newCookie,
+ getApiKeyById,
+ getApiKeyList,
+ internalAuthToken,
+ organization,
+ project,
+ state,
+ createApiKey,
+ ]);
+
+ 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 && !organization && (
+ {
+ setOrganization(organization.value);
+ await switchActiveOrganization(organization.value);
+ }}
+ onError={setError}
+ cookie={cookie}
+ />
+ )}
+ {state === 'project' &&
+ internalAuthToken &&
+ newCookie &&
+ organization &&
+ !project && (
+ setProject(project.value)}
+ cookie={newCookie}
+ onError={setError}
+ />
+ )}
+ >
+ )}
+ {state === 'login' && (
+ <>
+
+ >
+ )}
+ {state === 'creating-key' && (
+ <>
+ CLI_API_Key not found, creating one for you.
+ >
+ )}
+ {state === 'done' && authToken && currentScope && (
+ <>
+ {keyCreated && (
+ <>
+
+ Created an{' '}
+ {currentScope.environment_id
+ ? 'environment'
+ : currentScope.project_id
+ ? 'project'
+ : 'organization'}{' '}
+ level key for you, named CLI_API_key (this key is protected,
+ please do not change it)
+
+
+ >
+ )}
+
+ {!loading && !error && children}
+
+ >
+ )}
+ {error && {error}}
+ >
);
}
diff --git a/source/components/SelectInputItem.ts b/source/components/SelectInputItem.ts
deleted file mode 100644
index 14ae1cf..0000000
--- a/source/components/SelectInputItem.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export type SelectInputItem = {
- key?: string;
- label: string;
- value: V;
-};
diff --git a/source/components/SelectOrganization.tsx b/source/components/SelectOrganization.tsx
index b27c1ff..15678cf 100644
--- a/source/components/SelectOrganization.tsx
+++ b/source/components/SelectOrganization.tsx
@@ -64,6 +64,11 @@ const SelectOrganization: React.FC = ({
}
}
+ if (orgs.length === 0) {
+ onError('NO_ORGANIZATIONS');
+ return;
+ }
+
if (orgs.length === 1 && orgs[0]) {
onComplete({
label: orgs[0].name,
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..592ff05
--- /dev/null
+++ b/source/components/env/MemberComponent.tsx
@@ -0,0 +1,244 @@
+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';
+ inviter_name?: string;
+ inviter_email?: string;
+};
+
+interface MemberInviteResult {
+ memberEmail: string;
+ memberRole: string;
+}
+
+export default function MemberComponent({
+ environment,
+ project,
+ email: emailP,
+ role: roleP,
+ inviter_email,
+ inviter_name,
+}: Props) {
+ const [error, setError] = React.useState(null);
+ const [state, setState] = useState<
+ | 'loading'
+ | 'selecting'
+ | 'input-email'
+ | 'input-role'
+ | 'input-inviter-name'
+ | 'input-inviter-email'
+ | '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 [inviterName, setInviterName] = useState(
+ inviter_name,
+ );
+ const [inviterEmail, setInviterEmail] = useState(
+ inviter_email,
+ );
+ const [apiKey, setApiKey] = useState(null);
+
+ const { inviteNewMember } = useMemberApi();
+
+ useEffect(() => {
+ 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,
+ inviter_name ?? '',
+ inviter_email ?? '',
+ );
+ if (error) {
+ setError(error);
+ return;
+ }
+ setState('done');
+ },
+ [apiKey, inviteNewMember, inviter_email, inviter_name, keyScope],
+ );
+
+ const onEnvironmentSelectSuccess = useCallback(
+ (
+ organisation: ActiveState,
+ project: ActiveState,
+ environment: ActiveState,
+ ) => {
+ if (keyScope && keyScope.environment_id !== environment.value) {
+ setKeyScope({
+ organization_id: organisation.value,
+ project_id: project.value,
+ environment_id: environment.value,
+ });
+ }
+ },
+ [keyScope],
+ );
+
+ useEffect(() => {
+ if (!apiKey) return;
+ if (!environment && !keyScope?.environment_id) {
+ setState('selecting');
+ } else if (!email) {
+ setState('input-email');
+ } else if (!role) {
+ setState('input-role');
+ } else if (!inviterName) {
+ setState('input-inviter-name');
+ } else if (!inviterEmail) {
+ setState('input-inviter-email');
+ } else if (keyScope && keyScope.environment_id && email && role) {
+ setState('inviting');
+ handleMemberInvite({
+ memberEmail: email,
+ memberRole: role,
+ });
+ }
+ }, [
+ apiKey,
+ email,
+ environment,
+ handleMemberInvite,
+ inviterEmail,
+ inviterName,
+ 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);
+ }}
+ />
+ >
+ )}
+ {apiKey && state === 'input-inviter-name' && (
+ <>
+ Your name:
+ {
+ setInviterName(name_input);
+ }}
+ />
+ >
+ )}
+ {apiKey && state === 'input-inviter-email' && (
+ <>
+ Your email:
+ {
+ setInviterEmail(email_input);
+ }}
+ />
+ >
+ )}
+ {state === 'done' && User Invited Successfully !}
+ {error && {error}}
+ >
+ );
+}
diff --git a/source/components/env/SelectComponent.tsx b/source/components/env/SelectComponent.tsx
new file mode 100644
index 0000000..e0a1542
--- /dev/null
+++ b/source/components/env/SelectComponent.tsx
@@ -0,0 +1,71 @@
+import React, { useEffect, useState } from 'react';
+import { Text } from 'ink';
+import Spinner from 'ink-spinner';
+
+import { saveAuthToken } from '../../lib/auth.js';
+import EnvironmentSelection, {
+ ActiveState,
+} from '../../components/EnvironmentSelection.js';
+import { useAuth } from '../AuthProvider.js';
+
+export default function SelectComponent({ key }: { key: string | undefined }) {
+ const [error, setError] = React.useState(null);
+ // const [authToken, setAuthToken] = React.useState(apiKey);
+ const [state, setState] = useState<'loading' | 'selecting' | 'done'>(
+ 'loading',
+ );
+ const [environment, setEnvironment] = useState(null);
+ const [authToken, setAuthToken] = useState(key);
+
+ const auth = useAuth();
+
+ useEffect(() => {
+ if (error || (state === 'done' && environment)) {
+ process.exit(1);
+ }
+ }, [error, state, environment]);
+
+ useEffect(() => {
+ if (auth.authToken) {
+ setAuthToken(auth.authToken);
+ setState('selecting');
+ }
+ }, [auth.authToken]);
+
+ const onEnvironmentSelectSuccess = async (
+ _organisation: ActiveState,
+ _project: ActiveState,
+ environment: ActiveState,
+ secret: string,
+ ) => {
+ try {
+ await saveAuthToken(secret);
+ } catch (error: unknown) {
+ setError(error as string);
+ }
+ setEnvironment(environment.label);
+ setState('done');
+ };
+
+ return (
+ <>
+ {state === 'loading' && (
+
+
+ Loading your environment
+
+ )}
+ {authToken && state === 'selecting' && (
+
+ )}
+ {state === 'done' && environment && (
+ Environment: {environment} selected successfully
+ )}
+ {error && {error}}
+ >
+ );
+}
diff --git a/source/components/gitops/GitHubComponent.tsx b/source/components/gitops/GitHubComponent.tsx
index c130be7..e813935 100644
--- a/source/components/gitops/GitHubComponent.tsx
+++ b/source/components/gitops/GitHubComponent.tsx
@@ -1,11 +1,12 @@
import React, { useState, useCallback, useEffect } from 'react';
-import SelectProject from './SelectProject.js';
import RepositoryKey from './RepositoryKey.js';
import SSHKey from './SSHKey.js';
import BranchName from './BranchName.js';
import { Box, Text } from 'ink';
import { configurePermit, GitConfig } from '../../lib/gitops/utils.js';
import { useAuth } from '../AuthProvider.js';
+import SelectProject from '../SelectProject.js';
+import { ActiveState } from '../EnvironmentSelection.js';
type Props = {
authKey: string | undefined;
inactivateWhenValidated: boolean | undefined;
@@ -56,6 +57,17 @@ const GitHubComponent: React.FC = ({
useEffect(() => {
apiKeyRender();
}, [apiKeyRender]);
+
+ const handleProjectSelect = useCallback((project: ActiveState) => {
+ setProjectKey(project.value);
+ setState('repositoryKey');
+ }, []);
+
+ const handleProjectSelectionError = useCallback((error: string) => {
+ setError(error);
+ setState('error');
+ }, []);
+
return (
<>
@@ -64,15 +76,9 @@ const GitHubComponent: React.FC = ({
{state === 'project' && (
{
- setError(errorMessage);
- setState('error');
- }}
- onProjectSubmit={(projectIdKey: string) => {
- setProjectKey(projectIdKey);
- setState('repositoryKey');
- }}
+ onError={handleProjectSelectionError}
+ onComplete={handleProjectSelect}
+ accessToken={ApiKey}
/>
)}
diff --git a/source/components/gitops/SelectProject.tsx b/source/components/gitops/SelectProject.tsx
deleted file mode 100644
index d07c43d..0000000
--- a/source/components/gitops/SelectProject.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import React, { useCallback, useEffect, useState } from 'react';
-import { getProjectList } from '../../lib/gitops/utils.js';
-import { Text } from 'ink';
-import SelectInput from 'ink-select-input';
-
-type Props = {
- apiKey: string;
- onProjectSubmit: (project: string) => void;
- onError: (error: string) => void;
-};
-
-const SelectProject: React.FC = ({
- apiKey,
- onProjectSubmit,
- onError,
-}) => {
- const [projects, setProjects] = useState<{ label: string; value: string }[]>(
- [],
- );
- const [loading, setLoading] = useState(true);
-
- const retriveProject = useCallback(async () => {
- try {
- const projects = await getProjectList(apiKey);
- setProjects(
- projects.map(project => ({
- label: project.name,
- value: project.key,
- })),
- );
- setLoading(false);
- } catch (error) {
- onError(error instanceof Error ? error.message : String(error));
- }
- }, [apiKey, onError]);
-
- useEffect(() => {
- retriveProject();
- }, [retriveProject]);
-
- return (
- <>
- {loading && Loading projects...}
- {!loading && (
- <>
- Select Your Project:
- {
- onProjectSubmit(project.value);
- }}
- />
- >
- )}
- >
- );
-};
-
-export default SelectProject;
diff --git a/source/components/opa/OPAPolicyComponent.tsx b/source/components/opa/OPAPolicyComponent.tsx
new file mode 100644
index 0000000..728d845
--- /dev/null
+++ b/source/components/opa/OPAPolicyComponent.tsx
@@ -0,0 +1,139 @@
+import React from 'react';
+import { Box, Newline, Text } from 'ink';
+import Spinner from 'ink-spinner';
+import { inspect } from 'util';
+import { TextInput, Select } from '@inkjs/ui';
+import Fuse from 'fuse.js';
+import { OpaPolicyProps } from '../../commands/opa/policy.js';
+import { useAuth } from '../AuthProvider.js';
+
+interface PolicyItem {
+ id: string;
+ name?: string;
+}
+
+interface Option {
+ label: string;
+ value: string;
+}
+
+interface QueryResult {
+ result: { result: PolicyItem[] };
+ status: number;
+}
+
+export default function OPAPolicyComponent({ options }: OpaPolicyProps) {
+ const [error, setError] = React.useState(null);
+ const [res, setRes] = React.useState({
+ result: { result: [] },
+ status: 0,
+ });
+ const [selection, setSelection] = React.useState(
+ undefined,
+ );
+ const [selectionFilter, setSelectionFilter] = React.useState('');
+
+ const auth = useAuth();
+
+ const queryOPA = async (apiKey: string, path?: string) => {
+ const document = path ? `/${path}` : '';
+ const response = await fetch(
+ `${options.serverUrl}/v1/policies${document}`,
+ { headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {} },
+ );
+ const data = await response.json();
+ setRes({ result: data, status: response.status });
+ };
+
+ React.useEffect(() => {
+ if (auth.error) {
+ setError(Error(auth.error));
+ }
+ if (!auth.loading) {
+ const performQuery = async () => {
+ await queryOPA(auth.authToken);
+ };
+ performQuery().catch(err => setError(err));
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [options.apiKey, options.serverUrl, auth]);
+
+ const policyItems: Option[] = res.result.result.map(i => ({
+ label: i.id,
+ value: i.id,
+ }));
+
+ const fuse = new Fuse(policyItems, {
+ keys: ['label', 'id'],
+ minMatchCharLength: 0,
+ });
+ const filtered = fuse.search(selectionFilter).map(i => i.item);
+ const view = filtered.length === 0 ? policyItems : filtered;
+
+ const handleSelection = (selectedValue: string) => {
+ const selectedPolicy = res.result.result.find(p => p.id === selectedValue);
+ setSelection(selectedPolicy);
+ };
+
+ return (
+ <>
+
+ Listing Policies on Opa Server={options.serverUrl}
+
+ {res.status === 0 && error === null && }
+ {res.status === 200 && (
+ <>
+ {!selection && (
+ <>
+
+ Showing {view.length} of {policyItems.length} policies:
+
+
+
+ {
+ const selectedPolicy = res.result.result.find(
+ p => p.id === value,
+ );
+ setSelection(selectedPolicy);
+ }}
+ onChange={setSelectionFilter}
+ suggestions={policyItems.map(i => i.label)}
+ />
+
+
+
+
+ >
+ )}
+ {!!selection && (
+
+
+ {inspect(selection, {
+ colors: true,
+ depth: null,
+ maxArrayLength: Infinity,
+ })}
+
+
+ )}
+ >
+ )}
+ {error && (
+
+ Request failed: {JSON.stringify(error)}
+
+
+ {inspect(res, {
+ colors: true,
+ depth: null,
+ maxArrayLength: Infinity,
+ })}
+
+
+ )}
+ >
+ );
+}
diff --git a/source/components/pdp/PDPCheckComponent.tsx b/source/components/pdp/PDPCheckComponent.tsx
new file mode 100644
index 0000000..06671be
--- /dev/null
+++ b/source/components/pdp/PDPCheckComponent.tsx
@@ -0,0 +1,120 @@
+import React, { useEffect, useState } from 'react';
+import { Box, Newline, Text } from 'ink';
+import { CLOUD_PDP_URL } from '../../config.js';
+import Spinner from 'ink-spinner';
+import { inspect } from 'util';
+import { parseAttributes } from '../../utils/attributes.js';
+import { PDPCheckProps } from '../../commands/pdp/check.js';
+import { useAuth } from '../AuthProvider.js';
+
+interface AllowedResult {
+ allow?: boolean;
+}
+
+export default function PDPCheckComponent({ options }: PDPCheckProps) {
+ const [error, setError] = useState('');
+ const [res, setRes] = useState({ allow: undefined });
+
+ const auth = useAuth();
+
+ useEffect(() => {
+ const queryPDP = async (apiKey: string) => {
+ try {
+ const userAttrs = options.userAttributes
+ ? parseAttributes(options.userAttributes)
+ : {};
+ const resourceAttrs = options.resourceAttributes
+ ? parseAttributes(options.resourceAttributes)
+ : {};
+
+ const response = await fetch(
+ `${options.pdpurl || CLOUD_PDP_URL}/allowed`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
+ },
+ body: JSON.stringify({
+ user: {
+ key: options.user,
+ ...userAttrs,
+ },
+ resource: {
+ type: options.resource.includes(':')
+ ? options.resource.split(':')[0]
+ : options.resource,
+ key: options.resource.includes(':')
+ ? options.resource.split(':')[1]
+ : '',
+ tenant: options.tenant,
+ ...resourceAttrs,
+ },
+ action: options.action,
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ setError(errorText);
+ return;
+ }
+
+ setRes(await response.json());
+ } catch (err) {
+ if (err instanceof Error) {
+ setError(err.message);
+ } else {
+ setError(String(err));
+ }
+ }
+ };
+
+ if (auth.error) {
+ setError(auth.error);
+ }
+ if (!auth.loading) {
+ queryPDP(auth.authToken);
+ }
+ }, [auth, options]);
+
+ return (
+ <>
+ {/*The following text adheres to react/no-unescaped-entities rule */}
+
+ Checking user="{options.user}"{' '}
+ {options.userAttributes &&
+ ` with attributes=${options.userAttributes && ' '}`}
+ action="{options.action}" resource="{options.resource}
+ "{' '}
+ {options.resourceAttributes &&
+ ` with attributes=${options.resourceAttributes} && ' '`}
+ at tenant="{options.tenant}"
+
+ {res.allow === true && (
+ <>
+ ALLOWED
+
+
+ {inspect(res, {
+ colors: true,
+ depth: null,
+ maxArrayLength: Infinity,
+ })}
+
+
+ >
+ )}
+ {res.allow === false && DENIED}
+ {res.allow === undefined && error === '' && }
+ {error && (
+
+ Request failed: {error}
+
+ {JSON.stringify(res)}
+
+ )}
+ >
+ );
+}
diff --git a/source/components/PDPCommand.tsx b/source/components/pdp/PDPRunComponent.tsx
similarity index 83%
rename from source/components/PDPCommand.tsx
rename to source/components/pdp/PDPRunComponent.tsx
index 2604c80..7f91e1e 100644
--- a/source/components/PDPCommand.tsx
+++ b/source/components/pdp/PDPRunComponent.tsx
@@ -1,13 +1,13 @@
import React from 'react';
import { Text } from 'ink';
import Spinner from 'ink-spinner';
-import { useAuth } from './AuthProvider.js';
+import { useAuth } from '../AuthProvider.js';
type Props = {
opa?: number;
};
-export default function PDPCommand({ opa }: Props) {
+export default function PDPRunComponent({ opa }: Props) {
const { authToken } = useAuth();
return authToken ? (
<>
diff --git a/source/components/signup/SignupComponent.tsx b/source/components/signup/SignupComponent.tsx
new file mode 100644
index 0000000..92006a3
--- /dev/null
+++ b/source/components/signup/SignupComponent.tsx
@@ -0,0 +1,121 @@
+import React, { useState, FC, useEffect } from 'react';
+import { TextInput, ConfirmInput } from '@inkjs/ui';
+import { Text, Newline } from 'ink';
+import Spinner from 'ink-spinner';
+import randomName from '@scaleway/random-name';
+import { useOrganisationApi } from '../../hooks/useOrganisationApi.js';
+import { cleanKey } from '../../lib/env/copy/utils.js';
+
+type SignupProp = {
+ accessToken: string;
+ cookie?: string | null;
+ onSuccess: () => void;
+};
+
+const SignupComponent: FC = ({
+ accessToken,
+ cookie,
+ onSuccess,
+}: SignupProp) => {
+ const [organizationName, setOrganizationName] = useState(randomName());
+ const [error, setError] = useState(null);
+ const [state, setState] = useState<
+ 'confirming' | 'selecting' | 'creating' | 'done'
+ >('confirming');
+
+ useEffect(() => {
+ if (state === 'done') {
+ onSuccess();
+ }
+ if (error) {
+ setTimeout(() => {
+ process.exit(1);
+ }, 1000);
+ }
+ });
+
+ const { createOrg } = useOrganisationApi();
+
+ const handleWorkspaceCreation = async (workspace: string) => {
+ const cleanOrgName = cleanKey(workspace);
+ setOrganizationName(cleanOrgName);
+ const { error } = await createOrg(
+ {
+ key: cleanOrgName,
+ name: cleanOrgName,
+ },
+ accessToken,
+ cookie,
+ );
+ if (error) {
+ setError(error);
+ return;
+ }
+ setState('done');
+ };
+
+ const handleConfirm = async () => {
+ setState('creating');
+ await handleWorkspaceCreation(organizationName);
+ };
+
+ const handleCancel = () => {
+ setState('selecting');
+ };
+
+ return (
+ <>
+ Welcome! Create your Workspace
+ {/**/}
+ {state === 'confirming' && (
+ <>
+
+ Use the default organization name:{' '}
+ {organizationName}?{' '}
+ {' '}
+
+ >
+ )}
+ {state === 'selecting' && (
+ <>
+
+ Enter your organization name (Default:{' '}
+ {organizationName}):
+
+ {
+ setState('creating');
+ handleWorkspaceCreation(input || organizationName);
+ }}
+ />
+ >
+ )}
+
+ {state === 'creating' && (
+ <>
+
+ Creating your organization
+
+ >
+ )}
+
+ {state === 'done' && (
+ <>
+
+ Organization: {organizationName} created successfully & Signup
+ successful
+
+ >
+ )}
+ {error && (
+ <>
+
+ {error}
+ >
+ )}
+ >
+ );
+};
+
+export default SignupComponent;
diff --git a/source/config.tsx b/source/config.tsx
index e86f518..5e90bdb 100644
--- a/source/config.tsx
+++ b/source/config.tsx
@@ -5,3 +5,11 @@ export const CLOUD_PDP_URL = 'https://cloudpdp.api.permit.io';
export const PERMIT_API_URL = 'https://api.permit.io';
export const API_URL = 'https://api.permit.io/v2/';
export const API_PDPS_CONFIG_URL = `${API_URL}pdps/me/config`;
+export const PERMIT_ORIGIN_URL = 'https://app.permit.io';
+
+export const AUTH_REDIRECT_HOST = 'localhost';
+export const AUTH_REDIRECT_PORT = 62419;
+export const AUTH_REDIRECT_URI = `http://${AUTH_REDIRECT_HOST}:${AUTH_REDIRECT_PORT}`;
+export const AUTH_PERMIT_DOMAIN = 'app.permit.io';
+export const AUTH_API_URL = 'https://api.permit.io/v1/';
+export const AUTH_PERMIT_URL = 'https://auth.permit.io';
diff --git a/source/hooks/useApiKeyApi.ts b/source/hooks/useApiKeyApi.ts
index 4a69dcb..5f4ed72 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;
}, []);
@@ -76,10 +125,27 @@ export const useApiKeyApi = () => {
[validateApiKey],
);
+ const createApiKey = async (
+ token: string,
+ body: string,
+ cookie?: string | null,
+ ) => {
+ return await apiCall(
+ 'v2/api-key',
+ token,
+ cookie,
+ 'POST',
+ body,
+ );
+ };
+
return useMemo(
() => ({
getProjectEnvironmentApiKey,
getApiKeyScope,
+ getApiKeyList,
+ getApiKeyById,
+ createApiKey,
validateApiKeyScope,
validateApiKey,
}),
diff --git a/source/hooks/useMemberApi.ts b/source/hooks/useMemberApi.ts
index 2a1f5a9..1358b45 100644
--- a/source/hooks/useMemberApi.ts
+++ b/source/hooks/useMemberApi.ts
@@ -2,9 +2,14 @@ import { apiCall } from '../lib/api.js';
import { useMemo } from 'react';
export const useMemberApi = () => {
- const inviteNewMember = async (authToken: string, body: object) => {
+ const inviteNewMember = async (
+ authToken: string,
+ body: object,
+ inviter_name: string,
+ inviter_email: string,
+ ) => {
return await apiCall(
- `v2/members`,
+ `v2/members?inviter_name=${inviter_name}&inviter_email=${inviter_email}`,
authToken,
null,
'POST',
diff --git a/source/hooks/useOrganisationApi.ts b/source/hooks/useOrganisationApi.ts
index 45f54d9..cd63022 100644
--- a/source/hooks/useOrganisationApi.ts
+++ b/source/hooks/useOrganisationApi.ts
@@ -37,10 +37,25 @@ export const useOrganisationApi = () => {
);
};
+ const createOrg = async (
+ body: object,
+ accessToken: string,
+ cookie?: string | null,
+ ) => {
+ return await apiCall(
+ `v2/orgs`,
+ accessToken,
+ cookie ?? '',
+ 'POST',
+ JSON.stringify(body),
+ );
+ };
+
return useMemo(
() => ({
getOrgs,
getOrg,
+ createOrg,
}),
[],
);
diff --git a/source/lib/api.ts b/source/lib/api.ts
index 5e105fd..9f4759f 100644
--- a/source/lib/api.ts
+++ b/source/lib/api.ts
@@ -1,4 +1,4 @@
-import { PERMIT_API_URL } from '../config.js';
+import { PERMIT_API_URL, PERMIT_ORIGIN_URL } from '../config.js';
type ApiResponse = {
headers: Headers;
@@ -26,7 +26,7 @@ export const apiCall = async (
method,
headers: {
Accept: '*/*',
- Origin: 'https://app.permit.io',
+ Origin: PERMIT_ORIGIN_URL,
Authorization: `Bearer ${token}`,
Cookie: cookie ?? '',
'Content-Type': 'application/json',
diff --git a/source/lib/auth.ts b/source/lib/auth.ts
index 6540c7b..f1d352e 100644
--- a/source/lib/auth.ts
+++ b/source/lib/auth.ts
@@ -3,8 +3,14 @@ import * as http from 'node:http';
import open from 'open';
import * as pkg from 'keytar';
import {
+ AUTH_API_URL,
+ AUTH_PERMIT_DOMAIN,
+ AUTH_REDIRECT_HOST,
+ AUTH_REDIRECT_PORT,
+ AUTH_REDIRECT_URI,
DEFAULT_PERMIT_KEYSTORE_ACCOUNT,
KEYSTORE_PERMIT_SERVICE_NAME,
+ AUTH_PERMIT_URL,
} from '../config.js';
import { URL, URLSearchParams } from 'url';
import { setTimeout } from 'timers';
@@ -86,7 +92,7 @@ export const authCallbackServer = async (verifier: string): Promise => {
const code = url.searchParams.get('code');
// Send the response
- const data = await fetch('https://auth.permit.io/oauth/token', {
+ const data = await fetch(`${AUTH_PERMIT_URL}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -96,7 +102,7 @@ export const authCallbackServer = async (verifier: string): Promise => {
client_id: 'Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1',
code_verifier: verifier,
code,
- redirect_uri: 'http://localhost:62419',
+ redirect_uri: AUTH_REDIRECT_URI,
}),
}).then(async response => response.json());
res.statusCode = 200; // Set the response status code
@@ -107,11 +113,8 @@ export const authCallbackServer = async (verifier: string): Promise => {
});
// Specify the port and host
- const port = 62_419;
- const host = 'localhost';
-
// Start the server and listen on the specified port
- server.listen(port, host);
+ server.listen(AUTH_REDIRECT_PORT, AUTH_REDIRECT_HOST);
setTimeout(() => {
server.close();
@@ -137,20 +140,20 @@ export const browserAuth = async (): Promise => {
const challenge = base64UrlEncode(sha256(verifier));
const parameters = new URLSearchParams({
- audience: 'https://api.permit.io/v1/',
- screen_hint: 'app.permit.io',
- domain: 'app.permit.io',
+ audience: AUTH_API_URL,
+ screen_hint: AUTH_PERMIT_DOMAIN,
+ domain: AUTH_PERMIT_DOMAIN,
auth0Client: 'eyJuYW1lIjoiYXV0aDAtcmVhY3QiLCJ2ZXJzaW9uIjoiMS4xMC4yIn0=',
isEAP: 'false',
response_type: 'code',
- fragment: 'domain=app.permit.io',
+ fragment: `domain=${AUTH_PERMIT_DOMAIN}`,
code_challenge: challenge,
code_challenge_method: 'S256',
client_id: 'Pt7rWJ4BYlpELNIdLg6Ciz7KQ2C068C1',
- redirect_uri: 'http://localhost:62419',
+ redirect_uri: AUTH_REDIRECT_URI,
scope: 'openid profile email',
state: 'bFR2dn5idUhBVDNZYlhlSEFHZnJaSjRFdUhuczdaSlhCSHFDSGtlYXpqbQ==',
});
- await open(`https://auth.permit.io/authorize?${parameters.toString()}`);
+ await open(`${AUTH_PERMIT_URL}/authorize?${parameters.toString()}`);
return verifier;
};
diff --git a/tests/EnvCopy.test.tsx b/tests/EnvCopy.test.tsx
index b392952..4e28594 100644
--- a/tests/EnvCopy.test.tsx
+++ b/tests/EnvCopy.test.tsx
@@ -6,6 +6,7 @@ import { useApiKeyApi } from '../source/hooks/useApiKeyApi.js';
import { useEnvironmentApi } from '../source/hooks/useEnvironmentApi.js';
import EnvironmentSelection from '../source/components/EnvironmentSelection.js';
import delay from 'delay';
+import * as keytar from 'keytar';
vi.mock('../source/hooks/useApiKeyApi.js', () => ({
useApiKeyApi: vi.fn(() => ({
@@ -31,6 +32,19 @@ beforeEach(() => {
});
});
+
+vi.mock('keytar', () => {
+ const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
+ const keytar = {
+ setPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ getPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ deletePassword: vi.fn().mockResolvedValue(demoPermitKey),
+
+ };
+ return { ...keytar, default: keytar };
+});
+
+
afterEach(() => {
vi.restoreAllMocks();
});
diff --git a/tests/EnvMember.test.tsx b/tests/EnvMember.test.tsx
index 742deb8..d505007 100644
--- a/tests/EnvMember.test.tsx
+++ b/tests/EnvMember.test.tsx
@@ -6,6 +6,7 @@ import { useApiKeyApi } from '../source/hooks/useApiKeyApi.js';
import { useMemberApi } from '../source/hooks/useMemberApi.js';
import EnvironmentSelection from '../source/components/EnvironmentSelection.js';
import delay from 'delay';
+import * as keytar from "keytar"
vi.mock('../source/hooks/useApiKeyApi.js', () => ({
useApiKeyApi: vi.fn(() => ({
@@ -24,6 +25,18 @@ vi.mock('../source/components/EnvironmentSelection.js', () => ({
default: vi.fn(),
}));
+vi.mock('keytar', () => {
+ const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
+ const keytar = {
+ setPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ getPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ deletePassword: vi.fn().mockResolvedValue(demoPermitKey),
+
+ };
+ return { ...keytar, default: keytar };
+});
+
+
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(process, 'exit').mockImplementation(code => {
@@ -73,14 +86,61 @@ describe('Member Component', () => {
,
);
- await delay(50); // Allow environment selection
+ await delay(100); // Allow environment selection
stdin.write('user@example.com\n');
await delay(50);
stdin.write(enter);
await delay(50);
stdin.write(enter);
- await delay(100); // Allow role selection
+ await delay(50)
+ stdin.write("dummy_name")
+ await delay(50);
+ stdin.write(enter);
+ await delay(50)
+ stdin.write("dummy_email")
+ await delay(50);
+ stdin.write(enter);
+ await delay(100);
+
+ expect(lastFrame()).toMatch(/User Invited Successfully/);
+ });
+ it('should handle successful member invite flow with all flags passed', async () => {
+ vi.mocked(useApiKeyApi).mockReturnValue({
+ validateApiKeyScope: vi.fn(() =>
+ Promise.resolve({
+ valid: true,
+ scope: {
+ organization_id: 'org1',
+ project_id: 'proj1',
+ },
+ error: null,
+ }),
+ ),
+ });
+
+ vi.mocked(useMemberApi).mockReturnValue({
+ inviteNewMember: vi.fn(() =>
+ Promise.resolve({
+ error: null,
+ }),
+ ),
+ });
+
+ vi.mocked(EnvironmentSelection).mockImplementation(({ onComplete }) => {
+ onComplete(
+ { label: 'Org1', value: 'org1' },
+ { label: 'Proj1', value: 'proj1' },
+ { label: 'Env1', value: 'env1' },
+ );
+ return null;
+ });
+
+ const { lastFrame, stdin } = render(
+ ,
+ );
+
+ await delay(100);
expect(lastFrame()).toMatch(/User Invited Successfully/);
});
diff --git a/tests/EnvSelect.test.tsx b/tests/EnvSelect.test.tsx
index 51c0704..5fc9ee6 100644
--- a/tests/EnvSelect.test.tsx
+++ b/tests/EnvSelect.test.tsx
@@ -8,12 +8,60 @@ import Login from '../source/commands/login.js';
import EnvironmentSelection from '../source/components/EnvironmentSelection.js';
import { saveAuthToken } from '../source/lib/auth.js';
import delay from 'delay';
+import SelectComponent from '../source/components/env/SelectComponent';
+
+
+const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
+
+
+// vi.mock('../source/hooks/useApiKeyApi.js', () => ({
+// useApiKeyApi: vi.fn(() => ({
+// validateApiKey: vi.fn(),
+// validateApiKeyScope: vi.fn(),
+// })),
+// }));
+
+vi.mock('../source/components/AuthProvider.tsx', async() => {
+ const original = await vi.importActual('../source/components/AuthProvider.tsx');
+ return {
+ ...original,
+ useAuth: () => ({
+ authToken: demoPermitKey,
+ })
+ }
+})
+
+vi.mock('../source/hooks/useApiKeyApi', async() => {
+ const original = await vi.importActual('../source/hooks/useApiKeyApi');
+ return {
+ ...original,
+ useApiKeyApi: () => ({
+ getApiKeyScope: vi.fn().mockResolvedValue({
+ response: {
+ environment_id: 'env1',
+ project_id: 'proj1',
+ organization_id: 'org1',
+ },
+ error: null,
+ status: 200,
+ }),
+ getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({
+ response: { secret: 'test-secret' },
+ error: null,
+ }),
+ validateApiKeyScope: vi.fn().mockResolvedValue({
+ valid: true,
+ scope: {
+ environment_id: 'env1',
+ project_id: 'proj1',
+ organization_id: 'org1',
+ },
+ error: null
+ })
+ }),
+ }
+});
-vi.mock('../source/hooks/useApiKeyApi.js', () => ({
- useApiKeyApi: vi.fn(() => ({
- validateApiKey: vi.fn(),
- })),
-}));
vi.mock('../source/commands/login.js', () => ({
__esModule: true,
@@ -42,48 +90,12 @@ afterEach(() => {
describe('Select Component', () => {
it('should display loading state initially', async () => {
- const { lastFrame } = render();
+ const { lastFrame } = render();
expect(lastFrame()).toMatch(/Loading your environment/);
});
- it('should redirect to login when no API key is provided', async () => {
- // Mock the Login component
- vi.mocked(Login).mockImplementation(() => (
- Mocked Login Component
- ));
-
- const { lastFrame } = render();
-
- await delay(50); // Allow state transitions to occur
-
- expect(lastFrame()).toMatch(/No Key provided, Redirecting to Login/);
- expect(lastFrame()).toMatch(/Mocked Login Component/);
- });
-
- it('should display error for invalid API key', async () => {
- // Mock validateApiKey to return false
- vi.mocked(useApiKeyApi).mockReturnValue({
- validateApiKey: vi.fn(() => false),
- });
-
- const { lastFrame } = render(
- ,
- );
-
- await delay(50); // Allow state transitions to occur
-
- expect(lastFrame()).toMatch(
- /Invalid API Key. Please provide a valid API Key./,
- );
- expect(process.exit).toHaveBeenCalledWith(1);
- });
-
it('should handle environment selection successfully', async () => {
- // Mock validateApiKey to return true
- vi.mocked(useApiKeyApi).mockReturnValue({
- validateApiKey: vi.fn(() => true),
- });
// Mock saveAuthToken
vi.mocked(saveAuthToken).mockResolvedValueOnce();
@@ -106,10 +118,6 @@ describe('Select Component', () => {
expect(lastFrame()).toMatch(/Environment: Env1 selected successfully/);
});
it('should handle environment selection failure', async () => {
- // Mock validateApiKey to return true
- vi.mocked(useApiKeyApi).mockReturnValue({
- validateApiKey: vi.fn(() => true),
- });
// Mock saveAuthToken
vi.mocked(saveAuthToken).mockRejectedValueOnce('Failed to save token');
@@ -125,34 +133,15 @@ describe('Select Component', () => {
return null;
});
- const { lastFrame } = render();
+ const { lastFrame } = render();
await delay(100); // Allow async operations to complete
expect(lastFrame()).toMatch(/Failed to save token/);
expect(process.exit).toHaveBeenCalledWith(1);
});
- it('handle login successs', async () => {
- vi.mocked(useApiKeyApi).mockReturnValue({
- validateApiKey: vi.fn(() => true),
- });
- vi.mocked(EnvironmentSelection).mockImplementation(({ onComplete }) => {
- onComplete(
- { label: 'Org1', value: 'org1' },
- { label: 'Proj1', value: 'proj1' },
- { label: 'Env1', value: 'env1' },
- 'secret_token',
- );
- return null;
- });
- const { lastFrame } = render();
- await delay(100); // Allow async operations to complete
- expect(lastFrame()).toMatch(/Environment: Env1 selected successfully/);
- });
+
it('handle complete enviroment selection process', async () => {
- vi.mocked(useApiKeyApi).mockReturnValue({
- validateApiKey: vi.fn(() => true),
- });
vi.mocked(saveAuthToken).mockResolvedValueOnce();
vi.mocked(EnvironmentSelection).mockImplementation(({ onComplete }) => {
onComplete(
@@ -163,7 +152,7 @@ describe('Select Component', () => {
);
return null;
});
- const { lastFrame } = render();
+ const { lastFrame } = render();
await delay(100); // Allow async operations to complete
expect(lastFrame()).toMatch(/Environment: Env1 selected successfully/);
});
diff --git a/tests/OPAPolicy.test.tsx b/tests/OPAPolicy.test.tsx
index 20ac38f..c92f2ce 100644
--- a/tests/OPAPolicy.test.tsx
+++ b/tests/OPAPolicy.test.tsx
@@ -3,18 +3,59 @@ import { describe, it, expect, vi } from 'vitest';
import { render } from 'ink-testing-library';
import Policy from '../source/commands/opa/policy';
import delay from 'delay';
-import * as keytar from 'keytar';
+import * as keytar from "keytar"
global.fetch = vi.fn();
const enter = '\r';
-
vi.mock('keytar', () => {
+ const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
const keytar = {
- setPassword: vi.fn(),
- getPassword: vi.fn(), // Mocked return value
- deletePassword: vi.fn(),
+ setPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ getPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ deletePassword: vi.fn().mockResolvedValue(demoPermitKey),
+
};
return { ...keytar, default: keytar };
});
+const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
+
+vi.mock('../source/lib/auth.js', async () => {
+ const original = await vi.importActual('../source/lib/auth.js');
+ return {
+ ...original,
+ loadAuthToken: vi.fn(() => demoPermitKey),
+ };
+});
+vi.mock('../source/hooks/useApiKeyApi', async() => {
+ const original = await vi.importActual('../source/hooks/useApiKeyApi');
+ return {
+ ...original,
+ useApiKeyApi: () => ({
+ getApiKeyScope: vi.fn().mockResolvedValue({
+ response: {
+ environment_id: 'env1',
+ project_id: 'proj1',
+ organization_id: 'org1',
+ },
+ error: null,
+ status: 200,
+ }),
+ getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({
+ response: { secret: 'test-secret' },
+ error: null,
+ }),
+ validateApiKeyScope: vi.fn().mockResolvedValue({
+ valid: true,
+ scope: {
+ environment_id: 'env1',
+ project_id: 'proj1',
+ organization_id: 'org1',
+ },
+ error: null
+ })
+ }),
+ }
+});
+
describe('OPA Policy Command', () => {
it('should render the policy command', async () => {
@@ -34,6 +75,9 @@ describe('OPA Policy Command', () => {
status: 200,
});
const { stdin, lastFrame } = render();
+ const frameString = lastFrame()?.toString() ?? '';
+ expect(frameString).toMatch(/Loading Token/);
+ await delay(100);
expect(lastFrame()?.toString()).toMatch(
'Listing Policies on Opa Server=http://localhost:8181',
);
@@ -50,6 +94,9 @@ describe('OPA Policy Command', () => {
};
(fetch as any).mockRejectedValueOnce(new Error('Error'));
const { lastFrame } = render();
+ const frameString = lastFrame()?.toString() ?? '';
+ expect(frameString).toMatch(/Loading Token/);
+ await delay(100);
expect(lastFrame()?.toString()).toMatch(
'Listing Policies on Opa Server=http://localhost:8181',
);
diff --git a/tests/PDPCheck.test.tsx b/tests/PDPCheck.test.tsx
index 05b6ab1..5acda0a 100644
--- a/tests/PDPCheck.test.tsx
+++ b/tests/PDPCheck.test.tsx
@@ -1,101 +1,211 @@
import React from 'react';
import { render } from 'ink-testing-library';
-import { describe, vi, it, expect, afterEach } from 'vitest';
+import { describe, vi, it, expect, afterEach, beforeEach } from 'vitest';
import delay from 'delay';
import Check from '../source/commands/pdp/check';
import * as keytar from 'keytar';
+import { useApiKeyApi } from '../source/hooks/useApiKeyApi';
+
+const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
global.fetch = vi.fn();
vi.mock('keytar', () => {
+ const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
const keytar = {
- getPassword: vi.fn().mockResolvedValue('permit_key_a'.concat('a').repeat(97)),
+ setPassword: vi.fn(),
+ getPassword: vi.fn(),
+ deletePassword: vi.fn(),
+
};
return { ...keytar, default: keytar };
});
+
+vi.mock('../source/hooks/useApiKeyApi.js', () => ({
+ useApiKeyApi: vi.fn(() => ({
+ validateApiKeyScope: vi.fn(),
+ })),
+}));
+
+
+vi.mock('../source/lib/auth.js', async () => {
+ const original = await vi.importActual('../source/lib/auth.js');
+ return {
+ ...original,
+ loadAuthToken: vi.fn(() => demoPermitKey),
+ };
+});
+
describe('PDP Check Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks(); // Ensure all mocks are cleared before each test
+ });
+
afterEach(() => {
- // Clear mock calls after each test
- vi.clearAllMocks();
+ vi.restoreAllMocks(); // Restore all mocked modules after each test
});
- it('should render with the given options', async () => {
- (fetch as any).mockResolvedValueOnce({
+
+ it('should render with the given options and allow access', async () => {
+ (fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ allow: true }),
});
+
+ vi.mocked(useApiKeyApi).mockReturnValue({
+ validateApiKeyScope: vi.fn(() =>
+ Promise.resolve({
+ valid: true,
+ scope: {
+ project_id: 'proj1',
+ environment_id: 'proj1',
+ },
+ error: null,
+ }),
+ ),
+ });
+
const options = {
user: 'testUser',
resource: 'testResource',
action: 'testAction',
tenant: 'testTenant',
keyAccount: 'testKeyAccount',
+ demoPermitKey: demoPermitKey
};
const { lastFrame } = render();
+ const frameString = lastFrame()?.toString() ?? '';
+ expect(frameString).toMatch(/Loading Token/);
+ await delay(100);
expect(lastFrame()).toContain(
- `Checking user="testUser"action=testAction resource=testResourceat tenant=testTenant`,
+ `Checking user="testUser" action="testAction" resource="testResource" at tenant="testTenant"`,
);
+
await delay(50);
- console.log(lastFrame());
+
expect(lastFrame()?.toString()).toContain('ALLOWED');
});
- it('should render with the given options', async () => {
- (fetch as any).mockResolvedValueOnce({
+
+ it('should render with the given options and deny access', async () => {
+ (fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ allow: false }),
});
+
+ vi.mocked(useApiKeyApi).mockReturnValue({
+ validateApiKeyScope: vi.fn(() =>
+ Promise.resolve({
+ valid: true,
+ scope: {
+ project_id: 'proj1',
+ environment_id: 'proj1',
+ },
+ error: null,
+ }),
+ ),
+ });
+
const options = {
user: 'testUser',
resource: 'testResource',
action: 'testAction',
tenant: 'testTenant',
+ demoPermitKey: demoPermitKey,
keyAccount: 'testKeyAccount',
+
};
const { lastFrame } = render();
+ const frameString = lastFrame()?.toString() ?? '';
+ expect(frameString).toMatch(/Loading Token/);
+ await delay(100);
expect(lastFrame()).toContain(
- `Checking user="testUser"action=testAction resource=testResourceat tenant=testTenant`,
+ `Checking user="testUser" action="testAction" resource="testResource" at tenant="testTenant"`,
);
+
await delay(50);
+
expect(lastFrame()?.toString()).toContain('DENIED');
});
- it('should render with the given options', async () => {
+
+ it('should render an error when fetch fails', async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
text: async () => 'Error',
});
+
+ vi.mocked(useApiKeyApi).mockReturnValue({
+ validateApiKeyScope: vi.fn(() =>
+ Promise.resolve({
+ valid: true,
+ scope: {
+ project_id: 'proj1',
+ environment_id: 'proj1',
+ },
+ error: null,
+ }),
+ ),
+ });
+
const options = {
user: 'testUser',
resource: 'testResource',
action: 'testAction',
tenant: 'testTenant',
keyAccount: 'testKeyAccount',
+ demoPermitKey: demoPermitKey
};
const { lastFrame } = render();
+ const frameString = lastFrame()?.toString() ?? '';
+ expect(frameString).toMatch(/Loading Token/);
+ await delay(100);
expect(lastFrame()).toContain(
- `Checking user="testUser"action=testAction resource=testResourceat tenant=testTenant`,
+ `Checking user="testUser" action="testAction" resource="testResource" at tenant="testTenant"`,
);
- await delay(50);
+
+ await delay(100);
+
expect(lastFrame()?.toString()).toContain('Error');
});
- it('should render with the given options with multiple resource', async () => {
- (fetch as any).mockResolvedValueOnce({
+
+ it('should render with multiple resources and allow access', async () => {
+ (fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ allow: true }),
});
+
+ vi.mocked(useApiKeyApi).mockReturnValue({
+ validateApiKeyScope: vi.fn(() =>
+ Promise.resolve({
+ valid: true,
+ scope: {
+ project_id: 'proj1',
+ environment_id: 'proj1',
+ },
+ error: null,
+ }),
+ ),
+ });
+
const options = {
user: 'testUser',
- resource: 'testResourceType: testRecsourceKey',
+ resource: 'testResourceType: testResourceKey',
action: 'testAction',
tenant: 'testTenant',
keyAccount: 'testKeyAccount',
+ demoPermitKey: demoPermitKey
};
const { lastFrame } = render();
- expect(lastFrame())
- .toContain(`Checking user="testUser"action=testAction resource=testResourceType: testRecsourceKeyat
-tenant=testTenant`);
+ const frameString = lastFrame()?.toString() ?? '';
+ expect(frameString).toMatch(/Loading Token/);
+ await delay(100);
+ expect(lastFrame()).toContain(
+ `Checking user="testUser" action="testAction" resource="testResourceType: testResourceKey" at\ntenant="testTenant"`,
+ );
+
await delay(50);
+
expect(lastFrame()?.toString()).toContain('ALLOWED');
});
});
diff --git a/tests/components/AuthProvider.test.tsx b/tests/components/AuthProvider.test.tsx
index bd6e285..08fe63d 100644
--- a/tests/components/AuthProvider.test.tsx
+++ b/tests/components/AuthProvider.test.tsx
@@ -1,14 +1,39 @@
import React from 'react';
import { render } from 'ink-testing-library';
import { AuthProvider, useAuth } from '../../source/components/AuthProvider.js';
-import { loadAuthToken } from '../../source/lib/auth.js';
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { authCallbackServer, browserAuth, loadAuthToken } from '../../source/lib/auth.js';
+import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import delay from 'delay';
+import * as keytar from "keytar"
-vi.mock('../../source/lib/auth.js', () => ({
- loadAuthToken: vi.fn(),
-}));
+
+const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
+
+global.fetch = vi.fn();
+
+vi.mock('../../source/lib/auth.js', async () => {
+ const original = await vi.importActual('../../source/lib/auth.js');
+ return {
+ ...original,
+ loadAuthToken: vi.fn(),
+ browserAuth: vi.fn(),
+ authCallbackServer: vi.fn(),
+ };
+});
+
+vi.mock('keytar', () => {
+ const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
+
+ const keytar = {
+ setPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ getPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ deletePassword: vi.fn().mockResolvedValue(demoPermitKey),
+
+ };
+ return { ...keytar, default: keytar };
+});
+const enter = '\r';
describe('AuthProvider', () => {
it('should display loading text while loading token', async () => {
@@ -22,23 +47,18 @@ describe('AuthProvider', () => {
expect(lastFrame()).toContain('Loading Token');
});
- it('should display error message if loading token fails', async () => {
- (loadAuthToken as any).mockRejectedValueOnce(
- new Error('Failed to load token'),
- );
-
- const { lastFrame } = render(
-
- Child Component
- ,
- );
-
- await delay(50);
- expect(lastFrame()).toContain('Failed to load token');
- });
it('should display children when token is loaded successfully', async () => {
- (loadAuthToken as any).mockResolvedValueOnce('mocked-token');
+ (loadAuthToken as any).mockResolvedValue(demoPermitKey);
+ (fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ environment_id: 'env1',
+ project_id: 'proj1',
+ organization_id: 'org1',
+ }),
+ status: 200
+ });
const { lastFrame } = render(
@@ -46,26 +66,9 @@ describe('AuthProvider', () => {
,
);
- await delay(50);
+ await delay(500);
expect(lastFrame()).toContain('Child Component');
});
- it('should use the auth context successfully', async () => {
- const ChildComponent = () => {
- const { authToken } = useAuth();
- return {authToken || 'No token'};
- };
-
- (loadAuthToken as any).mockResolvedValueOnce('mocked-token');
-
- const { lastFrame } = render(
-
-
- ,
- );
-
- await delay(100);
- expect(lastFrame()).toContain('mocked-token');
- });
it('should throw an error when useAuth is called outside of AuthProvider', () => {
const ChildComponent = () => {
@@ -83,4 +86,196 @@ describe('AuthProvider', () => {
'useAuth must be used within an AuthProvider',
);
});
+
+ it('should display project if scope is project or greater', async () => {
+ (loadAuthToken as any).mockRejectedValue(
+ new Error('Failed to load token'),
+ );
+ vi.mocked(browserAuth).mockResolvedValue('verifier');
+ vi.mocked(authCallbackServer).mockResolvedValue('browser_token');
+ (fetch as any).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ headers: {
+ getSetCookie: () => ['cookie_value'],
+ },
+ json: async () => ({}),
+ error: null,
+ }).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ([
+ { id: 'org1', name: 'Organization 1' },
+ { id: 'org2', name: 'Organization 2' },
+ ]
+ )
+ }).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ headers: {
+ getSetCookie: () => ['cookie_value'],
+ },
+ json: async () => ({}),
+ error: null,
+ }).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ([
+ { id: 'proj1', name: 'Project 1' },
+ { id: 'proj2', name: 'Project 2' },
+ ]
+ )
+ }).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ({
+ data: [
+ {id: 'mock-id'}
+ ]
+ }
+ )
+ }).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => (
+ {id: 'mock-id'}
+
+ )
+ }).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ({
+ secret: "secret",
+ project_id: "proj_id",
+ organization_id: "organization_id",
+ }
+ )
+ })
+ const {stdin, lastFrame } = render(
+
+ Child Component
+ ,
+ );
+ await delay(500);
+ expect(lastFrame()).toContain('Select an organization');
+ await delay(50);
+ stdin.write(enter);
+ await delay(50);
+ expect(lastFrame()).toContain('Select a project');
+ await delay(50);
+ stdin.write(enter);
+ await delay(50);
+ expect(lastFrame()).toContain('Child Component');
+ });
+
+ it('should display project if scope is environment', async () => {
+ (loadAuthToken as any).mockRejectedValue(
+ new Error('Failed to load token'),
+ );
+ vi.mocked(browserAuth).mockResolvedValue('verifier');
+ vi.mocked(authCallbackServer).mockResolvedValue('browser_token');
+ (fetch as any).mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ headers: {
+ getSetCookie: () => ['cookie_value'],
+ },
+ json: async () => ({}),
+ error: null,
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ([
+ { id: 'org1', name: 'Organization 1' },
+ { id: 'org2', name: 'Organization 2' },
+ ]
+ )
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ headers: {
+ getSetCookie: () => ['cookie_value'],
+ },
+ json: async () => ({}),
+ error: null,
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ([
+ { id: 'proj1', name: 'Project 1' },
+ { id: 'proj2', name: 'Project 2' },
+ ]
+ )
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ([
+ { id: 'env1', name: 'Env 1' },
+ { id: 'env2', name: 'Env 2' },
+ ]
+ )
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ({
+ secret: 'super-secret'
+ }
+ )
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ({
+ data: [
+ {id: 'mock-id'}
+ ]
+ }
+ )
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ error: null,
+ json: async () => ({
+ secret: "secret",
+ project_id: "proj_id",
+ organization_id: "organization_id_",
+ }
+ )
+ })
+ const {stdin, lastFrame } = render(
+
+ Child Component
+ ,
+ );
+ await delay(500);
+ expect(lastFrame()).toContain('Select an organization');
+ await delay(50);
+ stdin.write(enter);
+ await delay(50);
+ expect(lastFrame()).toContain('Select a project');
+ await delay(50);
+ stdin.write(enter);
+ await delay(50);
+ expect(lastFrame()).toContain('Select an environment');
+ await delay(50);
+ stdin.write(enter);
+ await delay(50);
+ expect(lastFrame()).toContain('Child Component');
+ });
});
diff --git a/tests/components/PDPCommand.test.tsx b/tests/components/PDPCommand.test.tsx
index 81deba9..7b292e6 100644
--- a/tests/components/PDPCommand.test.tsx
+++ b/tests/components/PDPCommand.test.tsx
@@ -1,22 +1,53 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'ink-testing-library';
-import PDPCommand from '../../source/components/PDPCommand';
+import PDPRunComponent from '../../source/components/pdp/PDPRunComponent';
import { AuthProvider } from '../../source/components/AuthProvider';
import delay from 'delay';
import { loadAuthToken } from '../../source/lib/auth';
+import Run from '../../source/commands/pdp/run';
vi.mock('../../source/lib/auth', () => ({
loadAuthToken: vi.fn(),
}));
+
+vi.mock('../../source/hooks/useApiKeyApi', async() => {
+ const original = await vi.importActual('../../source/hooks/useApiKeyApi');
+ return {
+ ...original,
+ useApiKeyApi: () => ({
+ getApiKeyScope: vi.fn().mockResolvedValue({
+ response: {
+ environment_id: 'env1',
+ project_id: 'proj1',
+ organization_id: 'org1',
+ },
+ error: null,
+ status: 200,
+ }),
+ getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({
+ response: { secret: 'test-secret' },
+ error: null,
+ }),
+ validateApiKeyScope: vi.fn().mockResolvedValue({
+ valid: true,
+ scope: {
+ environment_id: 'env1',
+ project_id: 'proj1',
+ organization_id: 'org1',
+ },
+ error: null
+ })
+ }),
+ }
+});
+
describe('PDP Component', () => {
it('should render the PDP component with auth token', async () => {
(loadAuthToken as any).mockResolvedValueOnce(
'permit_key_'.concat('a'.repeat(97)),
);
const { lastFrame } = render(
-
-
- ,
+
);
expect(lastFrame()?.toString()).toMatch('Loading Token');
await delay(50);
@@ -24,14 +55,4 @@ describe('PDP Component', () => {
'Run the following command from your terminal:',
);
});
- it('should render the Spinner', async () => {
- const { lastFrame } = render(
-
-
- ,
- );
- expect(lastFrame()?.toString()).toMatch('Loading Token');
- await delay(50);
- expect(lastFrame()?.toString()).toMatch('Loading command');
- });
});
diff --git a/tests/components/SignupComponent.test.tsx b/tests/components/SignupComponent.test.tsx
new file mode 100644
index 0000000..8e69f7b
--- /dev/null
+++ b/tests/components/SignupComponent.test.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import { render } from 'ink-testing-library';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import SignupComponent from '../../source/components/signup/SignupComponent.js';
+import { useOrganisationApi } from '../../source/hooks/useOrganisationApi.js';
+import delay from 'delay';
+
+vi.mock('../../source/hooks/useOrganisationApi.js', () => ({
+ useOrganisationApi: vi.fn(() => ({
+ createOrg: vi.fn(),
+ })),
+}));
+
+const enter = '\r';
+
+describe('SignupComponent', () => {
+ let mockOnSuccess: () => void;
+ let mockCreateOrg: ReturnType;
+
+ beforeEach(() => {
+ mockOnSuccess = vi.fn();
+ mockCreateOrg = vi.fn();
+ (useOrganisationApi as jest.Mock).mockReturnValue({ createOrg: mockCreateOrg });
+ });
+
+ it('should display the default organization name and allow confirmation', async () => {
+ const { lastFrame, stdin } = render(
+
+ );
+
+ expect(lastFrame()).toMatch(/Welcome! Create your Workspace/);
+ expect(lastFrame()).toMatch(/Use the default organization name:/);
+
+ // Confirm the default organization name
+ stdin.write('y');
+ stdin.write(enter);
+ await delay(50);
+
+ });
+
+ it('should allow the user to enter a custom organization name', async () => {
+
+ mockCreateOrg.mockResolvedValue({ error: null });
+
+ const { lastFrame, stdin } = render(
+
+ );
+
+ expect(lastFrame()).toMatch(/Welcome! Create your Workspace/);
+ await delay(50);
+ // Cancel the default name confirmation
+ stdin.write('n');
+ // stdin.write(enter);
+ await delay(50);
+
+ expect(lastFrame()).toMatch(/Enter your organization name/);
+
+ // Submit a custom name
+ const customName = 'custom-org';
+ stdin.write(customName);
+ stdin.write(enter);
+ await delay(50);
+
+ });
+
+ it('should display a spinner while creating an organization', async () => {
+ mockCreateOrg.mockImplementation(() => new Promise(() => {})); // Never resolve
+ const { lastFrame, stdin } = render(
+
+ );
+
+ // Confirm the default organization name
+ await delay(50);
+ stdin.write('y');
+ // stdin.write(enter);
+ await delay(50);
+
+ expect(lastFrame()).toMatch(/Creating your organization/);
+ });
+
+ it('should handle errors and display an error message', async () => {
+ mockCreateOrg.mockResolvedValue({ error: 'Mock error' });
+
+ const { lastFrame, stdin } = render(
+
+ );
+
+ // Confirm the default organization name
+ await delay(50);
+ stdin.write('y');
+ // stdin.write(enter);
+ await delay(50);
+
+ expect(lastFrame()).toMatch(/Mock error/);
+ });
+
+ it('should call onSuccess after successful organization creation', async () => {
+ mockCreateOrg.mockResolvedValue({ error: null });
+
+ const { stdin } = render(
+
+ );
+
+ // Confirm the default organization name
+ await delay(50);
+ stdin.write('y');
+ await delay(50);
+
+ expect(mockOnSuccess).toHaveBeenCalledOnce();
+ });
+});
diff --git a/tests/github.test.tsx b/tests/github.test.tsx
index cbb20cc..d4898a5 100644
--- a/tests/github.test.tsx
+++ b/tests/github.test.tsx
@@ -1,6 +1,5 @@
import React from 'react';
import { render } from 'ink-testing-library';
-import SelectProject from '../source/components/gitops/SelectProject.js';
import RepositoryKey from '../source/components/gitops/RepositoryKey.js';
import SSHKey from '../source/components/gitops/SSHKey.js';
import BranchName from '../source/components/gitops/BranchName.js';
@@ -13,7 +12,13 @@ import {
getProjectList,
getRepoList,
} from '../source/lib/gitops/utils.js';
-import { loadAuthToken } from '../source/lib/auth.js';
+import { loadAuthToken, TokenType } from '../source/lib/auth.js';
+import { useApiKeyApi } from '../../source/hooks/useApiKeyApi';
+import * as keytar from "keytar"
+import SelectProject from '../source/components/SelectProject';
+import { useProjectAPI } from '../source/hooks/useProjectAPI';
+
+
const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
vi.mock('clipboardy', () => ({
@@ -21,9 +26,18 @@ vi.mock('clipboardy', () => ({
writeSync: vi.fn(),
},
}));
-vi.mock('../source/lib/auth.js', () => ({
- loadAuthToken: vi.fn(() => demoPermitKey),
+vi.mock('../source/lib/auth.js', async () => {
+ const original = await vi.importActual('../source/lib/auth.js');
+ return {
+ ...original,
+ loadAuthToken: vi.fn(() => demoPermitKey),
+ };
+});
+
+vi.mock('../source/hooks/useProjectAPI.js', () => ({
+ useProjectAPI: vi.fn(),
}));
+
vi.mock('../source/lib/gitops/utils.js', () => ({
getProjectList: vi.fn(() =>
Promise.resolve([
@@ -48,81 +62,51 @@ vi.mock('../source/lib/gitops/utils.js', () => ({
Promise.resolve({ id: '1', status: 'active', key: 'repo1' }),
),
}));
+vi.mock('../source/hooks/useApiKeyApi', async() => {
+ const original = await vi.importActual('../source/hooks/useApiKeyApi');
+ return {
+ ...original,
+ useApiKeyApi: () => ({
+ getApiKeyScope: vi.fn().mockResolvedValue({
+ response: {
+ environment_id: 'env1',
+ project_id: 'proj1',
+ organization_id: 'org1',
+ },
+ error: null,
+ status: 200,
+ }),
+ getProjectEnvironmentApiKey: vi.fn().mockResolvedValue({
+ response: { secret: 'test-secret' },
+ error: null,
+ }),
+ validateApiKeyScope: vi.fn().mockResolvedValue({
+ valid: true,
+ scope: {
+ environment_id: 'env1',
+ project_id: 'proj1',
+ organization_id: 'org1',
+ },
+ error: null
+ })
+ }),
+ }
+});
+
+vi.mock('keytar', () => {
+ const demoPermitKey = 'permit_key_'.concat('a'.repeat(97));
+ const keytar = {
+ setPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ getPassword: vi.fn().mockResolvedValue(demoPermitKey),
+ deletePassword: vi.fn().mockResolvedValue(demoPermitKey),
+
+ };
+ return { ...keytar, default: keytar };
+});
const enter = '\r';
const arrowUp = '\u001B[A';
const arrowDown = '\u001B[B';
-describe('Select Project Component', () => {
- it('should display loading message when projects are being loaded', async () => {
- const onProjectSubmit = vi.fn();
- const onError = vi.fn();
- const accessKey = 'permit_key_'.concat('a'.repeat(97));
- const { stdin, lastFrame } = render(
- ,
- );
-
- // Assertion
- expect(lastFrame()?.toString() ?? '').toMatch(/Loading projects.../);
- });
-
- it('Should display project after loading', async () => {
- const onProjectSubmit = vi.fn();
- const onError = vi.fn();
- const accessKey = 'permit_key_'.concat('a'.repeat(97));
- const { stdin, lastFrame } = render(
- ,
- );
-
- // Check that the loading message is displayed
- expect(lastFrame()?.toString() ?? '').toMatch(/Loading projects.../);
-
- // Wait for the mocked getProjectList to resolve and display the projects
- await delay(50); // Adjust time depending on the delay for fetching projects
-
- // Optionally: Check that the project names are displayed
- expect(lastFrame()?.toString()).toMatch(/Project 1/);
- expect(lastFrame()?.toString()).toMatch(/Project 2/);
- stdin.write(arrowDown);
- await delay(50);
- stdin.write(enter);
- await delay(50);
- expect(onProjectSubmit).toHaveBeenCalledOnce();
- expect(onProjectSubmit).toHaveBeenCalledWith('proj2');
- });
- it('should display an error message when fetching projects fails', async () => {
- const onProjectSubmit = vi.fn();
- const onError = vi.fn();
- const accessKey = 'permit_key_'.concat('a'.repeat(97));
-
- // Mock error response
- (getProjectList as any).mockRejectedValueOnce(
- new Error('Failed to fetch projects'),
- );
-
- const { stdin, lastFrame } = render(
- ,
- );
-
- // Initially, check for loading message
- expect(lastFrame()?.toString()).toMatch(/Loading projects.../);
-
- // Wait for the error to be handled
- await delay(50); // Adjust delay as needed
- expect(onError).toHaveBeenCalledWith('Failed to fetch projects');
- });
-});
describe('RepositoryKey Component', () => {
it('should call onRepoKeySubmit with the correct value', async () => {
@@ -130,6 +114,18 @@ describe('RepositoryKey Component', () => {
const onError = vi.fn();
const projectName = 'project1';
const accessToken = 'permit_key_'.concat('a'.repeat(97));
+ const mockGetProjects = vi.fn(() =>
+ Promise.resolve({
+ response: [
+ { id: 'proj1', name: 'Project 1' },
+ { id: 'proj2', name: 'Project 2' },
+ ],
+ error: null,
+ }),
+ );
+ (useProjectAPI as ReturnType).mockReturnValue({
+ getProjects: mockGetProjects,
+ });
const { stdin, lastFrame } = render(
{
const onError = vi.fn();
const projectName = 'project1';
const accessToken = 'permit_key_'.concat('a'.repeat(97));
+ const mockGetProjects = vi.fn(() =>
+ Promise.resolve({
+ response: [
+ { id: 'proj1', name: 'Project 1' },
+ { id: 'proj2', name: 'Project 2' },
+ ],
+ error: null,
+ }),
+ );
+ (useProjectAPI as ReturnType).mockReturnValue({
+ getProjects: mockGetProjects,
+ });
const { stdin, lastFrame } = render(
{
const onError = vi.fn();
const projectName = 'project1';
const accessToken = 'permit_key_'.concat('a'.repeat(97));
+ const mockGetProjects = vi.fn(() =>
+ Promise.resolve({
+ response: [
+ { id: 'proj1', name: 'Project 1' },
+ { id: 'proj2', name: 'Project 2' },
+ ],
+ error: null,
+ }),
+ );
+ (useProjectAPI as ReturnType).mockReturnValue({
+ getProjects: mockGetProjects,
+ });
const { stdin, lastFrame } = render(
{
const onError = vi.fn();
const projectName = 'project1';
const accessToken = 'permit_key_'.concat('a'.repeat(97));
+ const mockGetProjects = vi.fn(() =>
+ Promise.resolve({
+ response: [
+ { id: 'proj1', name: 'Project 1' },
+ { id: 'proj2', name: 'Project 2' },
+ ],
+ error: null,
+ }),
+ );
+ (useProjectAPI as ReturnType).mockReturnValue({
+ getProjects: mockGetProjects,
+ });
const { stdin, lastFrame } = render(
{
describe('SSHKey Component', () => {
it('should call onSSHKeySubmit with the correct value', async () => {
const onSSHKeySubmit = vi.fn();
+ const mockGetProjects = vi.fn(() =>
+ Promise.resolve({
+ response: [
+ { id: 'proj1', name: 'Project 1' },
+ { id: 'proj2', name: 'Project 2' },
+ ],
+ error: null,
+ }),
+ );
+ (useProjectAPI as ReturnType).mockReturnValue({
+ getProjects: mockGetProjects,
+ });
const onError = vi.fn();
const { stdin, lastFrame } = render(
,
@@ -265,6 +309,18 @@ describe('SSHKey Component', () => {
it("should call onError with 'Please enter a valid SSH URL' for empty value", async () => {
const onSSHKeySubmit = vi.fn();
const onError = vi.fn();
+ const mockGetProjects = vi.fn(() =>
+ Promise.resolve({
+ response: [
+ { id: 'proj1', name: 'Project 1' },
+ { id: 'proj2', name: 'Project 2' },
+ ],
+ error: null,
+ }),
+ );
+ (useProjectAPI as ReturnType).mockReturnValue({
+ getProjects: mockGetProjects,
+ });
const { stdin, lastFrame } = render(
,
);
@@ -287,6 +343,18 @@ describe('SSHKey Component', () => {
it("should call onError with 'Please enter a valid SSH URL' for invalid value", async () => {
const onSSHKeySubmit = vi.fn();
const onError = vi.fn();
+ const mockGetProjects = vi.fn(() =>
+ Promise.resolve({
+ response: [
+ { id: 'proj1', name: 'Project 1' },
+ { id: 'proj2', name: 'Project 2' },
+ ],
+ error: null,
+ }),
+ );
+ (useProjectAPI as ReturnType).mockReturnValue({
+ getProjects: mockGetProjects,
+ });
const { stdin, lastFrame } = render(
,
);
@@ -345,7 +413,7 @@ describe('Branch Name component', () => {
});
});
-describe('GiHub Complete Flow', () => {
+describe('GitHub Complete Flow', () => {
it('should complete the flow', async () => {
const { stdin, lastFrame } = render(
,
diff --git a/tests/hooks/useMemberAPI.test.tsx b/tests/hooks/useMemberAPI.test.tsx
index 10add98..71c4c7f 100644
--- a/tests/hooks/useMemberAPI.test.tsx
+++ b/tests/hooks/useMemberAPI.test.tsx
@@ -28,7 +28,7 @@ describe('useMemberApi', () => {
apiCall.mockResolvedValue({ success: true });
const inviteMember = async () => {
- const result = await inviteNewMember(authToken, body);
+ const result = await inviteNewMember(authToken, body, 'dummy_name', 'dummy_email');
return result.success ? 'Member invited' : 'Failed to invite member';
};
const [result, setResult] = React.useState(null);
@@ -56,7 +56,7 @@ describe('useMemberApi', () => {
apiCall.mockResolvedValue({ success: false });
const inviteMember = async () => {
- const result = await inviteNewMember(authToken, body);
+ const result = await inviteNewMember(authToken, body, 'dummy_name', 'dummy_email');
return result.success ? 'Member invited' : 'Failed to invite member';
};
const [result, setResult] = React.useState(null);