Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ jobs:
echo "tasks_test_user_group_id=${{ secrets.UIPATH_TASKS_TEST_USER_GROUP_ID_DEV || secrets.UIPATH_TASKS_TEST_USER_GROUP_ID }}" >> $GITHUB_OUTPUT
echo "tasks_test_user_id=${{ secrets.UIPATH_TASKS_TEST_USER_ID_DEV || secrets.UIPATH_TASKS_TEST_USER_ID }}" >> $GITHUB_OUTPUT
echo "organization_id=${{ secrets.UIPATH_ORGANIZATION_ID_DEV || secrets.UIPATH_ORGANIZATION_ID }}" >> $GITHUB_OUTPUT
echo "portal_url=${{ secrets.UIPATH_PORTAL_URL_DEV || secrets.UIPATH_PORTAL_URL }}" >> $GITHUB_OUTPUT

- name: Create Integration Test Configuration
run: |
Expand Down Expand Up @@ -107,6 +108,7 @@ jobs:
TASKS_TEST_USER_GROUP_ID=${{ steps.config.outputs.tasks_test_user_group_id }}
TASKS_TEST_USER_ID=${{ steps.config.outputs.tasks_test_user_id }}
UIPATH_ORGANIZATION_ID=${{ steps.config.outputs.organization_id }}
UIPATH_PORTAL_URL=${{ steps.config.outputs.portal_url }}
EOF

- name: Run Integration Tests
Expand Down
1 change: 1 addition & 0 deletions docs/oauth-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,4 @@ User management is authorized by the caller's **organization role** (organizatio
| `updateById()` | None — requires organization administrator role |
| `deleteById()` | None — requires organization administrator role |
| `create()` | None — requires organization administrator role |
| `invite()` | None — requires organization administrator role |
7 changes: 7 additions & 0 deletions src/models/identity/users.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export const UserMap: { [key: string]: string } = {
groupIDsToRemove: 'groupIdsToRemove',
};

/**
* Invite result field mappings (API field name → SDK field name).
*/
export const UserInviteResultMap: { [key: string]: string } = {
errorMsg: 'errorMessage',
};

/**
* Maps numeric user type codes (from API) to {@link UserType} enum values.
*/
Expand Down
19 changes: 19 additions & 0 deletions src/models/identity/users.internal-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,22 @@ export interface RawUserCreateResponse {
result: UserOperationResult;
users: RawUserEntry[];
}

/**
* Raw per-user result entry of `POST /api/User/InviteUsers`.
*/
export interface RawUserInviteResult {
email: string;
id: string;
/** Renamed to `errorMessage` in the SDK response. */
errorMsg: string | null;
success: boolean;
}

/**
* Raw response of `POST /api/User/InviteUsers`.
*/
export interface RawUserInviteResponse {
result: UserOperationResult;
users: RawUserInviteResult[];
}
40 changes: 40 additions & 0 deletions src/models/identity/users.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type {
RawUserGetResponse,
UserCreateData,
UserCreateOptions,
UserInviteData,
UserInviteResponse,
UserOperationResult,
UserUpdateOptions,
UserUpdateResponse,
Expand Down Expand Up @@ -144,6 +146,44 @@ export interface UserServiceModel {
organizationId: string,
options?: UserCreateOptions
): Promise<UserCreateResponse>;

/**
* Invites users to the organization by email.
*
* Each invited user receives an invitation email with an accept link that
* redirects to `redirectUrl`. Individual invitations can fail while the
* request as a whole succeeds — check `success` on each entry in `users`.
*
* @param users - Users to invite
* @returns Promise resolving to a {@link UserInviteResponse} with the overall outcome and per-user invitation results
*
* @example
* ```typescript
* const response = await users.invite([
* {
* email: 'jdoe@acme.com',
* redirectUrl: 'https://cloud.uipath.com/portal_/acceptInvite?organizationId=<organizationId>',
* },
* ]);
* for (const user of response.users) {
* console.log(`${user.email}: ${user.success ? 'invited' : user.errorMessage}`);
* }
* ```
*
* @example Invite into groups
* ```typescript
* await users.invite([
* {
* email: 'jdoe@acme.com',
* redirectUrl: 'https://cloud.uipath.com/portal_/acceptInvite?organizationId=<organizationId>',
* name: 'Jane',
* surname: 'Doe',
* groupIds: ['<groupId>'],
* },
* ]);
* ```
*/
invite(users: UserInviteData[]): Promise<UserInviteResponse>;
}

/**
Expand Down
48 changes: 48 additions & 0 deletions src/models/identity/users.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,51 @@ export interface UserCreateOptions {
groupIds?: string[];
}

/**
* A user to invite with `invite()`.
*/
export interface UserInviteData {
/** Email address the invitation is sent to. */
email: string;
/**
* URL the accept-invite link redirects to. Must be an allowed UiPath portal URL for
* the organization, e.g. `https://cloud.uipath.com/portal_/acceptInvite?organizationId=...`
* — other URLs are rejected per user with `Redirect URL is not valid`.
*/
redirectUrl: string;
/** First name. */
name?: string;
/** Last name. */
surname?: string;
/** Language code for the invitation email (e.g. `en`). */
language?: string;
/** GUIDs of groups the invited user is added to. */
groupIds?: string[];
}

/**
* Per-user outcome of `invite()`.
*/
export interface UserInviteResult {
/** Email address the invitation was sent to. */
email: string;
/** GUID of the newly invited user. All-zeros GUID when the invitation failed. */
id: string;
/** Why the invitation failed. `null` on success. */
errorMessage: string | null;
/** Whether this user was successfully invited. */
success: boolean;
}

/**
* Response from `invite()`.
*
* `result` reflects the request as a whole; individual invitations can still fail —
* check `success` on each entry in `users`.
*/
export interface UserInviteResponse {
/** Overall outcome of the invite request. */
result: UserOperationResult;
/** Per-user invitation outcomes. */
users: UserInviteResult[];
}
3 changes: 2 additions & 1 deletion src/services/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* Identity Users Service Module
*
* Provides organization-level user administration via the UiPath Identity API:
* - `Users` — retrieve, update and delete users, and create users in bulk
* - `Users` — retrieve, update and delete users, create users in bulk, and
* invite users to the organization by email
*
* @example
* ```typescript
Expand Down
17 changes: 17 additions & 0 deletions src/services/identity/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import type {
RawUserGetResponse,
UserCreateData,
UserCreateOptions,
UserInviteData,
UserInviteResponse,
UserInviteResult,
UserUpdateOptions,
UserUpdateResponse,
} from '../../models/identity/users.types';
Expand All @@ -22,9 +25,11 @@ import {
INTERNAL_USER_FIELDS,
type RawUserCreateResponse,
type RawUserEntry,
type RawUserInviteResponse,
} from '../../models/identity/users.internal-types';
import {
UserCategoryMap,
UserInviteResultMap,
UserMap,
UserTypeMap,
} from '../../models/identity/users.constants';
Expand Down Expand Up @@ -78,6 +83,18 @@ export class UserService extends BaseService implements UserServiceModel {
};
}

@track('Users.Invite')
async invite(users: UserInviteData[]): Promise<UserInviteResponse> {
const response = await this.post<RawUserInviteResponse>(
IDENTITY_USER_ENDPOINTS.INVITE,
users.map((user) => transformRequest(user, UserMap))
);
return {
result: response.data.result,
users: transformData(response.data.users, UserInviteResultMap) as unknown as UserInviteResult[],
};
}

/**
* Strips internal fields from a raw user entry and applies the SDK field
* renames and numeric → enum value mappings before returning it to the consumer.
Expand Down
1 change: 1 addition & 0 deletions src/utils/constants/endpoints/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export const IDENTITY_USER_ENDPOINTS = {
/** GET (retrieve), PUT (update) and DELETE share this URL — the HTTP method is chosen at the call site. */
BY_ID: (userId: string) => `${USER_API_BASE}/${userId}`,
BULK_CREATE: `${USER_API_BASE}/BulkCreate`,
INVITE: `${USER_API_BASE}/InviteUsers`,
} as const;
7 changes: 6 additions & 1 deletion tests/.env.integration.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,10 @@ TASKS_TEST_USER_GROUP_ID=
TASKS_TEST_USER_ID=

# Organization GUID (from UiPath Cloud > Admin > Organization Settings)
# Required by the Identity Users tests (user create operations)
# Required by the Identity Users tests (user create/invite operations)
UIPATH_ORGANIZATION_ID=3aa10965-a82d-4d9e-8366-0eff8e87bf7a

# Portal host for invite accept-links (e.g. https://cloud.uipath.com).
# Only needed when UIPATH_BASE_URL points at the API gateway host — invite
# redirect URLs must use the portal host. Defaults to UIPATH_BASE_URL.
UIPATH_PORTAL_URL=
8 changes: 8 additions & 0 deletions tests/integration/config/test-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export interface IntegrationConfig {
* as the partition the users belong to, and invite redirect URLs embed it).
*/
organizationId?: string;
/**
* Portal host for invite accept-links (e.g. `https://alpha.uipath.com`). Invite
* redirect URLs must point at the portal host — the API gateway host is rejected
* with `Redirect URL is not valid`. Defaults to `baseUrl` when they coincide.
*/
portalUrl?: string;
secret: string;
timeout: number;
skipCleanup: boolean;
Expand Down Expand Up @@ -85,6 +91,7 @@ function validateConfig(rawConfig: Record<string, unknown>): IntegrationConfig {
tenantName: rawConfig.tenantName as string,
tenantId: typeof rawConfig.tenantId === 'string' ? rawConfig.tenantId : undefined,
organizationId: typeof rawConfig.organizationId === 'string' ? rawConfig.organizationId : undefined,
portalUrl: typeof rawConfig.portalUrl === 'string' ? rawConfig.portalUrl : undefined,
secret: rawConfig.secret as string,
timeout: typeof rawConfig.timeout === 'number' && rawConfig.timeout > 0 ? rawConfig.timeout : 30000,
skipCleanup: typeof rawConfig.skipCleanup === 'boolean' ? rawConfig.skipCleanup : false,
Expand Down Expand Up @@ -129,6 +136,7 @@ export function loadIntegrationConfig(): IntegrationConfig {
tenantName: process.env.UIPATH_TENANT_NAME,
tenantId: process.env.UIPATH_TENANT_ID_DEV || undefined,
organizationId: process.env.UIPATH_ORGANIZATION_ID || undefined,
portalUrl: process.env.UIPATH_PORTAL_URL || undefined,
secret: process.env.UIPATH_SECRET,
timeout: process.env.INTEGRATION_TEST_TIMEOUT
? parseInt(process.env.INTEGRATION_TEST_TIMEOUT, 10)
Expand Down
64 changes: 64 additions & 0 deletions tests/integration/shared/identity/users.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ describe.each(modes)('Identity Users - Integration Tests [%s]', (mode) => {
let sharedUserName!: string;
const createdUserIds: string[] = [];

/**
* Invite redirect URLs must be an allowed portal URL for the organization —
* anything else (including the API gateway host) fails per-user with
* `Redirect URL is not valid`.
*/
const buildRedirectUrl = (email: string): string => {
const config = getTestConfig();
const params = new URLSearchParams({
organizationId,
emailForUserinvite: email,
organizationName: config.orgName,
language: 'en',
});
return `${config.portalUrl ?? config.baseUrl}/portal_/acceptInvite?${params.toString()}`;
};

beforeAll(async () => {
const service = getServices().users;
if (!service) {
Expand Down Expand Up @@ -134,6 +150,54 @@ describe.each(modes)('Identity Users - Integration Tests [%s]', (mode) => {
});
});

describe('invite', () => {
it('should invite a user by email and report the per-user outcome', async () => {
// example.com is reserved (RFC 2606) — the invitation email goes nowhere.
const email = `sdk-invite-${generateRandomString(10)}@example.com`;

const response = await users.invite([
{
email,
redirectUrl: buildRedirectUrl(email),
name: 'Sdk',
surname: 'Invited',
language: 'en',
},
]);

expect(response.result.succeeded).toBe(true);
expect(response.users.length).toBe(1);

const invited = response.users[0];
expect(invited.email).toBe(email);
expect(invited.success).toBe(true);
expect(invited.errorMessage).toBeNull();
expect(invited.id.length).toBeGreaterThan(0);

createdUserIds.push(invited.id);

// The invited user is a real user retrievable by ID with the invite pending.
const user = await users.getById(invited.id);
expect(user.email).toBe(email);
expect(user.invitationAccepted).toBe(false);
});

it('should report a per-user failure for a disallowed redirect URL', async () => {
const email = `sdk-invite-${generateRandomString(10)}@example.com`;

const response = await users.invite([
{ email, redirectUrl: 'https://not-a-uipath-portal.example.com/accept' },
]);

// The request as a whole succeeds; the individual invitation fails.
expect(response.result.succeeded).toBe(true);
const invited = response.users[0];
expect(invited.success).toBe(false);
expect(typeof invited.errorMessage).toBe('string');
expect(invited).not.toHaveProperty('errorMsg');
});
});

describe('deleteById', () => {
it('should delete a user and make subsequent retrieval fail', async () => {
const userName = `sdkit${generateRandomString(10)}`;
Expand Down
Loading
Loading