From e1da5237b6a888e1c2d3a53bb8d1bd681aad672a Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Thu, 22 Jan 2026 00:08:40 +0530 Subject: [PATCH 01/15] @W-20893693: Add AM topic with user and role command --- docs/api-readme.md | 116 ++ docs/cli/index.md | 5 + docs/cli/role.md | 286 +++++ docs/cli/user.md | 352 ++++++ packages/b2c-cli/README.md | 45 + packages/b2c-cli/package.json | 7 + packages/b2c-cli/src/commands/role/get.ts | 84 ++ packages/b2c-cli/src/commands/role/grant.ts | 85 ++ packages/b2c-cli/src/commands/role/list.ts | 174 +++ packages/b2c-cli/src/commands/role/revoke.ts | 85 ++ packages/b2c-cli/src/commands/user/create.ts | 71 ++ packages/b2c-cli/src/commands/user/delete.ts | 77 ++ packages/b2c-cli/src/commands/user/get.ts | 136 ++ packages/b2c-cli/src/commands/user/list.ts | 193 +++ packages/b2c-cli/src/commands/user/reset.ts | 52 + packages/b2c-cli/src/commands/user/update.ts | 80 ++ .../b2c-cli/test/commands/role/get.test.ts | 176 +++ .../b2c-cli/test/commands/role/grant.test.ts | 223 ++++ .../b2c-cli/test/commands/role/list.test.ts | 229 ++++ .../b2c-cli/test/commands/role/revoke.test.ts | 220 ++++ .../b2c-cli/test/commands/user/create.test.ts | 181 +++ .../b2c-cli/test/commands/user/delete.test.ts | 191 +++ .../b2c-cli/test/commands/user/get.test.ts | 186 +++ .../b2c-cli/test/commands/user/list.test.ts | 302 +++++ .../b2c-cli/test/commands/user/update.test.ts | 248 ++++ packages/b2c-cli/test/helpers/role.ts | 61 + packages/b2c-cli/test/helpers/user.ts | 54 + packages/b2c-tooling-sdk/README.md | 87 ++ packages/b2c-tooling-sdk/package.json | 24 +- .../specs/am-roles-api-v1.yaml | 316 +++++ .../specs/am-users-api-v1.yaml | 1102 +++++++++++++++++ packages/b2c-tooling-sdk/src/cli/index.ts | 2 + .../b2c-tooling-sdk/src/cli/role-command.ts | 43 + .../b2c-tooling-sdk/src/cli/user-command.ts | 43 + .../src/clients/am-roles-api.generated.ts | 299 +++++ .../src/clients/am-roles-api.ts | 263 ++++ .../src/clients/am-users-api.generated.ts | 892 +++++++++++++ .../src/clients/am-users-api.ts | 467 +++++++ packages/b2c-tooling-sdk/src/clients/index.ts | 43 + .../src/clients/middleware-registry.ts | 4 +- packages/b2c-tooling-sdk/src/index.ts | 38 + .../src/operations/roles/index.ts | 53 + .../src/operations/users/index.ts | 278 +++++ .../test/cli/role-command.test.ts | 64 + .../test/cli/user-command.test.ts | 64 + .../test/clients/am-roles-api.test.ts | 179 +++ .../test/clients/am-users-api.test.ts | 331 +++++ .../test/operations/roles/index.test.ts | 138 +++ .../test/operations/users/index.test.ts | 338 +++++ 49 files changed, 8985 insertions(+), 2 deletions(-) create mode 100644 docs/cli/role.md create mode 100644 docs/cli/user.md create mode 100644 packages/b2c-cli/src/commands/role/get.ts create mode 100644 packages/b2c-cli/src/commands/role/grant.ts create mode 100644 packages/b2c-cli/src/commands/role/list.ts create mode 100644 packages/b2c-cli/src/commands/role/revoke.ts create mode 100644 packages/b2c-cli/src/commands/user/create.ts create mode 100644 packages/b2c-cli/src/commands/user/delete.ts create mode 100644 packages/b2c-cli/src/commands/user/get.ts create mode 100644 packages/b2c-cli/src/commands/user/list.ts create mode 100644 packages/b2c-cli/src/commands/user/reset.ts create mode 100644 packages/b2c-cli/src/commands/user/update.ts create mode 100644 packages/b2c-cli/test/commands/role/get.test.ts create mode 100644 packages/b2c-cli/test/commands/role/grant.test.ts create mode 100644 packages/b2c-cli/test/commands/role/list.test.ts create mode 100644 packages/b2c-cli/test/commands/role/revoke.test.ts create mode 100644 packages/b2c-cli/test/commands/user/create.test.ts create mode 100644 packages/b2c-cli/test/commands/user/delete.test.ts create mode 100644 packages/b2c-cli/test/commands/user/get.test.ts create mode 100644 packages/b2c-cli/test/commands/user/list.test.ts create mode 100644 packages/b2c-cli/test/commands/user/update.test.ts create mode 100644 packages/b2c-cli/test/helpers/role.ts create mode 100644 packages/b2c-cli/test/helpers/user.ts create mode 100644 packages/b2c-tooling-sdk/specs/am-roles-api-v1.yaml create mode 100644 packages/b2c-tooling-sdk/specs/am-users-api-v1.yaml create mode 100644 packages/b2c-tooling-sdk/src/cli/role-command.ts create mode 100644 packages/b2c-tooling-sdk/src/cli/user-command.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/am-roles-api.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/am-roles-api.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/am-users-api.generated.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/am-users-api.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/roles/index.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/users/index.ts create mode 100644 packages/b2c-tooling-sdk/test/cli/role-command.test.ts create mode 100644 packages/b2c-tooling-sdk/test/cli/user-command.test.ts create mode 100644 packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts create mode 100644 packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/roles/index.test.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/users/index.test.ts diff --git a/docs/api-readme.md b/docs/api-readme.md index 56d0f430..327da4fb 100644 --- a/docs/api-readme.md +++ b/docs/api-readme.md @@ -240,6 +240,122 @@ const { data, error } = await instance.ocapi.PATCH('/code_versions/{code_version }); ``` +## Account Manager Operations + +The SDK provides operations for managing users and roles. + +### User Management + +```typescript +import { + createAccountManagerClient, + listUsers, + getUserByLogin, + createUser, + updateUser, + deleteUser, + resetUser, + grantRole, + revokeRole, +} from '@salesforce/b2c-tooling-sdk/operations/users'; +import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +// Create Account Manager client +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// List users with pagination +const users = await listUsers(client, { size: 25, page: 0 }); + +// Get user by email +const user = await getUserByLogin(client, 'user@example.com'); + +// Create a new user +const newUser = await createUser(client, { + user: { + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + organizations: ['org-id'], + primaryOrganization: 'org-id', + }, +}); + +// Update a user +await updateUser(client, { + userId: user.id!, + changes: { firstName: 'Jane' }, +}); + +// Grant a role to a user +await grantRole(client, { + userId: user.id!, + role: 'bm-admin', + scope: 'tenant1,tenant2', // Optional tenant filter +}); + +// Revoke a role from a user +await revokeRole(client, { + userId: user.id!, + role: 'bm-admin', + scope: 'tenant1', // Optional: remove specific scope +}); + +// Reset user to INITIAL state +await resetUser(client, user.id!); + +// Delete (disable) a user +await deleteUser(client, user.id!); +``` + +### Role Management + +```typescript +import { + createAccountManagerRolesClient, + getRole, + listRoles, +} from '@salesforce/b2c-tooling-sdk/operations/roles'; +import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +// Create Account Manager Roles client +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerRolesClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Get role details by ID +const role = await getRole(client, 'bm-admin'); + +// List all roles with pagination +const roles = await listRoles(client, { size: 25, page: 0 }); + +// List roles filtered by target type +const userRoles = await listRoles(client, { + size: 25, + page: 0, + roleTargetType: 'User', +}); +``` + +### Required Permissions + +Account Manager operations require: +- OAuth client with `sfcc.accountmanager.user.manage` scope +- Account Manager hostname configuration + ## Logging Configure logging for debugging HTTP requests: diff --git a/docs/cli/index.md b/docs/cli/index.md index 6e0d53d7..42a3d147 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -43,6 +43,11 @@ These flags are available on all commands that interact with B2C instances: - [SLAS Commands](./slas) - Manage Shopper Login and Access Service (SLAS) API clients - [Custom APIs](./custom-apis) - SCAPI Custom API endpoint status +### Account Management + +- [User Management](./user) - Manage Account Manager users (list, create, update, delete, reset) +- [Role Management](./role) - Manage Account Manager roles and role assignments + ### Utilities - [Auth Commands](./auth) - Authentication and token management diff --git a/docs/cli/role.md b/docs/cli/role.md new file mode 100644 index 00000000..9709ea0e --- /dev/null +++ b/docs/cli/role.md @@ -0,0 +1,286 @@ +--- +description: Commands for managing Account Manager roles including listing roles, viewing role details, and granting/revoking roles to users. +--- + +# Role Management Commands + +Commands for managing roles and role assignments in Account Manager. + +## Global Role Flags + +These flags are available on all role commands: + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (e.g., `account.demandware.com`) | + +## Authentication + +Role commands require an Account Manager API Client with OAuth authentication. + +### Required Configuration + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID for Account Manager | +| `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret for Account Manager | + +### Required Roles + +The API client must have the following role: +- `sfcc.accountmanager.user.manage` - Required for role assignment operations + +### Configuration + +```bash +# Set Account Manager host +export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com + +# Set OAuth credentials +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-client-secret + +# List roles +b2c role list +``` + +--- + +## b2c role list + +List roles in Account Manager with pagination support. + +### Usage + +```bash +b2c role list [FLAGS] +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--page` | Page number (0-based) | `0` | +| `--size` | Number of results per page (1-4000) | `20` | +| `--target-type` | Filter by target type (`User` or `ApiClient`) | All types | +| `--columns` | Comma-separated list of columns to display | Default columns | +| `--extended` | Show all available columns | `false` | +| `--json` | Output results as JSON | `false` | + +### Default Columns + +- ID +- Description +- Scope +- Internal Role + +### Extended Columns + +- Target Type + +### Examples + +```bash +# List first page of roles (default: 20 per page) +b2c role list + +# List roles with custom page size +b2c role list --size 50 + +# Get second page of results +b2c role list --page 1 --size 25 + +# Filter roles by target type +b2c role list --target-type User + +# Show all columns +b2c role list --extended + +# Show only specific columns +b2c role list --columns id,description + +# Output as JSON +b2c role list --json + +# Using environment variables +export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-client-secret +b2c role list +``` + +### Output + +Displays a table of roles with the selected columns. If more pages are available, an info message is displayed at the end. + +### Notes + +- Page size must be between 1 and 4000 +- Page number must be a non-negative integer (0-based) +- If the requested page exceeds available data, an error is returned +- Target type filter accepts `User` or `ApiClient` + +--- + +## b2c role get + +Get detailed information about a specific role. + +### Usage + +```bash +b2c role get +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `ROLE_ID` | Role identifier (e.g., `bm-admin`, `SLAS_ORGANIZATION_ADMIN`) | Yes | + +### Flags + +| Flag | Description | +|------|-------------| +| `--json` | Output results as JSON | + +### Examples + +```bash +# Get role details +b2c role get bm-admin + +# Get internal role details +b2c role get SLAS_ORGANIZATION_ADMIN + +# Output as JSON +b2c role get bm-admin --json +``` + +### Output + +When not using `--json`, displays formatted role information including: + +- Basic Information: ID, Description, Scope, Target Type +- Internal Role: Whether this is an internal role + +### Notes + +- Role ID can be either the external role name (e.g., `bm-admin`) or internal role enum name (e.g., `ECOM_ADMIN`) +- If role is not found, an error is returned + +--- + +## b2c role grant + +Grant a role to a user, optionally with tenant scope. + +### Usage + +```bash +b2c role grant --role [FLAGS] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +### Required Flags + +| Flag | Description | +|------|-------------| +| `--role`, `-r` | Role ID to grant (e.g., `bm-admin`) | Yes | + +### Optional Flags + +| Flag | Description | +|------|-------------| +| `--scope`, `-s` | Tenant scope (comma-separated list of tenant IDs). If not provided, grants role without scope restrictions. | +| `--json` | Output results as JSON | + +### Examples + +```bash +# Grant a role without scope +b2c role grant user@example.com --role bm-admin + +# Grant a role with single tenant scope +b2c role grant user@example.com --role bm-admin --scope tenant1 + +# Grant a role with multiple tenant scopes +b2c role grant user@example.com --role bm-admin --scope "tenant1,tenant2" + +# Using short flags +b2c role grant user@example.com -r bm-admin -s tenant1 + +# Output as JSON +b2c role grant user@example.com --role bm-admin --scope tenant1 --json +``` + +### Notes + +- If the user already has the role, the scope will be updated if `--scope` is provided +- If `--scope` is not provided, the role is granted without tenant restrictions +- If `--scope` is provided, it replaces any existing scope for that role +- Multiple scopes can be specified as a comma-separated list +- If user is not found, an error is returned + +--- + +## b2c role revoke + +Revoke a role from a user, optionally removing specific tenant scope. + +### Usage + +```bash +b2c role revoke --role [FLAGS] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +### Required Flags + +| Flag | Description | +|------|-------------| +| `--role`, `-r` | Role ID to revoke (e.g., `bm-admin`) | Yes | + +### Optional Flags + +| Flag | Description | +|------|-------------| +| `--scope`, `-s` | Tenant scope to remove (comma-separated). If not provided, removes the entire role. | +| `--json` | Output results as JSON | + +### Examples + +```bash +# Revoke entire role +b2c role revoke user@example.com --role bm-admin + +# Revoke specific tenant scope (keeps role for other tenants) +b2c role revoke user@example.com --role bm-admin --scope tenant1 + +# Revoke multiple tenant scopes +b2c role revoke user@example.com --role bm-admin --scope "tenant1,tenant2" + +# Using short flags +b2c role revoke user@example.com -r bm-admin -s tenant1 + +# Output as JSON +b2c role revoke user@example.com --role bm-admin --scope tenant1 --json +``` + +### Notes + +- If `--scope` is not provided, the entire role is removed from the user +- If `--scope` is provided, only the specified tenant scopes are removed +- If all scopes are removed, the role itself is removed +- Multiple scopes can be specified as a comma-separated list +- If user is not found, an error is returned diff --git a/docs/cli/user.md b/docs/cli/user.md new file mode 100644 index 00000000..e2d1f401 --- /dev/null +++ b/docs/cli/user.md @@ -0,0 +1,352 @@ +--- +description: Commands for managing Account Manager users including listing, creating, updating, and deleting users. +--- + +# User Management Commands + +Commands for managing users in Account Manager. + +## Global User Flags + +These flags are available on all user commands: + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (e.g., `account.demandware.com`) | + +## Authentication + +User commands require an Account Manager API Client with OAuth authentication. + +### Required Configuration + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID for Account Manager | +| `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret for Account Manager | + +### Required Roles + +The API client must have the following role: +- `sfcc.accountmanager.user.manage` - Required for all user management operations + +### Configuration + +```bash +# Set Account Manager host +export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com + +# Set OAuth credentials +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-client-secret + +# List users +b2c user list +``` + +--- + +## b2c user list + +List users in Account Manager with pagination support. + +### Usage + +```bash +b2c user list [FLAGS] +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--page` | Page number (0-based) | `0` | +| `--size` | Number of results per page (1-4000) | `20` | +| `--columns` | Comma-separated list of columns to display | Default columns | +| `--extended` | Show all available columns | `false` | +| `--json` | Output results as JSON | `false` | + +### Default Columns + +- Email +- First Name +- Last Name +- State +- Password Expired +- 2FA Enabled +- Linked to SF +- Last Login + +### Extended Columns + +- Roles +- Organizations + +### Examples + +```bash +# List first page of users (default: 20 per page) +b2c user list + +# List users with custom page size +b2c user list --size 50 + +# Get second page of results +b2c user list --page 1 --size 25 + +# Show all columns including roles and organizations +b2c user list --extended + +# Show only specific columns +b2c user list --columns mail,firstName,userState + +# Output as JSON +b2c user list --json + +# Using environment variables +export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-client-secret +b2c user list +``` + +### Output + +Displays a table of users with the selected columns. If more pages are available, an info message is displayed at the end. + +### Notes + +- Page size must be between 1 and 4000 +- Page number must be a non-negative integer (0-based) +- If the requested page exceeds available data, an error is returned + +--- + +## b2c user get + +Get detailed information about a specific user. + +### Usage + +```bash +b2c user get +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +### Flags + +| Flag | Description | +|------|-------------| +| `--json` | Output results as JSON | + +### Examples + +```bash +# Get user details +b2c user get user@example.com + +# Output as JSON +b2c user get user@example.com --json +``` + +### Output + +When not using `--json`, displays formatted user information including: + +- Basic Information: ID, Email, Name, State, Organization, etc. +- Organizations: List of organization IDs +- Roles: List of role IDs +- Role Tenant Filters: Role-specific tenant scope mappings + +### Notes + +- User is identified by email address (login) +- If user is not found, an error is returned + +--- + +## b2c user create + +Create a new user in Account Manager. + +### Usage + +```bash +b2c user create --org --mail [FLAGS] +``` + +### Required Flags + +| Flag | Description | +|------|-------------| +| `--org` | Organization ID where the user will be created | +| `--mail` | User email address (login) | + +### Optional Flags + +| Flag | Description | +|------|-------------| +| `--first-name` | User's first name | +| `--last-name` | User's last name | +| `--display-name` | Display name | +| `--preferred-locale` | Preferred locale (e.g., `en_US`) | +| `--business-phone` | Business phone number | +| `--home-phone` | Home phone number | +| `--mobile-phone` | Mobile phone number | +| `--json` | Output results as JSON | + +### Examples + +```bash +# Create a basic user +b2c user create --org org-123 --mail user@example.com \ + --first-name John --last-name Doe + +# Create a user with additional details +b2c user create --org org-123 --mail user@example.com \ + --first-name John --last-name Doe \ + --display-name "John Doe" \ + --preferred-locale en_US \ + --business-phone "+1-555-123-4567" + +# Output as JSON +b2c user create --org org-123 --mail user@example.com \ + --first-name John --last-name Doe --json +``` + +### Notes + +- User will be created in INITIAL state +- User must be assigned roles separately using `b2c role grant` +- The user's primary organization is set to the specified `--org` + +--- + +## b2c user update + +Update an existing user's information. + +### Usage + +```bash +b2c user update [FLAGS] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +### Flags + +| Flag | Description | +|------|-------------| +| `--first-name` | Update first name | +| `--last-name` | Update last name | +| `--display-name` | Update display name | +| `--preferred-locale` | Update preferred locale | +| `--business-phone` | Update business phone | +| `--home-phone` | Update home phone | +| `--mobile-phone` | Update mobile phone | +| `--json` | Output results as JSON | + +### Examples + +```bash +# Update user's first name +b2c user update user@example.com --first-name Jane + +# Update multiple fields +b2c user update user@example.com \ + --first-name Jane \ + --last-name Smith \ + --display-name "Jane Smith" + +# Output as JSON +b2c user update user@example.com --first-name Jane --json +``` + +### Notes + +- At least one field must be provided to update +- Only specified fields will be updated; other fields remain unchanged +- If user is not found, an error is returned + +--- + +## b2c user reset + +Reset a user to INITIAL state, clearing password expiration and allowing password reset. + +### Usage + +```bash +b2c user reset +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +### Examples + +```bash +# Reset user to INITIAL state +b2c user reset user@example.com +``` + +### Notes + +- Resets the user's state to INITIAL +- Clears password expiration timestamp +- User will need to set a new password on next login + +--- + +## b2c user delete + +Delete (disable) a user in Account Manager. + +### Usage + +```bash +b2c user delete [FLAGS] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +### Flags + +| Flag | Description | +|------|-------------| +| `--purge` | Permanently delete the user (hard delete). User must be in DELETED state first. | + +### Examples + +```bash +# Soft delete (disable) a user +b2c user delete user@example.com + +# Permanently delete a user (must be in DELETED state first) +b2c user delete user@example.com --purge +``` + +### Notes + +- By default, this performs a soft delete (disables the user) +- Soft delete sets the user state to DELETED +- Use `--purge` for permanent deletion (hard delete) +- Purging requires the user to already be in DELETED state +- Deletion is permanent and cannot be undone diff --git a/packages/b2c-cli/README.md b/packages/b2c-cli/README.md index f89a7f55..dd020eba 100644 --- a/packages/b2c-cli/README.md +++ b/packages/b2c-cli/README.md @@ -186,6 +186,51 @@ List and inspect storefront sites. b2c sites list ``` +### User Management (Account Manager) + +Manage users in Account Manager. + +```sh +# List users with pagination +b2c user list --page 0 --size 20 + +# Get user details by email +b2c user get user@example.com + +# Create a new user +b2c user create --org org-id --mail user@example.com --first-name John --last-name Doe + +# Update a user +b2c user update user@example.com --first-name Jane + +# Reset a user to INITIAL state +b2c user reset user@example.com + +# Delete (disable) a user +b2c user delete user@example.com +``` + +### Role Management (Account Manager) + +Manage roles and role assignments in Account Manager. + +```sh +# List roles with pagination +b2c role list --page 0 --size 20 --target-type User + +# Get role details +b2c role get bm-admin + +# Grant a role to a user +b2c role grant user@example.com --role bm-admin + +# Grant a role with tenant scope +b2c role grant user@example.com --role bm-admin --scope "tenant1,tenant2" + +# Revoke a role from a user +b2c role revoke user@example.com --role bm-admin +``` + ### Authentication Get OAuth tokens for scripting. diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index ef1338e3..c1b5b748 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -42,6 +42,7 @@ "eslint-plugin-prettier": "^5.5.4", "execa": "^9.6.1", "mocha": "^10", + "msw": "^2.0.0", "oclif": "^4", "prettier": "^3.6.2", "shx": "^0.3.3", @@ -140,6 +141,12 @@ "sites": { "description": "List and inspect storefront sites\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/sites.html" }, + "user": { + "description": "Manage Account Manager users" + }, + "role": { + "description": "Manage roles for Account Manager users" + }, "slas": { "description": "Manage SLAS API clients and credentials\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/slas.html", "subtopics": { diff --git a/packages/b2c-cli/src/commands/role/get.ts b/packages/b2c-cli/src/commands/role/get.ts new file mode 100644 index 00000000..47ef799f --- /dev/null +++ b/packages/b2c-cli/src/commands/role/get.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {RoleCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getRole} from '@salesforce/b2c-tooling-sdk/operations/roles'; +import type {AccountManagerRole} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +/** + * Command to get details of a single Account Manager role. + */ +export default class RoleGet extends RoleCommand { + static args = { + roleId: Args.string({ + description: 'Role ID', + required: true, + }), + }; + + static description = t('commands.role.get.description', 'Get details of an Account Manager role'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> bm-admin', + '<%= config.bin %> <%= command.id %> bm-user --json', + ]; + + async run(): Promise { + const {roleId} = this.args; + + this.log(t('commands.role.get.fetching', 'Fetching role {{roleId}}...', {roleId})); + + const role = await getRole(this.accountManagerRolesClient, roleId); + + if (this.jsonEnabled()) { + return role; + } + + this.printRoleDetails(role); + + return role; + } + + private printRoleDetails(role: AccountManagerRole): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: 'Role Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields: [string, string | undefined][] = [ + ['ID', role.id], + ['Description', role.description], + ['Role Enum Name', role.roleEnumName], + ['Scope', role.scope], + ['Target Type', role.targetType || undefined], + ['2FA Enabled', role.twoFAEnabled?.toString()], + ['Internal Role', (role as {internalRole?: boolean}).internalRole?.toString()], + ]; + + for (const [label, value] of fields) { + if (value !== undefined) { + ui.div({text: `${label}:`, width: 25, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + + // Permissions + if (role.permissions && role.permissions.length > 0) { + ui.div({text: 'Permissions', padding: [2, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + ui.div( + {text: 'Permissions:', width: 25, padding: [0, 2, 0, 0]}, + {text: role.permissions.join(', '), padding: [0, 0, 0, 0]}, + ); + } + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/role/grant.ts b/packages/b2c-cli/src/commands/role/grant.ts new file mode 100644 index 00000000..9ef666f9 --- /dev/null +++ b/packages/b2c-cli/src/commands/role/grant.ts @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getUserByLogin, grantRole} from '@salesforce/b2c-tooling-sdk/operations/users'; +import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +/** + * Command to grant a role to an Account Manager user. + */ +export default class RoleGrant extends UserCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.role.grant.description', 'Grant a role to an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --role bm-admin', + '<%= config.bin %> <%= command.id %> user@example.com --role bm-admin --scope tenant1,tenant2', + ]; + + static flags = { + role: Flags.string({ + char: 'r', + description: 'Role to grant', + required: true, + }), + scope: Flags.string({ + char: 's', + description: 'Scope for the role (tenant IDs, comma-separated)', + }), + }; + + async run(): Promise { + const {login} = this.args; + const {role, scope} = this.flags; + + this.log(t('commands.role.grant.fetching', 'Fetching user {{login}}...', {login})); + + const user = await getUserByLogin(this.accountManagerClient, login); + + if (!user.id) { + this.error(t('commands.role.grant.noId', 'User does not have an ID')); + } + + this.log( + t('commands.role.grant.granting', 'Granting role {{role}} to user {{login}}...', { + role, + login, + }), + ); + + const updatedUser = await grantRole(this.accountManagerClient, { + userId: user.id, + role, + scope, + }); + + if (this.jsonEnabled()) { + return updatedUser; + } + + const message = scope + ? t('commands.role.grant.successWithScope', 'User {{login}} granted role {{role}} with scope {{scope}}.', { + login, + role, + scope, + }) + : t('commands.role.grant.success', 'User {{login}} granted role {{role}}.', {login, role}); + + this.log(message); + + return updatedUser; + } +} diff --git a/packages/b2c-cli/src/commands/role/list.ts b/packages/b2c-cli/src/commands/role/list.ts new file mode 100644 index 00000000..aadcea04 --- /dev/null +++ b/packages/b2c-cli/src/commands/role/list.ts @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags, Errors} from '@oclif/core'; +import {RoleCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {listRoles} from '@salesforce/b2c-tooling-sdk/operations/roles'; +import type {AccountManagerRole, RoleCollection} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +const COLUMNS: Record> = { + id: { + header: 'ID', + get: (r) => r.id || '-', + }, + description: { + header: 'Description', + get: (r) => r.description || '-', + }, + roleEnumName: { + header: 'Role Enum Name', + get: (r) => r.roleEnumName || '-', + }, + scope: { + header: 'Scope', + get: (r) => r.scope || '-', + }, + targetType: { + header: 'Target Type', + get: (r) => r.targetType || '-', + }, + twoFAEnabled: { + header: '2FA Enabled', + get: (r) => (r.twoFAEnabled ? 'Yes' : 'No'), + }, + permissions: { + header: 'Permissions', + get(r) { + if (!r.permissions || r.permissions.length === 0) return '-'; + return r.permissions.join(', '); + }, + extended: true, + }, +}; + +const DEFAULT_COLUMNS = ['id', 'description', 'roleEnumName']; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to list Account Manager roles. + */ +export default class RoleList extends RoleCommand { + static description = t('commands.role.list.description', 'List Account Manager roles'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --size 50', + '<%= config.bin %> <%= command.id %> --target-type User', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + size: Flags.integer({ + char: 's', + description: 'Page size (default: 20, min: 1, max: 4000)', + }), + page: Flags.integer({ + description: 'Page number (zero-based index, default: 0, min: 0)', + }), + 'target-type': Flags.string({ + char: 't', + description: 'Filter by target type (User or ApiClient)', + options: ['User', 'ApiClient'], + }), + columns: Flags.string({ + char: 'c', + description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show all columns including extended fields', + default: false, + }), + }; + + async run(): Promise { + const {size, page, 'target-type': targetType} = this.flags; + + // Ensure size and page are numbers (oclif might return undefined or string) + const pageSize = size === undefined ? 20 : Number(size); + const pageNumber = page === undefined ? 0 : Number(page); + + // Validate size parameter + if (pageSize < 1 || pageSize > 4000) { + throw new Errors.CLIError( + t('commands.role.list.invalidSize', 'Page size must be between 1 and 4000 (inclusive). Received: {{size}}', { + size: pageSize, + }), + ); + } + + // Validate page parameter (must be zero-based index, non-negative) + if (pageNumber < 0 || !Number.isFinite(pageNumber)) { + throw new Errors.CLIError( + t( + 'commands.role.list.invalidPage', + 'Page number must be a non-negative integer (zero-based index). Received: {{page}}', + {page: pageNumber}, + ), + ); + } + + this.log(t('commands.role.list.fetching', 'Fetching roles...')); + + const result = await listRoles(this.accountManagerRolesClient, { + size: pageSize, + page: pageNumber, + roleTargetType: targetType as 'ApiClient' | 'User' | undefined, + }); + + if (this.jsonEnabled()) { + return result; + } + + const roles = result.content || []; + if (roles.length === 0) { + this.log(t('commands.role.list.noRoles', 'No roles found.')); + return result; + } + + tableRenderer.render(roles, this.getSelectedColumns()); + + // Check if there are more pages (if we got a full page of results, there might be more) + if (roles.length === pageSize) { + const nextPage = pageNumber + 1; + this.log( + t('commands.role.list.moreRoles', 'More roles available. Use --page {{nextPage}} to view the next page.', { + nextPage, + }), + ); + } + + return result; + } + + /** + * Determines which columns to display based on flags. + */ + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + const requested = columnsFlag.split(',').map((c) => c.trim()); + const valid = tableRenderer.validateColumnKeys(requested); + if (valid.length === 0) { + this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); + return DEFAULT_COLUMNS; + } + return valid; + } + + if (extended) { + return tableRenderer.getColumnKeys(); + } + + return DEFAULT_COLUMNS; + } +} diff --git a/packages/b2c-cli/src/commands/role/revoke.ts b/packages/b2c-cli/src/commands/role/revoke.ts new file mode 100644 index 00000000..c417fb93 --- /dev/null +++ b/packages/b2c-cli/src/commands/role/revoke.ts @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getUserByLogin, revokeRole} from '@salesforce/b2c-tooling-sdk/operations/users'; +import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +/** + * Command to revoke a role from an Account Manager user. + */ +export default class RoleRevoke extends UserCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.role.revoke.description', 'Revoke a role from an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --role bm-admin', + '<%= config.bin %> <%= command.id %> user@example.com --role bm-admin --scope tenant1', + ]; + + static flags = { + role: Flags.string({ + char: 'r', + description: 'Role to revoke', + required: true, + }), + scope: Flags.string({ + char: 's', + description: 'Scope to remove (if not provided, removes entire role)', + }), + }; + + async run(): Promise { + const {login} = this.args; + const {role, scope} = this.flags; + + this.log(t('commands.role.revoke.fetching', 'Fetching user {{login}}...', {login})); + + const user = await getUserByLogin(this.accountManagerClient, login); + + if (!user.id) { + this.error(t('commands.role.revoke.noId', 'User does not have an ID')); + } + + this.log( + t('commands.role.revoke.revoking', 'Revoking role {{role}} from user {{login}}...', { + role, + login, + }), + ); + + const updatedUser = await revokeRole(this.accountManagerClient, { + userId: user.id, + role, + scope, + }); + + if (this.jsonEnabled()) { + return updatedUser; + } + + const message = scope + ? t('commands.role.revoke.successWithScope', 'User {{login}} revoked role {{role}} with scope {{scope}}.', { + login, + role, + scope, + }) + : t('commands.role.revoke.success', 'User {{login}} revoked role {{role}}.', {login, role}); + + this.log(message); + + return updatedUser; + } +} diff --git a/packages/b2c-cli/src/commands/user/create.ts b/packages/b2c-cli/src/commands/user/create.ts new file mode 100644 index 00000000..594fe42c --- /dev/null +++ b/packages/b2c-cli/src/commands/user/create.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags} from '@oclif/core'; +import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {createUser} from '@salesforce/b2c-tooling-sdk/operations/users'; +import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +/** + * Command to create a new Account Manager user. + */ +export default class UserCreate extends UserCommand { + static description = t('commands.user.create.description', 'Create a new Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> --org org-id --mail user@example.com --first-name John --last-name Doe', + '<%= config.bin %> <%= command.id %> --org org-id --mail user@example.com --first-name John --last-name Doe --json', + ]; + + static flags = { + org: Flags.string({ + char: 'o', + description: 'Organization ID to create the user in', + required: true, + }), + mail: Flags.string({ + char: 'm', + description: 'User email (login)', + required: true, + }), + 'first-name': Flags.string({ + description: 'First name', + required: true, + }), + 'last-name': Flags.string({ + description: 'Last name', + required: true, + }), + }; + + async run(): Promise { + const {org, mail, 'first-name': firstName, 'last-name': lastName} = this.flags; + + this.log(t('commands.user.create.creating', 'Creating user {{mail}} in organization {{org}}...', {mail, org})); + + const user = await createUser(this.accountManagerClient, { + user: { + mail, + firstName, + lastName, + organizations: [org], + primaryOrganization: org, + }, + }); + + if (this.jsonEnabled()) { + return user; + } + + this.log( + t('commands.user.create.success', 'User {{mail}} created successfully in organization {{org}}.', {mail, org}), + ); + + return user; + } +} diff --git a/packages/b2c-cli/src/commands/user/delete.ts b/packages/b2c-cli/src/commands/user/delete.ts new file mode 100644 index 00000000..61bac341 --- /dev/null +++ b/packages/b2c-cli/src/commands/user/delete.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getUserByLogin, deleteUser, purgeUser} from '@salesforce/b2c-tooling-sdk/operations/users'; +import {t} from '../../i18n/index.js'; + +/** + * Command to delete an Account Manager user. + */ +export default class UserDelete extends UserCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.user.delete.description', 'Delete an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com', + '<%= config.bin %> <%= command.id %> user@example.com --purge', + ]; + + static flags = { + purge: Flags.boolean({ + description: 'Purge the user completely (hard delete). User must be in DELETED state first.', + default: false, + }), + }; + + async run(): Promise { + const {login} = this.args; + const {purge} = this.flags; + + this.log(t('commands.user.delete.fetching', 'Fetching user {{login}}...', {login})); + + const user = await getUserByLogin(this.accountManagerClient, login); + + if (!user.id) { + this.error(t('commands.user.delete.noId', 'User does not have an ID')); + } + + if (purge) { + this.log( + t('commands.user.delete.purging', 'Purging user {{login}}...', { + login, + }), + ); + await purgeUser(this.accountManagerClient, user.id); + } else { + this.log( + t('commands.user.delete.deleting', 'Deleting user {{login}}...', { + login, + }), + ); + await deleteUser(this.accountManagerClient, user.id); + } + + if (this.jsonEnabled()) { + return; + } + + this.log( + t('commands.user.delete.success', 'User {{login}} {{action}} successfully.', { + login, + action: purge ? 'purged' : 'deleted', + }), + ); + } +} diff --git a/packages/b2c-cli/src/commands/user/get.ts b/packages/b2c-cli/src/commands/user/get.ts new file mode 100644 index 00000000..a921d9e3 --- /dev/null +++ b/packages/b2c-cli/src/commands/user/get.ts @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getUserByLogin} from '@salesforce/b2c-tooling-sdk/operations/users'; +import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +/** + * Command to get details of a single Account Manager user. + */ +export default class UserGet extends UserCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.user.get.description', 'Get details of an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com', + '<%= config.bin %> <%= command.id %> user@example.com --json', + ]; + + async run(): Promise { + const {login} = this.args; + + this.log(t('commands.user.get.fetching', 'Fetching user {{login}}...', {login})); + + const user = await getUserByLogin(this.accountManagerClient, login); + + if (this.jsonEnabled()) { + return user; + } + + this.printUserDetails(user); + + return user; + } + + private printBasicFields(ui: ReturnType, user: AccountManagerUser): void { + const isPasswordExpired = user.passwordExpirationTimestamp + ? user.passwordExpirationTimestamp < Date.now() + : undefined; + const twoFAEnabled = user.verifiers && user.verifiers.length > 0 ? 'Yes' : 'No'; + + const fields: [string, string | undefined][] = [ + ['ID', user.id], + ['Email', user.mail], + ['First Name', user.firstName], + ['Last Name', user.lastName], + ['Display Name', user.displayName], + ['State', user.userState], + ['Primary Organization', user.primaryOrganization], + ['Preferred Locale', user.preferredLocale || undefined], + ['Business Phone', user.businessPhone || undefined], + ['Home Phone', user.homePhone || undefined], + ['Mobile Phone', user.mobilePhone || undefined], + ['Linked to SF Identity', user.linkedToSfIdentity?.toString()], + ['2FA Enabled', twoFAEnabled], + ['Password Expired', isPasswordExpired === undefined ? undefined : isPasswordExpired ? 'Yes' : 'No'], + ['Last Login', user.lastLoginDate || undefined], + ['Created At', user.createdAt ? new Date(user.createdAt).toLocaleString() : undefined], + ['Last Modified', user.lastModified ? new Date(user.lastModified).toLocaleString() : undefined], + ]; + + for (const [label, value] of fields) { + if (value !== undefined) { + ui.div({text: `${label}:`, width: 25, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + } + + private printOrganizations(ui: ReturnType, user: AccountManagerUser): void { + if (user.organizations === undefined || user.organizations.length === 0) { + return; + } + + ui.div({text: 'Organizations', padding: [2, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const orgIds = user.organizations.map((o) => (typeof o === 'string' ? o : o.id || 'Unknown')); + ui.div( + {text: 'Organization IDs:', width: 25, padding: [0, 2, 0, 0]}, + {text: orgIds.join(', '), padding: [0, 0, 0, 0]}, + ); + } + + private printRoles(ui: ReturnType, user: AccountManagerUser): void { + if (user.roles === undefined || user.roles.length === 0) { + return; + } + + ui.div({text: 'Roles', padding: [2, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const roleNames = user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || r.id || 'Unknown')); + ui.div({text: 'Role IDs:', width: 25, padding: [0, 2, 0, 0]}, {text: roleNames.join(', '), padding: [0, 0, 0, 0]}); + } + + private printRoleTenantFilters(ui: ReturnType, user: AccountManagerUser): void { + if (user.roleTenantFilterMap === undefined || Object.keys(user.roleTenantFilterMap).length === 0) { + return; + } + + ui.div({text: 'Role Tenant Filters', padding: [2, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const [roleId, filter] of Object.entries(user.roleTenantFilterMap)) { + const filterValue = typeof filter === 'string' ? filter : JSON.stringify(filter); + ui.div({text: `${roleId}:`, width: 30, padding: [0, 2, 0, 0]}, {text: filterValue, padding: [0, 0, 0, 0]}); + } + } + + private printUserDetails(user: AccountManagerUser): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: 'User Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + this.printBasicFields(ui, user); + this.printOrganizations(ui, user); + this.printRoles(ui, user); + this.printRoleTenantFilters(ui, user); + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/user/list.ts b/packages/b2c-cli/src/commands/user/list.ts new file mode 100644 index 00000000..00d71652 --- /dev/null +++ b/packages/b2c-cli/src/commands/user/list.ts @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags, Errors} from '@oclif/core'; +import {UserCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {listUsers} from '@salesforce/b2c-tooling-sdk/operations/users'; +import type {AccountManagerUser, UserCollection} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +const COLUMNS: Record> = { + mail: { + header: 'Email', + get: (u) => u.mail || '-', + }, + firstName: { + header: 'First Name', + get: (u) => u.firstName || '-', + }, + lastName: { + header: 'Last Name', + get: (u) => u.lastName || '-', + }, + userState: { + header: 'State', + get: (u) => u.userState || '-', + }, + passwordExpired: { + header: 'Password Expired', + get: (u) => (u.passwordExpirationTimestamp ? 'Yes' : 'No'), + }, + twoFAEnabled: { + header: '2FA Enabled', + get: (u) => (u.verifiers && u.verifiers.length > 0 ? 'Yes' : 'No'), + }, + linkedToSfIdentity: { + header: 'Linked to SF', + get: (u) => (u.linkedToSfIdentity ? 'Yes' : 'No'), + }, + lastLoginDate: { + header: 'Last Login', + get: (u) => u.lastLoginDate || '-', + }, + roles: { + header: 'Roles', + get(u) { + if (!u.roles || u.roles.length === 0) return '-'; + return u.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')).join(', '); + }, + extended: true, + }, + organizations: { + header: 'Organizations', + get(u) { + if (!u.organizations || u.organizations.length === 0) return '-'; + return u.organizations.map((o) => (typeof o === 'string' ? o : o.id || '')).join(', '); + }, + extended: true, + }, +}; + +const DEFAULT_COLUMNS = [ + 'mail', + 'firstName', + 'lastName', + 'userState', + 'passwordExpired', + 'twoFAEnabled', + 'linkedToSfIdentity', + 'lastLoginDate', +]; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to list Account Manager users. + */ +export default class UserList extends UserCommand { + static description = t('commands.user.list.description', 'List Account Manager users'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --size 50', + '<%= config.bin %> <%= command.id %> --size 100', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + size: Flags.integer({ + char: 's', + description: 'Page size (default: 20, min: 1, max: 4000)', + }), + page: Flags.integer({ + description: 'Page number (zero-based index, default: 0, min: 0)', + }), + columns: Flags.string({ + char: 'c', + description: `Columns to display (comma-separated). Available: ${Object.keys(COLUMNS).join(', ')}`, + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show all columns including extended fields', + default: false, + }), + }; + + async run(): Promise { + const {size, page} = this.flags; + + // Ensure size and page are numbers (oclif might return undefined or string) + const pageSize = size === undefined ? 20 : Number(size); + const pageNumber = page === undefined ? 0 : Number(page); + + // Validate size parameter + if (pageSize < 1 || pageSize > 4000) { + throw new Errors.CLIError( + t('commands.user.list.invalidSize', 'Page size must be between 1 and 4000 (inclusive). Received: {{size}}', { + size: pageSize, + }), + ); + } + + // Validate page parameter (must be zero-based index, non-negative) + if (pageNumber < 0 || !Number.isFinite(pageNumber)) { + throw new Errors.CLIError( + t( + 'commands.user.list.invalidPage', + 'Page number must be a non-negative integer (zero-based index). Received: {{page}}', + {page: pageNumber}, + ), + ); + } + + this.log(t('commands.user.list.fetching', 'Fetching users...')); + + const result = await listUsers(this.accountManagerClient, { + size: pageSize, + page: pageNumber, + }); + + if (this.jsonEnabled()) { + return result; + } + + const users = result.content || []; + if (users.length === 0) { + this.log(t('commands.user.list.noUsers', 'No users found.')); + return result; + } + + tableRenderer.render(users, this.getSelectedColumns()); + + // Check if there are more pages (if we got a full page of results, there might be more) + if (users.length === pageSize) { + const nextPage = pageNumber + 1; + this.log( + t('commands.user.list.moreUsers', 'More users available. Use --page {{nextPage}} to view the next page.', { + nextPage, + }), + ); + } + + return result; + } + + /** + * Determines which columns to display based on flags. + */ + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + const requested = columnsFlag.split(',').map((c) => c.trim()); + const valid = tableRenderer.validateColumnKeys(requested); + if (valid.length === 0) { + this.warn(`No valid columns specified. Available: ${tableRenderer.getColumnKeys().join(', ')}`); + return DEFAULT_COLUMNS; + } + return valid; + } + + if (extended) { + return tableRenderer.getColumnKeys(); + } + + return DEFAULT_COLUMNS; + } +} diff --git a/packages/b2c-cli/src/commands/user/reset.ts b/packages/b2c-cli/src/commands/user/reset.ts new file mode 100644 index 00000000..eadb2c73 --- /dev/null +++ b/packages/b2c-cli/src/commands/user/reset.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args} from '@oclif/core'; +import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getUserByLogin, resetUser} from '@salesforce/b2c-tooling-sdk/operations/users'; +import {t} from '../../i18n/index.js'; + +/** + * Command to reset an Account Manager user password. + */ +export default class UserReset extends UserCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.user.reset.description', 'Reset an Account Manager user password'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com', + '<%= config.bin %> <%= command.id %> user@example.com --json', + ]; + + async run(): Promise { + const {login} = this.args; + + this.log(t('commands.user.reset.fetching', 'Fetching user {{login}}...', {login})); + + const user = await getUserByLogin(this.accountManagerClient, login); + + if (!user.id) { + this.error(t('commands.user.reset.noId', 'User does not have an ID')); + } + + this.log(t('commands.user.reset.resetting', 'Resetting password for user {{login}}...', {login})); + + await resetUser(this.accountManagerClient, user.id); + + if (this.jsonEnabled()) { + return; + } + + this.log(t('commands.user.reset.success', 'Password reset for user {{login}} completed successfully.', {login})); + } +} diff --git a/packages/b2c-cli/src/commands/user/update.ts b/packages/b2c-cli/src/commands/user/update.ts new file mode 100644 index 00000000..30237c9c --- /dev/null +++ b/packages/b2c-cli/src/commands/user/update.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getUserByLogin, updateUser} from '@salesforce/b2c-tooling-sdk/operations/users'; +import type {AccountManagerUser, UserUpdate as UserUpdateType} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +/** + * Command to update an Account Manager user. + */ +export default class UserUpdate extends UserCommand { + static args = { + login: Args.string({ + description: 'User login (email)', + required: true, + }), + }; + + static description = t('commands.user.update.description', 'Update an Account Manager user'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> user@example.com --first-name Jane', + '<%= config.bin %> <%= command.id %> user@example.com --last-name Smith --json', + ]; + + static flags = { + 'first-name': Flags.string({ + description: 'First name', + }), + 'last-name': Flags.string({ + description: 'Last name', + }), + }; + + async run(): Promise { + const {login} = this.args; + const {'first-name': firstName, 'last-name': lastName} = this.flags; + + this.log(t('commands.user.update.fetching', 'Fetching user {{login}}...', {login})); + + const user = await getUserByLogin(this.accountManagerClient, login); + + if (!user.id) { + this.error(t('commands.user.update.noId', 'User does not have an ID')); + } + + const changes: Partial = {}; + if (firstName !== undefined) { + changes.firstName = firstName; + } + if (lastName !== undefined) { + changes.lastName = lastName; + } + + if (Object.keys(changes).length === 0) { + this.error(t('commands.user.update.noChanges', 'No changes specified. Provide at least one field to update.')); + } + + this.log(t('commands.user.update.updating', 'Updating user {{login}}...', {login})); + + const updatedUser = await updateUser(this.accountManagerClient, { + userId: user.id, + changes: changes as UserUpdateType, + }); + + if (this.jsonEnabled()) { + return updatedUser; + } + + this.log(t('commands.user.update.success', 'User {{login}} updated successfully.', {login})); + + return updatedUser; + } +} diff --git a/packages/b2c-cli/test/commands/role/get.test.ts b/packages/b2c-cli/test/commands/role/get.test.ts new file mode 100644 index 00000000..e36e2aff --- /dev/null +++ b/packages/b2c-cli/test/commands/role/get.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import RoleGet from '../../../src/commands/role/get.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/role.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for role get command CLI logic. + * Tests output formatting. + * SDK tests cover the actual API calls. + */ +describe('role get', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require roleId as argument', () => { + expect(RoleGet.args).to.have.property('roleId'); + expect(RoleGet.args.roleId.required).to.be.true; + }); + + it('should have correct description', () => { + expect(RoleGet.description).to.be.a('string'); + expect(RoleGet.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(RoleGet.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return role data in JSON mode', async () => { + const command = new RoleGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {roleId: 'bm-admin'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockRole = { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1', 'permission2'], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles/bm-admin`, () => { + return HttpResponse.json(mockRole); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockRole); + expect(result.id).to.equal('bm-admin'); + }); + + it('should return role data in non-JSON mode', async () => { + const command = new RoleGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {roleId: 'bm-admin'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockRole = { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1'], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles/bm-admin`, () => { + return HttpResponse.json(mockRole); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockRole); + }); + + it('should handle API errors', async () => { + const command = new RoleGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {roleId: 'nonexistent'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles/nonexistent`, () => { + return HttpResponse.json({error: {message: 'Role not found'}}, {status: 404}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + const errorMessage = (error as Error).message; + // The error should either be the custom 404 message or the API error message + expect(errorMessage).to.include('not found'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/role/grant.test.ts b/packages/b2c-cli/test/commands/role/grant.test.ts new file mode 100644 index 00000000..8727eab3 --- /dev/null +++ b/packages/b2c-cli/test/commands/role/grant.test.ts @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import RoleGrant from '../../../src/commands/role/grant.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/role.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for role grant command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('role grant', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(RoleGrant.args).to.have.property('login'); + expect(RoleGrant.args.login.required).to.be.true; + }); + + it('should require role flag', () => { + expect(RoleGrant.flags).to.have.property('role'); + expect(RoleGrant.flags.role.required).to.be.true; + }); + + it('should have correct description', () => { + expect(RoleGrant.description).to.be.a('string'); + expect(RoleGrant.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(RoleGrant.enableJsonFlag).to.be.true; + }); + }); + + describe('validation', () => { + it('should error when user has no ID', async () => { + const command = new RoleGrant([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin'}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + mail: 'user@example.com', + id: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/User does not have an ID/); + } + }); + }); + + describe('output formatting', () => { + it('should grant role without scope', async () => { + const command = new RoleGrant([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + roles: [], + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + }; + + let getUserCallCount = 0; + let updateUserCallCount = 0; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, () => { + getUserCallCount++; + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + updateUserCallCount++; + const body = (await request.json()) as {roles?: string[]}; + expect(body.roles).to.include('bm-admin'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + expect(getUserCallCount).to.equal(1); + expect(updateUserCallCount).to.equal(1); + }); + + it('should grant role with scope', async () => { + const command = new RoleGrant([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin', scope: 'tenant1,tenant2'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + roles: [], + roleTenantFilter: '', + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('bm-admin:tenant1,tenant2'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/role/list.test.ts b/packages/b2c-cli/test/commands/role/list.test.ts new file mode 100644 index 00000000..5df375d2 --- /dev/null +++ b/packages/b2c-cli/test/commands/role/list.test.ts @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import RoleList from '../../../src/commands/role/list.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/role.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for role list command CLI logic. + * Tests column selection, pagination validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('role list', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(RoleList.description).to.be.a('string'); + expect(RoleList.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(RoleList.enableJsonFlag).to.be.true; + }); + }); + + describe('getSelectedColumns', () => { + it('should return default columns when no flags provided', () => { + const command = new RoleList([], {} as any); + (command as any).flags = {}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal(['id', 'description', 'roleEnumName']); + }); + + it('should return all columns when --extended flag is set', () => { + const command = new RoleList([], {} as any); + (command as any).flags = {extended: true}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.include('id'); + expect(columns).to.include('permissions'); + }); + + it('should return custom columns when --columns flag is set', () => { + const command = new RoleList([], {} as any); + (command as any).flags = {columns: 'id,description,scope'}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal(['id', 'description', 'scope']); + }); + }); + + describe('pagination validation', () => { + it('should validate size parameter - minimum', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {size: 0}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page size must be between 1 and 4000/); + } + }); + + it('should validate size parameter - maximum', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {size: 5000}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page size must be between 1 and 4000/); + } + }); + + it('should validate page parameter - negative', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {page: -1}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page number must be a non-negative integer/); + } + }); + }); + + describe('output formatting', () => { + it('should return role collection in JSON mode', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1', 'permission2'], + }, + { + id: 'bm-user', + description: 'Business Manager User', + roleEnumName: 'ECOM_USER', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: true, + permissions: [], + }, + ], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].id).to.equal('bm-admin'); + }); + + it('should handle empty results', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles`, () => { + return HttpResponse.json({content: []}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.deep.equal([]); + }); + + it('should pass target type filter', async () => { + const command = new RoleList([], {} as any); + (command as any).flags = {'target-type': 'User'}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('roleTargetType')).to.equal('User'); + return HttpResponse.json({content: []}); + }), + ); + + await command.run(); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/role/revoke.test.ts b/packages/b2c-cli/test/commands/role/revoke.test.ts new file mode 100644 index 00000000..5ec75b19 --- /dev/null +++ b/packages/b2c-cli/test/commands/role/revoke.test.ts @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import RoleRevoke from '../../../src/commands/role/revoke.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/role.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for role revoke command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('role revoke', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(RoleRevoke.args).to.have.property('login'); + expect(RoleRevoke.args.login.required).to.be.true; + }); + + it('should require role flag', () => { + expect(RoleRevoke.flags).to.have.property('role'); + expect(RoleRevoke.flags.role.required).to.be.true; + }); + + it('should have correct description', () => { + expect(RoleRevoke.description).to.be.a('string'); + expect(RoleRevoke.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(RoleRevoke.enableJsonFlag).to.be.true; + }); + }); + + describe('validation', () => { + it('should error when user has no ID', async () => { + const command = new RoleRevoke([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin'}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + mail: 'user@example.com', + id: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/User does not have an ID/); + } + }); + }); + + describe('output formatting', () => { + it('should revoke role without scope', async () => { + const command = new RoleRevoke([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + roles: [], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {roles?: string[]}; + // When revoking a role without scope, roles might be undefined if empty + if (body.roles) { + expect(body.roles).to.not.include('bm-admin'); + } + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + }); + + it('should revoke role with scope', async () => { + const command = new RoleRevoke([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {role: 'bm-admin', scope: 'tenant1'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant2', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('tenant2'); + expect(body.roleTenantFilter).to.not.include('tenant1'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/user/create.test.ts b/packages/b2c-cli/test/commands/user/create.test.ts new file mode 100644 index 00000000..b8fcb8ca --- /dev/null +++ b/packages/b2c-cli/test/commands/user/create.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import UserCreate from '../../../src/commands/user/create.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for user create command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('user create', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should have required flags', () => { + expect(UserCreate.flags).to.have.property('org'); + expect(UserCreate.flags).to.have.property('mail'); + expect(UserCreate.flags).to.have.property('first-name'); + expect(UserCreate.flags).to.have.property('last-name'); + expect(UserCreate.flags.org.required).to.be.true; + expect(UserCreate.flags.mail.required).to.be.true; + expect(UserCreate.flags['first-name'].required).to.be.true; + expect(UserCreate.flags['last-name'].required).to.be.true; + }); + + it('should have correct description', () => { + expect(UserCreate.description).to.be.a('string'); + expect(UserCreate.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserCreate.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return created user in JSON mode', async () => { + const command = new UserCreate([], {} as any); + (command as any).flags = { + org: 'org-123', + mail: 'newuser@example.com', + 'first-name': 'John', + 'last-name': 'Doe', + }; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + organizations: ['org-123'], + primaryOrganization: 'org-123', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.post(`${BASE_URL}/users`, async ({request}) => { + const body = (await request.json()) as {mail?: string; firstName?: string; lastName?: string}; + expect(body.mail).to.equal('newuser@example.com'); + return HttpResponse.json(mockUser, {status: 201}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + expect(result.mail).to.equal('newuser@example.com'); + }); + + it('should return created user in non-JSON mode', async () => { + const command = new UserCreate([], {} as any); + (command as any).flags = { + org: 'org-123', + mail: 'newuser@example.com', + 'first-name': 'John', + 'last-name': 'Doe', + }; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.post(`${BASE_URL}/users`, () => { + return HttpResponse.json(mockUser, {status: 201}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + }); + + it('should handle API errors', async () => { + const command = new UserCreate([], {} as any); + (command as any).flags = { + org: 'org-123', + mail: 'newuser@example.com', + 'first-name': 'John', + 'last-name': 'Doe', + }; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.post(`${BASE_URL}/users`, () => { + return HttpResponse.json({error: {message: 'User already exists'}}, {status: 400}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('User already exists'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/user/delete.test.ts b/packages/b2c-cli/test/commands/user/delete.test.ts new file mode 100644 index 00000000..639c33ee --- /dev/null +++ b/packages/b2c-cli/test/commands/user/delete.test.ts @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import UserDelete from '../../../src/commands/user/delete.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for user delete command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('user delete', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(UserDelete.args).to.have.property('login'); + expect(UserDelete.args.login.required).to.be.true; + }); + + it('should have correct description', () => { + expect(UserDelete.description).to.be.a('string'); + expect(UserDelete.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserDelete.enableJsonFlag).to.be.true; + }); + }); + + describe('validation', () => { + it('should error when user has no ID', async () => { + const command = new UserDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {purge: false}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + mail: 'user@example.com', + id: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/User does not have an ID/); + } + }); + }); + + describe('output formatting', () => { + it('should delete user (soft delete)', async () => { + const command = new UserDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {purge: false}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + }; + + let deleteCalled = false; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.post(`${BASE_URL}/users/user-123/disable`, () => { + deleteCalled = true; + return HttpResponse.json({}); + }), + ); + + await command.run(); + + expect(deleteCalled).to.be.true; + }); + + it('should purge user (hard delete)', async () => { + const command = new UserDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {purge: true}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + }; + + let purgeCalled = false; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.delete(`${BASE_URL}/users/user-123`, () => { + purgeCalled = true; + return HttpResponse.json({}); + }), + ); + + await command.run(); + + expect(purgeCalled).to.be.true; + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/user/get.test.ts b/packages/b2c-cli/test/commands/user/get.test.ts new file mode 100644 index 00000000..48ededdf --- /dev/null +++ b/packages/b2c-cli/test/commands/user/get.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import UserGet from '../../../src/commands/user/get.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for user get command CLI logic. + * Tests output formatting. + * SDK tests cover the actual API calls. + */ +describe('user get', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(UserGet.args).to.have.property('login'); + expect(UserGet.args.login.required).to.be.true; + }); + + it('should have correct description', () => { + expect(UserGet.description).to.be.a('string'); + expect(UserGet.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserGet.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return user data in JSON mode', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + userState: 'ACTIVE', + primaryOrganization: 'org-123', + passwordExpirationTimestamp: null, + verifiers: [], + linkedToSfIdentity: false, + lastLoginDate: '2025-01-01', + createdAt: 1_234_567_890, + lastModified: 1_234_567_890, + roles: ['bm-admin'], + organizations: ['org-123'], + roleTenantFilterMap: { + 'bm-admin': 'tenant1,tenant2', + }, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + expect(result.mail).to.equal('user@example.com'); + }); + + it('should return user data in non-JSON mode', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + }); + + it('should handle API errors', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'nonexistent@example.com'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages - return empty result + return HttpResponse.json({content: []}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('not found'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/user/list.test.ts b/packages/b2c-cli/test/commands/user/list.test.ts new file mode 100644 index 00000000..ee1f6a75 --- /dev/null +++ b/packages/b2c-cli/test/commands/user/list.test.ts @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import UserList from '../../../src/commands/user/list.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for user list command CLI logic. + * Tests column selection, pagination validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('user list', () => { + const server = setupServer(); + + const mockUsers = [ + { + id: 'user-1', + mail: 'user1@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + passwordExpirationTimestamp: null, + verifiers: [], + linkedToSfIdentity: false, + lastLoginDate: '2025-01-01', + }, + { + id: 'user-2', + mail: 'user2@example.com', + firstName: 'Jane', + lastName: 'Smith', + userState: 'INITIAL', + passwordExpirationTimestamp: Date.now() - 1000, + verifiers: [{id: 'verifier-1'}], + linkedToSfIdentity: true, + lastLoginDate: null, + }, + ]; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(UserList.description).to.be.a('string'); + expect(UserList.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserList.enableJsonFlag).to.be.true; + }); + }); + + describe('getSelectedColumns', () => { + it('should return default columns when no flags provided', () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal([ + 'mail', + 'firstName', + 'lastName', + 'userState', + 'passwordExpired', + 'twoFAEnabled', + 'linkedToSfIdentity', + 'lastLoginDate', + ]); + }); + + it('should return all columns when --extended flag is set', () => { + const command = new UserList([], {} as any); + (command as any).flags = {extended: true}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.include('mail'); + expect(columns).to.include('roles'); + expect(columns).to.include('organizations'); + }); + + it('should return custom columns when --columns flag is set', () => { + const command = new UserList([], {} as any); + (command as any).flags = {columns: 'mail,firstName,userState'}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal(['mail', 'firstName', 'userState']); + }); + }); + + describe('pagination validation', () => { + it('should validate size parameter - minimum', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {size: 0}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page size must be between 1 and 4000/); + } + }); + + it('should validate size parameter - maximum', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {size: 5000}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page size must be between 1 and 4000/); + } + }); + + it('should validate page parameter - negative', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {page: -1}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page number must be a non-negative integer/); + } + }); + }); + + describe('output formatting', () => { + it('should return user collection in JSON mode', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: mockUsers}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].mail).to.equal('user1@example.com'); + }); + + it('should handle empty results', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: []}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.deep.equal([]); + }); + + it('should return data in non-JSON mode', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: mockUsers}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should use default pagination when not specified', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + let capturedPage: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + capturedPage = url.searchParams.get('page'); + return HttpResponse.json({content: []}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('20'); + expect(capturedPage).to.equal('0'); + }); + + it('should pass custom pagination parameters', async () => { + const command = new UserList([], {} as any); + (command as any).flags = {size: 50, page: 2}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + let capturedPage: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + capturedPage = url.searchParams.get('page'); + return HttpResponse.json({content: []}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('50'); + expect(capturedPage).to.equal('2'); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/user/update.test.ts b/packages/b2c-cli/test/commands/user/update.test.ts new file mode 100644 index 00000000..47f132c8 --- /dev/null +++ b/packages/b2c-cli/test/commands/user/update.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import UserUpdate from '../../../src/commands/user/update.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for user update command CLI logic. + * Tests flag validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('user update', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(UserUpdate.args).to.have.property('login'); + expect(UserUpdate.args.login.required).to.be.true; + }); + + it('should have correct description', () => { + expect(UserUpdate.description).to.be.a('string'); + expect(UserUpdate.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserUpdate.enableJsonFlag).to.be.true; + }); + }); + + describe('validation', () => { + it('should error when no changes specified', async () => { + const command = new UserUpdate([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/No changes specified/); + } + }); + + it('should error when user has no ID', async () => { + const command = new UserUpdate([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {'first-name': 'Jane'}; + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + const mockUser = { + mail: 'user@example.com', + id: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/User does not have an ID/); + } + }); + }); + + describe('output formatting', () => { + it('should return updated user in JSON mode', async () => { + const command = new UserUpdate([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {'first-name': 'Jane'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'Jane', + lastName: 'Doe', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {firstName?: string}; + expect(body.firstName).to.equal('Jane'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + expect(result.firstName).to.equal('Jane'); + }); + + it('should return updated user in non-JSON mode', async () => { + const command = new UserUpdate([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + (command as any).flags = {'last-name': 'Smith'}; + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + }; + + const updatedUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Smith', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.put(`${BASE_URL}/users/user-123`, async ({request}) => { + const body = (await request.json()) as {lastName?: string}; + expect(body.lastName).to.equal('Smith'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(updatedUser); + }); + }); +}); diff --git a/packages/b2c-cli/test/helpers/role.ts b/packages/b2c-cli/test/helpers/role.ts new file mode 100644 index 00000000..92d3ced4 --- /dev/null +++ b/packages/b2c-cli/test/helpers/role.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export function stubCommandConfigAndLogger(command: any, accountManagerHost = 'account.test.demandware.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); + + Object.defineProperty(command, 'resolvedConfig', { + value: { + values: { + accountManagerHost, + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + hasOAuthConfig() { + return Boolean(this.values.clientId); + }, + }, + configurable: true, + }); +} + +export function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +export function stubAccountManagerRolesClient(command: any, client: any): void { + Object.defineProperty(command, 'accountManagerRolesClient', { + get: () => client, + configurable: true, + }); +} + +export function stubAccountManagerClient(command: any, client: any): void { + Object.defineProperty(command, 'accountManagerClient', { + get: () => client, + configurable: true, + }); +} + +export function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} diff --git a/packages/b2c-cli/test/helpers/user.ts b/packages/b2c-cli/test/helpers/user.ts new file mode 100644 index 00000000..a427cc10 --- /dev/null +++ b/packages/b2c-cli/test/helpers/user.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export function stubCommandConfigAndLogger(command: any, accountManagerHost = 'account.test.demandware.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); + + Object.defineProperty(command, 'resolvedConfig', { + value: { + values: { + accountManagerHost, + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + hasOAuthConfig() { + return Boolean(this.values.clientId); + }, + }, + configurable: true, + }); +} + +export function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +export function stubAccountManagerClient(command: any, client: any): void { + Object.defineProperty(command, 'accountManagerClient', { + get: () => client, + configurable: true, + }); +} + +export function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} diff --git a/packages/b2c-tooling-sdk/README.md b/packages/b2c-tooling-sdk/README.md index 53509ae9..5aacf633 100644 --- a/packages/b2c-tooling-sdk/README.md +++ b/packages/b2c-tooling-sdk/README.md @@ -125,6 +125,91 @@ const result = await waitForJob(instance, 'my-job-id', execution.id); await siteArchiveImport(instance, './site-data.zip'); ``` +### Account Manager User Management + +```typescript +import { + getUserByLogin, + listUsers, + createUser, + updateUser, + deleteUser, + resetUser, + grantRole, + revokeRole, +} from '@salesforce/b2c-tooling-sdk/operations/users'; +import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerClient({}, auth); + +// List users with pagination +const users = await listUsers(client, {size: 25, page: 0}); + +// Get user by email +const user = await getUserByLogin(client, 'user@example.com'); + +// Create a new user +const newUser = await createUser(client, { + user: { + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + organizations: ['org-id'], + primaryOrganization: 'org-id', + }, +}); + +// Update a user +await updateUser(client, { + userId: user.id!, + changes: {firstName: 'Jane'}, +}); + +// Grant a role to a user +await grantRole(client, { + userId: user.id!, + role: 'bm-admin', + scope: 'tenant1,tenant2', // Optional tenant filter +}); + +// Reset user to INITIAL state +await resetUser(client, user.id!); +``` + +### Account Manager Role Management + +```typescript +import {getRole, listRoles} from '@salesforce/b2c-tooling-sdk/operations/roles'; +import {createAccountManagerRolesClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerRolesClient({}, auth); + +// Get role details by ID +const role = await getRole(client, 'bm-admin'); + +// List all roles with pagination +const roles = await listRoles(client, {size: 25, page: 0}); + +// List roles filtered by target type +const userRoles = await listRoles(client, { + size: 25, + page: 0, + roleTargetType: 'User', +}); +``` + ## Module Exports The SDK provides subpath exports for tree-shaking and organization: @@ -139,6 +224,8 @@ The SDK provides subpath exports for tree-shaking and organization: | `@salesforce/b2c-tooling-sdk/operations/code` | Code deployment operations | | `@salesforce/b2c-tooling-sdk/operations/jobs` | Job execution and site import/export | | `@salesforce/b2c-tooling-sdk/operations/sites` | Site management | +| `@salesforce/b2c-tooling-sdk/operations/users` | Account Manager user management | +| `@salesforce/b2c-tooling-sdk/operations/roles` | Account Manager role management | | `@salesforce/b2c-tooling-sdk/discovery` | Workspace type detection (PWA Kit, SFRA, etc.) | | `@salesforce/b2c-tooling-sdk/cli` | CLI utilities (BaseCommand, table rendering) | | `@salesforce/b2c-tooling-sdk/logging` | Structured logging utilities | diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 8f4169d1..5c6e33cb 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -123,6 +123,28 @@ "default": "./dist/cjs/operations/logs/index.js" } }, + "./operations/users": { + "development": "./src/operations/users/index.ts", + "import": { + "types": "./dist/esm/operations/users/index.d.ts", + "default": "./dist/esm/operations/users/index.js" + }, + "require": { + "types": "./dist/cjs/operations/users/index.d.ts", + "default": "./dist/cjs/operations/users/index.js" + } + }, + "./operations/roles": { + "development": "./src/operations/roles/index.ts", + "import": { + "types": "./dist/esm/operations/roles/index.d.ts", + "default": "./dist/esm/operations/roles/index.js" + }, + "require": { + "types": "./dist/cjs/operations/roles/index.d.ts", + "default": "./dist/cjs/operations/roles/index.js" + } + }, "./cli": { "development": "./src/cli/index.ts", "import": { @@ -221,7 +243,7 @@ "data" ], "scripts": { - "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts", + "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts", "build": "pnpm run generate:types && pnpm run build:esm && pnpm run build:cjs", "build:esm": "tsc -p tsconfig.esm.json", "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", diff --git a/packages/b2c-tooling-sdk/specs/am-roles-api-v1.yaml b/packages/b2c-tooling-sdk/specs/am-roles-api-v1.yaml new file mode 100644 index 00000000..73dafa49 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/am-roles-api-v1.yaml @@ -0,0 +1,316 @@ +openapi: 3.0.1 +info: + title: Roles + description: | + # API Overview + + Retrieve information about roles within the Account Manager system. Roles define permissions and access levels that can be assigned to Users and API Clients. Roles can be global (applying across all instances) or instance-specific (applying to particular instances). + + ## Authentication & Authorization + + All requests to the Roles API must be authenticated using OAuth 2.0 bearer token authentication. The API supports two OAuth 2.0 flows: client credentials and authorization code. The token endpoint is available at `https://account.demandware.com/dwsso/oauth2/access_token`. + + ## Use Cases + + ### List Available Roles + + Retrieve a paginated list of all roles available in the system. Use the roleTargetType parameter to filter roles by whether they can be assigned to Users or API Clients. + + ### Get Role Details + + Retrieve detailed information about a specific role, including its permissions, scope, and target type. + version: 1.0.0-beta +servers: + - url: https://account.demandware.com + description: Account Manager Production Instance +security: + - AmOAuth2: [] +paths: + /dw/rest/v1/roles: + get: + operationId: getRoles + summary: List all roles. + description: Retrieve a paginated list of all roles. Use the roleTargetType parameter to filter by target type. + parameters: + - name: pageable + in: query + schema: + $ref: "#/components/schemas/Pageable" + - name: roleTargetType + in: query + schema: + type: string + enum: + - ApiClient + - User + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/RoleCollection" + example: + content: + - description: Business Manager User + roleEnumName: ECOM_USER + permissions: [] + scope: INSTANCE + targetType: User + id: bm-user + - description: Business Manager Administrator + roleEnumName: ECOM_ADMIN + permissions: [] + scope: INSTANCE + targetType: User + id: bm-admin + /dw/rest/v1/roles/{roleId}: + get: + operationId: getRole + summary: Get role by ID. + description: Retrieve a specific role by ID. + parameters: + - name: roleId + in: path + required: true + schema: + type: string + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: A role with this ID was not found. + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/Role" + example: + description: Business Manager User + roleEnumName: ECOM_USER + permissions: [] + scope: INSTANCE + targetType: User + id: bm-user +components: + examples: + ErrorAuthenticationRequired: + summary: 401 Unauthorized + description: Response for `401 Unauthorized` status + value: > + { + "message": "Full authentication is required to access this resource", + "code": "InsufficientAuthenticationException", + "fieldErrors": null + } + responses: + UnauthorizedError: + description: Access token is missing or invalid + content: + application/json: + schema: + type: object + properties: + errors: + type: array + description: The list of errors + items: + type: object + properties: + message: + type: string + description: Error message + code: + type: string + description: Error code + fieldErrors: + type: array + nullable: true + description: Field-specific errors. + items: + type: object + properties: + field: + type: string + description: The field that contained the erroneous value. + rejectedValue: + type: object + description: The value that was rejected. + bindingFailure: + type: boolean + description: Whether this error was caused by failed binding (e.g. type mismatch). + example: + errors: + - message: "Full authentication is required to access this resource" + code: "InsufficientAuthenticationException" + fieldErrors: null + RateLimitedError: + description: Request has been rate-limited. The X-RateLimit-* headers can be used to improve retry behavior. + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + securitySchemes: + AmOAuth2: + type: oauth2 + description: Account Manager OAuth 2.0 bearer token authentication. + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: {} + authorizationCode: + authorizationUrl: https://account.demandware.com/dwsso/oauth2/authorize + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: {} + headers: + X-RateLimit-Limit: + schema: + type: integer + description: Rate limit per minute. + X-RateLimit-Remaining: + schema: + type: integer + description: The number of requests left in the current time window. + X-RateLimit-Reset: + schema: + type: string + format: date-time + description: The UTC timestamp at which the current rate limit window resets. + schemas: + Role: + type: object + description: A role defines permissions and access levels that can be assigned to Users and API Clients. + properties: + description: + type: string + description: Description of the role. + roleEnumName: + maxLength: 50 + minLength: 0 + type: string + description: Enumeration name of the role. + permissions: + uniqueItems: true + type: array + description: List of permissions granted by this role. + items: + type: string + scope: + type: string + description: Scope level of the role (global or instance-specific). + enum: + - GLOBAL + - INSTANCE + targetType: + type: string + nullable: true + description: Type of entity the role can be assigned to. + enum: + - ApiClient + - User + twoFAEnabled: + type: boolean + description: Indicates if two-factor authentication is required for the role. + id: + type: string + description: Unique identifier of the role. + RoleCollection: + type: object + description: A paginated collection of roles. + properties: + content: + type: array + items: + $ref: "#/components/schemas/Role" + Pageable: + type: object + description: Pagination parameters for list operations. + properties: + page: + minimum: 0 + type: integer + format: int32 + description: Zero-based page index. + size: + minimum: 1 + default: 20 + maximum: 4000 + type: integer + format: int32 + description: Number of items to return per page. + ErrorResponse: + type: object + description: Standard error response format returned when API requests fail. + properties: + errors: + type: array + description: The list of errors + items: + type: object + properties: + message: + type: string + description: Error message + code: + type: string + description: Error code + fieldErrors: + type: array + nullable: true + description: Field-specific errors + items: + type: object + properties: + field: + type: string + description: The field that contained the erroneous value + rejectedValue: + type: object + description: The value that was rejected + bindingFailure: + type: boolean + description: Whether this error was caused by failed binding (e.g. type mismatch) diff --git a/packages/b2c-tooling-sdk/specs/am-users-api-v1.yaml b/packages/b2c-tooling-sdk/specs/am-users-api-v1.yaml new file mode 100644 index 00000000..215bea92 --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/am-users-api-v1.yaml @@ -0,0 +1,1102 @@ +openapi: 3.0.1 +info: + title: Users + description: | + # API Overview + + Manage user accounts within the Account Manager system. Users represent individual accounts with access to the platform. They can be assigned roles and organizations, have configurable authentication preferences, and support various lifecycle states (INITIAL, ENABLED, DELETED). + + ## Authentication & Authorization + + All requests to the Users API must be authenticated using OAuth 2.0 bearer token authentication. The API supports two OAuth 2.0 flows: client credentials and authorization code. The token endpoint is available at `https://account.demandware.com/dwsso/oauth2/access_token`. + + ## Use Cases + + ### Create and Manage User Accounts + + Create new user accounts, update user information, and manage user lifecycle states. Users are created in INITIAL state and must be activated before they can log in. + + ### Reset User Accounts + + Reset users to INITIAL state and send activation instructions, useful for password resets or account recovery scenarios. + + ### Disable and Purge Users + + Disable user accounts (setting state to DELETED) and purge users that have been disabled. Users must be in DELETED state before they can be purged. + version: 1.0.0-beta +servers: + - url: https://account.demandware.com + description: Account Manager Production Instance +security: + - AmOAuth2: [] +paths: + /dw/rest/v1/users: + get: + operationId: getUsers + summary: List all users. + description: Retrieve a paginated list of all users. + parameters: + - name: pageable + in: query + required: false + schema: + $ref: "#/components/schemas/Pageable" + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/UserCollection" + example: + content: + - id: "a7f3c8e2-91d4-4b5a-8c6f-2e9d1a3b7f4c" + mail: "alice.johnson@example.com" + firstName: "Alice" + lastName: "Johnson" + displayName: "Alice Johnson" + mobilePhone: "+1-555-9876" + preferredLocale: "en_US" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + organizations: + - "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + primaryOrganization: "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + userState: "ENABLED" + createdAt: "2024-01-15T10:30:00Z" + lastModified: "2024-11-20T14:22:00Z" + lastLoginDate: "2024-12-03" + linkedToSfIdentity: false + - id: "c9b5e7f1-84a2-4d6c-9e3b-7f2a1c8d5e9a" + mail: "robert.williams@example.com" + firstName: "Robert" + lastName: "Williams" + displayName: "Robert Williams" + preferredLocale: "en_US" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:wxyz_stg" + organizations: + - "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + primaryOrganization: "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + userState: "INITIAL" + createdAt: "2024-12-04T09:15:00Z" + linkedToSfIdentity: false + + post: + operationId: createUser + summary: Create user. + description: | + Create a new user with the specified properties. + Note: Users are created in INITIAL state. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserCreate" + example: + mail: "john.doe@example.com" + firstName: "John" + lastName: "Doe" + primaryOrganization: "e39dbb7a-63bd-4972-980b-0f6fb3a24bd6" + organizations: + - "e39dbb7a-63bd-4972-980b-0f6fb3a24bd6" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + required: true + responses: + "400": + description: Bad Request + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorCreatingUserFailed: + $ref: "#/components/examples/ErrorUserValidation" + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "429": + $ref: "#/components/responses/RateLimitedError" + 201: + description: Created + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + Location: + description: URL to read created user + schema: + type: string + format: uri + content: + application/json: + schema: + $ref: "#/components/schemas/UserRead" + example: + id: "c9b5e7f1-84a2-4d6c-9e3b-7f2a1c8d5e9a" + mail: "robert.williams@example.com" + firstName: "Robert" + lastName: "Williams" + displayName: "Robert Williams" + primaryOrganization: "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + organizations: + - "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + userState: "INITIAL" + createdAt: "2024-12-04T09:15:00Z" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + /dw/rest/v1/users/{userId}: + get: + operationId: getUser + summary: Get user by ID. + description: Retrieve a specific user by ID. Use the expand parameter to retrieve more information on related organizations and roles. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + - name: expand + in: query + required: false + style: form + explode: false + example: organizations + description: Comma-separated list of fields that should be expanded in the response. Ensures that fully inlined organization and/or role objects get returned. + schema: + type: array + items: + type: string + enum: + - organizations + - roles + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: A user with this ID was not found. + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/UserRead" + example: + id: "a7f3c8e2-91d4-4b5a-8c6f-2e9d1a3b7f4c" + mail: "alice.johnson@example.com" + firstName: "Alice" + lastName: "Johnson" + displayName: "Alice Johnson" + mobilePhone: "+1-555-9876" + preferredLocale: "en_US" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + organizations: + - "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + primaryOrganization: "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + userState: "ENABLED" + createdAt: "2024-01-15T10:30:00Z" + lastModified: "2024-11-20T14:22:00Z" + lastLoginDate: "2024-12-03" + linkedToSfIdentity: false + put: + operationId: updateUser + summary: Update user. + description: Apply a partial update to an existing user, i.e. an omitted field stays at its previous value, a contained field replaces the previously saved value. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserUpdate" + example: + firstName: "Jane" + lastName: "Smith" + displayName: "Jane Smith" + mobilePhone: "+1-555-0123" + preferredLocale: "en_US" + roles: + - "bm-user" + roleTenantFilter: "ECOM_USER:abcd_prd" + required: true + responses: + "400": + description: Bad Request + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorUpdatingUserFailed: + $ref: "#/components/examples/ErrorUserValidation" + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "429": + $ref: "#/components/responses/RateLimitedError" + "200": + description: OK + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + content: + application/json: + schema: + $ref: "#/components/schemas/UserRead" + delete: + operationId: purgeUser + summary: Purge user. + description: | + Purge a user. Only users in state Deleted may be purged. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: A user with this ID was not found. + "412": + description: Precondition Failed - User must be in status DELETED to be purged. + "429": + $ref: "#/components/responses/RateLimitedError" + "204": + description: User has been purged. + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + /dw/rest/v1/users/{userId}/reset: + post: + operationId: resetUser + summary: Reset user to INITIAL status + description: Reset a user to INITIAL status and send activation instructions. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserResetResource" + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: User not found. + "429": + $ref: "#/components/responses/RateLimitedError" + "204": + description: User has been reset. + /dw/rest/v1/users/{userId}/disable: + post: + operationId: disableUser + summary: Disable user. + description: Disable a user, setting their userState to DELETED. This is a prerequisite for purging a user. + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserDeactivationResource" + responses: + "400": + description: Bad Request + "401": + description: Access token is missing or invalid. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ErrorResponse" + examples: + errorAuthenticationRequired: + $ref: "#/components/examples/ErrorAuthenticationRequired" + "404": + description: User not found. + "429": + $ref: "#/components/responses/RateLimitedError" + "204": + description: User has been disabled. +components: + examples: + ErrorUserValidation: + summary: 400 Bad request + description: Response for `400 Bad request` status + value: > + { + "errors": [ + { + "message": "invalid argument User", + "code": "invalid argument User", + "fieldErrors": [ + { + "codes": [ + "ValidEmail.user.mail", + "ValidEmail.mail", + "ValidEmail.java.lang.String", + "ValidEmail" + ], + "arguments": null, + "defaultMessage": "Email 'null' invalid, reason 'Email address must not be null.'.", + "objectName": "user", + "field": "mail", + "rejectedValue": null, + "bindingFailure": false, + "code": "ValidEmail" + }, + { + "codes": [ + "NotNull.user.primaryOrganization", + "NotNull.primaryOrganization", + "NotNull.java.lang.String", + "NotNull" + ], + "arguments": null, + "defaultMessage": "must not be null", + "objectName": "user", + "field": "primaryOrganization", + "rejectedValue": null, + "bindingFailure": false, + "code": "NotNull" + }, + { + "codes": [ + "NotNull.user.lastName", + "NotNull.lastName", + "NotNull.java.lang.String", + "NotNull" + ], + "arguments": null, + "defaultMessage": "must not be null", + "objectName": "user", + "field": "lastName", + "rejectedValue": null, + "bindingFailure": false, + "code": "NotNull" + }, + { + "codes": [ + "NotNull.user.mail", + "NotNull.mail", + "NotNull.java.lang.String", + "NotNull" + ], + "arguments": null, + "defaultMessage": "must not be null", + "objectName": "user", + "field": "mail", + "rejectedValue": null, + "bindingFailure": false, + "code": "NotNull" + } + ] + } + ] + } + ErrorAuthenticationRequired: + summary: 401 Unauthorized + description: Response for `401 Unauthorized` status + value: > + { + "message": "Full authentication is required to access this resource", + "code": "InsufficientAuthenticationException", + "fieldErrors": null + } + responses: + RateLimitedError: + description: Request has been rate-limited. The X-RateLimit-* headers can be used to improve retry behavior. + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/X-RateLimit-Limit" + X-RateLimit-Remaining: + $ref: "#/components/headers/X-RateLimit-Remaining" + X-RateLimit-Reset: + $ref: "#/components/headers/X-RateLimit-Reset" + securitySchemes: + AmOAuth2: + type: oauth2 + description: Account Manager OAuth 2.0 bearer token authentication. + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: {} + authorizationCode: + authorizationUrl: https://account.demandware.com/dwsso/oauth2/authorize + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: {} + headers: + X-RateLimit-Limit: + schema: + type: integer + description: Rate limit per minute. + X-RateLimit-Remaining: + schema: + type: integer + description: The number of requests left in the current time window. + X-RateLimit-Reset: + schema: + type: string + format: date-time + description: The UTC timestamp at which the current rate limit window resets. + schemas: + UserCreate: + type: object + description: Request body for creating a new user. + required: + - mail + - firstName + - lastName + - organizations + - primaryOrganization + properties: + mail: + type: string + description: Email address of the user. + firstName: + maxLength: 40 + minLength: 1 + type: string + description: First name of the user. + lastName: + maxLength: 40 + minLength: 1 + type: string + description: Last name of the user. + displayName: + maxLength: 100 + minLength: 1 + type: string + description: Display name of the user. + businessPhone: + type: string + nullable: true + description: Business phone number. + homePhone: + type: string + nullable: true + description: Home phone number. + mobilePhone: + type: string + nullable: true + description: Mobile phone number. + preferredLocale: + type: string + nullable: true + description: Preferred locale for the user. + enum: + - none + - de + - de_DE + - en + - en_CA + - en_US + - es + - fr + - fr_CA + - nl + roles: + uniqueItems: true + type: array + description: List of IDs of the roles this user possesses. + items: + type: string + title: Role ID + organizations: + uniqueItems: true + type: array + description: List of organization IDs this user belongs to. + items: + type: string + title: Organization ID + primaryOrganization: + type: string + description: Primary organization ID for the user. + roleTenantFilter: + $ref: "#/components/schemas/RoleTenantFilter" + supportTicketId: + type: string + x-internal: true + writeOnly: true + description: Only required for users of the Salesforce organization. + UserUpdate: + type: object + description: Request body for updating an existing user. + properties: + mail: + type: string + description: Email address of the user. + firstName: + maxLength: 40 + minLength: 1 + type: string + description: First name of the user. + lastName: + maxLength: 40 + minLength: 1 + type: string + description: Last name of the user. + displayName: + maxLength: 100 + minLength: 1 + type: string + description: Display name of the user. + businessPhone: + type: string + nullable: true + description: Business phone number. + homePhone: + type: string + nullable: true + description: Home phone number. + mobilePhone: + type: string + nullable: true + description: Mobile phone number. + preferredLocale: + type: string + nullable: true + description: Preferred locale for the user. + enum: + - none + - de + - de_DE + - en + - en_CA + - en_US + - es + - fr + - fr_CA + - nl + roles: + uniqueItems: true + type: array + description: List of IDs of the roles this user possesses. + items: + type: string + title: Role ID + organizations: + uniqueItems: true + type: array + description: List of organization IDs this user belongs to. + items: + type: string + title: Organization ID + primaryOrganization: + type: string + description: Primary organization ID for the user. + roleTenantFilter: + $ref: "#/components/schemas/RoleTenantFilter" + supportTicketId: + type: string + x-internal: true + writeOnly: true + description: Only required for users of the Salesforce organization. + UserRead: + type: object + description: User object returned in read operations. + properties: + mail: + type: string + description: Email address of the user. + firstName: + maxLength: 40 + minLength: 1 + type: string + description: First name of the user. + lastName: + maxLength: 40 + minLength: 1 + type: string + description: Last name of the user. + displayName: + maxLength: 100 + minLength: 1 + type: string + description: Display name of the user. + businessPhone: + type: string + nullable: true + description: Business phone number. + homePhone: + type: string + nullable: true + description: Home phone number. + mobilePhone: + type: string + nullable: true + description: Mobile phone number. + preferredLocale: + type: string + nullable: true + description: Preferred locale for the user. + enum: + - none + - de + - de_DE + - en + - en_CA + - en_US + - es + - fr + - fr_CA + - nl + roles: + uniqueItems: true + type: array + description: List of IDs of the roles this user possesses. + items: + oneOf: + - type: string + title: Role ID + - $ref: "#/components/schemas/Role" + organizations: + uniqueItems: true + type: array + description: List of organization IDs this user belongs to. + items: + oneOf: + - type: string + title: Organization ID + - $ref: "#/components/schemas/Organization" + primaryOrganization: + type: string + description: Primary organization ID for the user. + roleTenantFilter: + $ref: "#/components/schemas/RoleTenantFilter" + passwordExpirationTimestamp: + type: integer + format: int64 + readOnly: true + nullable: true + description: Timestamp when the password expires. + passwordModificationTimestamp: + type: integer + format: int64 + readOnly: true + nullable: true + description: Timestamp of the last password modification. + createdAt: + type: string + format: date-time + readOnly: true + nullable: true + description: Timestamp when the user was created. + lastModified: + type: string + format: date-time + readOnly: true + nullable: true + description: Timestamp of the last modification. + lastLoginDate: + type: string + format: date + readOnly: true + nullable: true + description: Date of the last successful login. + userState: + type: string + readOnly: true + description: Current state of the user account. + enum: + - INITIAL + - ENABLED + - DELETED + activationCodeCreationTimestamp: + type: integer + format: int64 + readOnly: true + nullable: true + description: Timestamp when the activation code was created. + sfUserId: + type: string + readOnly: true + nullable: true + description: Salesforce user identifier. + verifiers: + type: array + readOnly: true + description: List of authentication verifiers (e.g., MFA devices). + items: + $ref: "#/components/schemas/Verifier" + deleteTimestamp: + type: integer + format: int64 + readOnly: true + nullable: true + description: Timestamp when the user was marked for deletion. + roleTenantFilterMap: + type: object + readOnly: true + description: Map of role tenant filter assignments. + linkedToSfIdentity: + type: boolean + readOnly: true + description: Indicates if the user is linked to a Salesforce identity. + id: + type: string + format: uuid + readOnly: true + description: Unique identifier of the user. + supportTicketId: + type: string + x-internal: true + writeOnly: true + description: Only required for users of the Salesforce organization. + Verifier: + type: object + description: A verifier represents a two-factor authentication device or method associated with a user account. + required: + - displayName + - id + - status + - type + properties: + id: + type: string + description: Unique identifier of the verifier. + type: + type: string + description: Type of verifier (e.g., SMS, TOTP, WebAuthn). + displayName: + type: string + description: Human-readable name for the verifier. + status: + type: string + description: Current status of the verifier. + UserResetResource: + type: object + description: Request body for resetting a user to INITIAL state. + properties: + supportTicketId: + type: string + x-internal: true + description: Only required for users of the Salesforce organization. + UserDeactivationResource: + type: object + description: Request body for disabling a user. + properties: + supportTicketId: + type: string + x-internal: true + description: Only required for users of the Salesforce organization. + Pageable: + type: object + description: Pagination parameters for list operations. + properties: + page: + minimum: 0 + type: integer + format: int32 + description: Zero-based page index. + size: + minimum: 1 + default: 20 + maximum: 4000 + type: integer + format: int32 + description: Number of items to return per page. + UserCollection: + type: object + description: A paginated collection of users. + properties: + content: + type: array + items: + $ref: "#/components/schemas/UserRead" + Role: + type: object + description: A role defines permissions and access levels that can be assigned to Users and API Clients. + properties: + description: + type: string + description: Description of the role. + roleEnumName: + maxLength: 50 + minLength: 0 + type: string + description: Enumeration name of the role. + permissions: + uniqueItems: true + type: array + description: List of permissions granted by this role. + items: + type: string + scope: + type: string + description: Scope level of the role (global or instance-specific). + enum: + - GLOBAL + - INSTANCE + targetType: + type: string + nullable: true + description: Type of entity this role can be assigned to. + enum: + - ApiClient + - User + twoFAEnabled: + type: boolean + description: Indicates if two-factor authentication is required for this role. + id: + type: string + description: Unique identifier of the role. + Organization: + type: object + description: An organization represents a customer, partner, or internal entity within the Account Manager system. + properties: + name: + type: string + description: Name of the organization. + contactUsers: + type: array + description: List of contact user IDs. + items: + type: string + realms: + type: array + description: List of realm identifiers. + items: + type: string + passwordMinEntropy: + type: integer + description: Minimum password entropy requirement. + passwordHistorySize: + type: integer + description: Number of previous passwords to remember. + passwordDaysExpiration: + type: integer + description: Number of days until password expires. + sfAccountIds: + type: array + description: Salesforce account identifiers. + items: + type: string + type: + type: string + description: Type of organization. + enum: + - CUSTOMER + - PARTNER + - INTERNAL + twoFARoles: + type: array + description: List of role IDs that require two-factor authentication. + items: + type: string + twoFAEnabled: + type: boolean + description: Indicates if two-factor authentication is enabled for the organization. + sfMyDomain: + type: string + nullable: true + description: Salesforce My Domain name. + sfMyDomainSuffix: + type: string + description: Salesforce My Domain suffix. + sfMyDomainVerified: + type: boolean + description: Indicates if Salesforce My Domain is verified. + sfMyDomainVerificationTimestamp: + type: string + format: date-time + nullable: true + description: Timestamp when Salesforce My Domain was verified. + sfIdentityFederation: + type: string + description: Salesforce identity federation status. + enum: + - DISABLED + - ENABLED + justInTimeUserProvisioningEnabled: + type: boolean + description: Indicates if just-in-time user provisioning is enabled. + allowedVerifierTypes: + type: array + description: List of allowed verifier types for authentication. + items: + type: string + disableInactiveUsers: + type: boolean + description: Indicates if inactive users should be automatically disabled. + inactiveUserDays: + type: integer + description: Number of days before a user is considered inactive. + id: + type: string + readOnly: true + description: Unique identifier of the organization. + ErrorResponse: + type: object + description: Standard error response format returned when API requests fail. + properties: + errors: + type: array + description: The list of errors. + items: + type: object + properties: + message: + type: string + description: Error message. + code: + type: string + description: Error code. + fieldErrors: + type: array + nullable: true + description: Field-specific errors. + items: + type: object + properties: + codes: + type: array + items: + type: string + description: List of error codes. + arguments: + nullable: true + description: Arguments for the error message. + defaultMessage: + type: string + description: Default error message. + objectName: + type: string + description: Name of the object that failed validation. + field: + type: string + description: The field that contained the erroneous value. + rejectedValue: + nullable: true + description: The value that was rejected. + bindingFailure: + type: boolean + description: Whether this error was caused by failed binding (e.g. type mismatch). + code: + type: string + description: Error code. + RoleTenantFilter: + type: string + pattern: '(\w+:\w{4,}_\w{3,}(,\w{4,}_\w{3,})*(;)?)*' + description: | + Filter for role tenant assignments. Format: ROLE_ENUM_NAME:instance_id,instance_id;ROLE_ENUM_NAME:instance_id + - Role enum names are separated by semicolons (;) + - Each role enum name is followed by a colon (:) and its tenant filters + - Tenant filters are comma-separated (,) + - Each tenant filter consists of a 4-character realm and 3-character instance_id separated by underscore (_) + - A special case is an instance_id ending in _sbx, as it gives access to all sandboxes of a realm + + Example: CC_USER:aabc_prd,aabc_t12;LOGCENTER_USER:aamn_sbx diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 8d821d4c..31ce0fad 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -99,6 +99,8 @@ export {CartridgeCommand} from './cartridge-command.js'; export {JobCommand} from './job-command.js'; export {MrtCommand} from './mrt-command.js'; export {OdsCommand} from './ods-command.js'; +export {UserCommand} from './user-command.js'; +export {RoleCommand} from './role-command.js'; export {WebDavCommand, WEBDAV_ROOTS, VALID_ROOTS} from './webdav-command.js'; export type {WebDavRootKey} from './webdav-command.js'; diff --git a/packages/b2c-tooling-sdk/src/cli/role-command.ts b/packages/b2c-tooling-sdk/src/cli/role-command.ts new file mode 100644 index 00000000..b6d528b5 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/role-command.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Command} from '@oclif/core'; +import {OAuthCommand} from './oauth-command.js'; +import {createAccountManagerRolesClient} from '../clients/am-roles-api.js'; +import type {AccountManagerRolesClient} from '../clients/am-roles-api.js'; + +/** + * Base command for Account Manager role operations. + * + * Extends OAuthCommand with Account Manager Roles client setup. + * + * @example + * export default class RoleList extends RoleCommand { + * async run(): Promise { + * const roles = await listRoles(this.accountManagerRolesClient, {}); + * // ... + * } + * } + */ +export abstract class RoleCommand extends OAuthCommand { + private _accountManagerRolesClient?: AccountManagerRolesClient; + + /** + * Gets the Account Manager Roles client, creating it if necessary. + */ + protected get accountManagerRolesClient(): AccountManagerRolesClient { + if (!this._accountManagerRolesClient) { + this.requireOAuthCredentials(); + const authStrategy = this.getOAuthStrategy(); + this._accountManagerRolesClient = createAccountManagerRolesClient( + { + hostname: this.accountManagerHost, + }, + authStrategy, + ); + } + return this._accountManagerRolesClient; + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/user-command.ts b/packages/b2c-tooling-sdk/src/cli/user-command.ts new file mode 100644 index 00000000..f7b85cf0 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/user-command.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Command} from '@oclif/core'; +import {OAuthCommand} from './oauth-command.js'; +import {createAccountManagerClient} from '../clients/am-users-api.js'; +import type {AccountManagerClient} from '../clients/am-users-api.js'; + +/** + * Base command for Account Manager user operations. + * + * Extends OAuthCommand with Account Manager client setup. + * + * @example + * export default class UserList extends UserCommand { + * async run(): Promise { + * const users = await this.accountManagerClient.listUsers(); + * // ... + * } + * } + */ +export abstract class UserCommand extends OAuthCommand { + private _accountManagerClient?: AccountManagerClient; + + /** + * Gets the Account Manager client, creating it if necessary. + */ + protected get accountManagerClient(): AccountManagerClient { + if (!this._accountManagerClient) { + this.requireOAuthCredentials(); + const authStrategy = this.getOAuthStrategy(); + this._accountManagerClient = createAccountManagerClient( + { + hostname: this.accountManagerHost, + }, + authStrategy, + ); + } + return this._accountManagerClient; + } +} diff --git a/packages/b2c-tooling-sdk/src/clients/am-roles-api.generated.ts b/packages/b2c-tooling-sdk/src/clients/am-roles-api.generated.ts new file mode 100644 index 00000000..1fdff7df --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/am-roles-api.generated.ts @@ -0,0 +1,299 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/dw/rest/v1/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all roles. + * @description Retrieve a paginated list of all roles. Use the roleTargetType parameter to filter by target type. + */ + get: operations["getRoles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dw/rest/v1/roles/{roleId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get role by ID. + * @description Retrieve a specific role by ID. + */ + get: operations["getRole"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description A role defines permissions and access levels that can be assigned to Users and API Clients. */ + Role: { + /** @description Description of the role. */ + description?: string; + /** @description Enumeration name of the role. */ + roleEnumName?: string; + /** @description List of permissions granted by this role. */ + permissions?: string[]; + /** + * @description Scope level of the role (global or instance-specific). + * @enum {string} + */ + scope?: "GLOBAL" | "INSTANCE"; + /** + * @description Type of entity the role can be assigned to. + * @enum {string|null} + */ + targetType?: "ApiClient" | "User" | null; + /** @description Indicates if two-factor authentication is required for the role. */ + twoFAEnabled?: boolean; + /** @description Unique identifier of the role. */ + id?: string; + }; + /** @description A paginated collection of roles. */ + RoleCollection: { + content?: components["schemas"]["Role"][]; + }; + /** @description Pagination parameters for list operations. */ + Pageable: { + /** + * Format: int32 + * @description Zero-based page index. + */ + page?: number; + /** + * Format: int32 + * @description Number of items to return per page. + * @default 20 + */ + size: number; + }; + /** @description Standard error response format returned when API requests fail. */ + ErrorResponse: { + /** @description The list of errors */ + errors?: { + /** @description Error message */ + message?: string; + /** @description Error code */ + code?: string; + /** @description Field-specific errors */ + fieldErrors?: { + /** @description The field that contained the erroneous value */ + field?: string; + /** @description The value that was rejected */ + rejectedValue?: Record; + /** @description Whether this error was caused by failed binding (e.g. type mismatch) */ + bindingFailure?: boolean; + }[] | null; + }[]; + }; + }; + responses: { + /** @description Access token is missing or invalid */ + UnauthorizedError: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "errors": [ + * { + * "message": "Full authentication is required to access this resource", + * "code": "InsufficientAuthenticationException", + * "fieldErrors": null + * } + * ] + * } + */ + "application/json": { + /** @description The list of errors */ + errors?: { + /** @description Error message */ + message?: string; + /** @description Error code */ + code?: string; + /** @description Field-specific errors. */ + fieldErrors?: { + /** @description The field that contained the erroneous value. */ + field?: string; + /** @description The value that was rejected. */ + rejectedValue?: Record; + /** @description Whether this error was caused by failed binding (e.g. type mismatch). */ + bindingFailure?: boolean; + }[] | null; + }[]; + }; + }; + }; + /** @description Request has been rate-limited. The X-RateLimit-* headers can be used to improve retry behavior. */ + RateLimitedError: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content?: never; + }; + }; + parameters: never; + requestBodies: never; + headers: { + /** @description Rate limit per minute. */ + "X-RateLimit-Limit": number; + /** @description The number of requests left in the current time window. */ + "X-RateLimit-Remaining": number; + /** @description The UTC timestamp at which the current rate limit window resets. */ + "X-RateLimit-Reset": string; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getRoles: { + parameters: { + query?: { + pageable?: components["schemas"]["Pageable"]; + roleTargetType?: "ApiClient" | "User"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "content": [ + * { + * "description": "Business Manager User", + * "roleEnumName": "ECOM_USER", + * "permissions": [], + * "scope": "INSTANCE", + * "targetType": "User", + * "id": "bm-user" + * }, + * { + * "description": "Business Manager Administrator", + * "roleEnumName": "ECOM_ADMIN", + * "permissions": [], + * "scope": "INSTANCE", + * "targetType": "User", + * "id": "bm-admin" + * } + * ] + * } + */ + "application/json": components["schemas"]["RoleCollection"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + getRole: { + parameters: { + query?: never; + header?: never; + path: { + roleId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "description": "Business Manager User", + * "roleEnumName": "ECOM_USER", + * "permissions": [], + * "scope": "INSTANCE", + * "targetType": "User", + * "id": "bm-user" + * } + */ + "application/json": components["schemas"]["Role"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A role with this ID was not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/am-roles-api.ts b/packages/b2c-tooling-sdk/src/clients/am-roles-api.ts new file mode 100644 index 00000000..29b61d0d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/am-roles-api.ts @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager Roles API client for B2C Commerce. + * + * Provides a fully typed client for the Account Manager Roles REST API using + * openapi-fetch with OAuth authentication middleware. Used for retrieving + * role information and permissions in Account Manager. + * + * @module clients/am-roles-api + */ +import createClient, {type Client, type Middleware} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './am-roles-api.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; +import {getLogger} from '../logging/logger.js'; + +/** + * Re-export generated types for external use. + */ +export type {paths, components}; + +/** + * The typed Account Manager Roles client - this is the openapi-fetch Client with full type safety. + * + * @see {@link createAccountManagerRolesClient} for instantiation + */ +export type AccountManagerRolesClient = Client; + +/** + * Helper type to extract response data from an operation. + */ +export type AccountManagerRolesResponse = T extends {content: {'application/json': infer R}} ? R : never; + +/** + * Account Manager Roles error response type from the generated schema. + */ +export type AccountManagerRolesError = components['schemas']['ErrorResponse']; + +/** + * Middleware to transform pageable query parameters from bracket notation + * (pageable[size]=X&pageable[page]=Y) to flattened format (size=X&page=Y) + * that the Account Manager API expects. + */ +function createPageableTransformMiddleware(): Middleware { + const logger = getLogger(); + return { + async onRequest({request}) { + const url = new URL(request.url); + + // Check if URL has pageable[size] or pageable[page] parameters + const pageableSize = url.searchParams.get('pageable[size]'); + const pageablePage = url.searchParams.get('pageable[page]'); + + if (pageableSize !== null || pageablePage !== null) { + // Remove the bracket notation parameters + url.searchParams.delete('pageable[size]'); + url.searchParams.delete('pageable[page]'); + + // Add flattened parameters + if (pageableSize !== null) { + url.searchParams.set('size', pageableSize); + } + if (pageablePage !== null) { + url.searchParams.set('page', pageablePage); + } + + logger.trace( + { + originalUrl: request.url, + transformedUrl: url.toString(), + size: pageableSize, + page: pageablePage, + }, + '[AM-ROLES] Transformed pageable query parameters from bracket to flattened notation', + ); + + return new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + }); + } + + return request; + }, + }; +} + +/** + * Configuration for creating an Account Manager Roles client. + */ +export interface AccountManagerRolesClientConfig { + /** + * Account Manager hostname. + * Defaults to: account.demandware.com + * + * @example "account.demandware.com" + */ + hostname?: string; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; +} + +/** + * Creates a typed Account Manager Roles API client. + * + * Returns the openapi-fetch client directly, with authentication + * handled via middleware. This gives full access to all openapi-fetch + * features with type-safe paths, parameters, and responses. + * + * @param config - Account Manager Roles client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Typed openapi-fetch client + * + * @example + * // Create Account Manager Roles client with OAuth auth + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerRolesClient({}, oauthStrategy); + * + * // List roles + * const { data, error } = await client.GET('/dw/rest/v1/roles', { + * params: { query: { pageable: { size: 25, page: 0 } } } + * }); + * + * // Get role by ID + * const { data, error } = await client.GET('/dw/rest/v1/roles/{roleId}', { + * params: { path: { roleId: 'bm-admin' } } + * }); + */ +export function createAccountManagerRolesClient( + config: AccountManagerRolesClientConfig, + auth: AuthStrategy, +): AccountManagerRolesClient { + const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${hostname}`, + }); + + // Core middleware: auth first + client.use(createAuthMiddleware(auth)); + + // Transform pageable query parameters from bracket notation to flattened format + // This is needed because the API expects size=X&page=Y, not pageable[size]=X&pageable[page]=Y + client.use(createPageableTransformMiddleware()); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('am-roles-api')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) + client.use(createLoggingMiddleware('AM-ROLES')); + + return client; +} + +/** + * Role type from the generated schema. + */ +export type AccountManagerRole = components['schemas']['Role']; +export type RoleCollection = components['schemas']['RoleCollection']; + +/** + * Options for listing roles. + */ +export interface ListRolesOptions { + /** Page size (default: 20, min: 1, max: 4000) */ + size?: number; + /** Page number (default: 0) */ + page?: number; + /** Filter by target type (User or ApiClient) */ + roleTargetType?: 'ApiClient' | 'User'; +} + +/** + * Retrieves details of a role by ID. + * + * @param client - Account Manager Roles client + * @param roleId - Role ID + * @returns Role details + * @throws Error if role is not found or request fails + */ +export async function getRole(client: AccountManagerRolesClient, roleId: string): Promise { + const result = await client.GET('/dw/rest/v1/roles/{roleId}', { + params: {path: {roleId}}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + if (result.response?.status === 404) { + throw new Error(`Role ${roleId} not found`); + } + throw new Error(error.error?.message || `Failed to get role: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Lists roles with pagination. + * + * @param client - Account Manager Roles client + * @param options - List options (size, page, roleTargetType) + * @returns Paginated role collection + * @throws Error if request fails + */ +export async function listRoles( + client: AccountManagerRolesClient, + options: ListRolesOptions = {}, +): Promise { + const {size = 20, page = 0, roleTargetType} = options; + + const result = await client.GET('/dw/rest/v1/roles', { + params: { + query: { + pageable: { + size, + page, + }, + ...(roleTargetType && {roleTargetType}), + }, + }, + }); + + if (result.error) { + const error = result.error as { + error?: {message?: string}; + errors?: Array<{message?: string; code?: string}>; + }; + + // Check for pagination out-of-bounds error + const errorMessage = error.errors?.[0]?.message || error.error?.message; + if (errorMessage?.includes('fromIndex') && errorMessage?.includes('toIndex')) { + throw new Error( + `Page ${page} is out of bounds. The requested page exceeds the available data. Try a lower page number.`, + ); + } + + throw new Error(errorMessage || `Failed to list roles: ${JSON.stringify(result.error)}`); + } + + return result.data || {content: []}; +} diff --git a/packages/b2c-tooling-sdk/src/clients/am-users-api.generated.ts b/packages/b2c-tooling-sdk/src/clients/am-users-api.generated.ts new file mode 100644 index 00000000..96040c7d --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/am-users-api.generated.ts @@ -0,0 +1,892 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/dw/rest/v1/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all users. + * @description Retrieve a paginated list of all users. + */ + get: operations["getUsers"]; + put?: never; + /** + * Create user. + * @description Create a new user with the specified properties. + * Note: Users are created in INITIAL state. + */ + post: operations["createUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dw/rest/v1/users/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user by ID. + * @description Retrieve a specific user by ID. Use the expand parameter to retrieve more information on related organizations and roles. + */ + get: operations["getUser"]; + /** + * Update user. + * @description Apply a partial update to an existing user, i.e. an omitted field stays at its previous value, a contained field replaces the previously saved value. + */ + put: operations["updateUser"]; + post?: never; + /** + * Purge user. + * @description Purge a user. Only users in state Deleted may be purged. + */ + delete: operations["purgeUser"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dw/rest/v1/users/{userId}/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reset user to INITIAL status + * @description Reset a user to INITIAL status and send activation instructions. + */ + post: operations["resetUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/dw/rest/v1/users/{userId}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Disable user. + * @description Disable a user, setting their userState to DELETED. This is a prerequisite for purging a user. + */ + post: operations["disableUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** @description Request body for creating a new user. */ + UserCreate: { + /** @description Email address of the user. */ + mail: string; + /** @description First name of the user. */ + firstName: string; + /** @description Last name of the user. */ + lastName: string; + /** @description Display name of the user. */ + displayName?: string; + /** @description Business phone number. */ + businessPhone?: string | null; + /** @description Home phone number. */ + homePhone?: string | null; + /** @description Mobile phone number. */ + mobilePhone?: string | null; + /** + * @description Preferred locale for the user. + * @enum {string|null} + */ + preferredLocale?: "none" | "de" | "de_DE" | "en" | "en_CA" | "en_US" | "es" | "fr" | "fr_CA" | "nl" | null; + /** @description List of IDs of the roles this user possesses. */ + roles?: string[]; + /** @description List of organization IDs this user belongs to. */ + organizations: string[]; + /** @description Primary organization ID for the user. */ + primaryOrganization: string; + roleTenantFilter?: components["schemas"]["RoleTenantFilter"]; + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description Request body for updating an existing user. */ + UserUpdate: { + /** @description Email address of the user. */ + mail?: string; + /** @description First name of the user. */ + firstName?: string; + /** @description Last name of the user. */ + lastName?: string; + /** @description Display name of the user. */ + displayName?: string; + /** @description Business phone number. */ + businessPhone?: string | null; + /** @description Home phone number. */ + homePhone?: string | null; + /** @description Mobile phone number. */ + mobilePhone?: string | null; + /** + * @description Preferred locale for the user. + * @enum {string|null} + */ + preferredLocale?: "none" | "de" | "de_DE" | "en" | "en_CA" | "en_US" | "es" | "fr" | "fr_CA" | "nl" | null; + /** @description List of IDs of the roles this user possesses. */ + roles?: string[]; + /** @description List of organization IDs this user belongs to. */ + organizations?: string[]; + /** @description Primary organization ID for the user. */ + primaryOrganization?: string; + roleTenantFilter?: components["schemas"]["RoleTenantFilter"]; + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description User object returned in read operations. */ + UserRead: { + /** @description Email address of the user. */ + mail?: string; + /** @description First name of the user. */ + firstName?: string; + /** @description Last name of the user. */ + lastName?: string; + /** @description Display name of the user. */ + displayName?: string; + /** @description Business phone number. */ + businessPhone?: string | null; + /** @description Home phone number. */ + homePhone?: string | null; + /** @description Mobile phone number. */ + mobilePhone?: string | null; + /** + * @description Preferred locale for the user. + * @enum {string|null} + */ + preferredLocale?: "none" | "de" | "de_DE" | "en" | "en_CA" | "en_US" | "es" | "fr" | "fr_CA" | "nl" | null; + /** @description List of IDs of the roles this user possesses. */ + roles?: (string | components["schemas"]["Role"])[]; + /** @description List of organization IDs this user belongs to. */ + organizations?: (string | components["schemas"]["Organization"])[]; + /** @description Primary organization ID for the user. */ + primaryOrganization?: string; + roleTenantFilter?: components["schemas"]["RoleTenantFilter"]; + /** + * Format: int64 + * @description Timestamp when the password expires. + */ + readonly passwordExpirationTimestamp?: number | null; + /** + * Format: int64 + * @description Timestamp of the last password modification. + */ + readonly passwordModificationTimestamp?: number | null; + /** + * Format: date-time + * @description Timestamp when the user was created. + */ + readonly createdAt?: string | null; + /** + * Format: date-time + * @description Timestamp of the last modification. + */ + readonly lastModified?: string | null; + /** + * Format: date + * @description Date of the last successful login. + */ + readonly lastLoginDate?: string | null; + /** + * @description Current state of the user account. + * @enum {string} + */ + readonly userState?: "INITIAL" | "ENABLED" | "DELETED"; + /** + * Format: int64 + * @description Timestamp when the activation code was created. + */ + readonly activationCodeCreationTimestamp?: number | null; + /** @description Salesforce user identifier. */ + readonly sfUserId?: string | null; + /** @description List of authentication verifiers (e.g., MFA devices). */ + readonly verifiers?: components["schemas"]["Verifier"][]; + /** + * Format: int64 + * @description Timestamp when the user was marked for deletion. + */ + readonly deleteTimestamp?: number | null; + /** @description Map of role tenant filter assignments. */ + readonly roleTenantFilterMap?: Record; + /** @description Indicates if the user is linked to a Salesforce identity. */ + readonly linkedToSfIdentity?: boolean; + /** + * Format: uuid + * @description Unique identifier of the user. + */ + readonly id?: string; + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description A verifier represents a two-factor authentication device or method associated with a user account. */ + Verifier: { + /** @description Unique identifier of the verifier. */ + id: string; + /** @description Type of verifier (e.g., SMS, TOTP, WebAuthn). */ + type: string; + /** @description Human-readable name for the verifier. */ + displayName: string; + /** @description Current status of the verifier. */ + status: string; + }; + /** @description Request body for resetting a user to INITIAL state. */ + UserResetResource: { + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description Request body for disabling a user. */ + UserDeactivationResource: { + /** @description Only required for users of the Salesforce organization. */ + supportTicketId?: string; + }; + /** @description Pagination parameters for list operations. */ + Pageable: { + /** + * Format: int32 + * @description Zero-based page index. + */ + page?: number; + /** + * Format: int32 + * @description Number of items to return per page. + * @default 20 + */ + size: number; + }; + /** @description A paginated collection of users. */ + UserCollection: { + content?: components["schemas"]["UserRead"][]; + }; + /** @description A role defines permissions and access levels that can be assigned to Users and API Clients. */ + Role: { + /** @description Description of the role. */ + description?: string; + /** @description Enumeration name of the role. */ + roleEnumName?: string; + /** @description List of permissions granted by this role. */ + permissions?: string[]; + /** + * @description Scope level of the role (global or instance-specific). + * @enum {string} + */ + scope?: "GLOBAL" | "INSTANCE"; + /** + * @description Type of entity this role can be assigned to. + * @enum {string|null} + */ + targetType?: "ApiClient" | "User" | null; + /** @description Indicates if two-factor authentication is required for this role. */ + twoFAEnabled?: boolean; + /** @description Unique identifier of the role. */ + id?: string; + }; + /** @description An organization represents a customer, partner, or internal entity within the Account Manager system. */ + Organization: { + /** @description Name of the organization. */ + name?: string; + /** @description List of contact user IDs. */ + contactUsers?: string[]; + /** @description List of realm identifiers. */ + realms?: string[]; + /** @description Minimum password entropy requirement. */ + passwordMinEntropy?: number; + /** @description Number of previous passwords to remember. */ + passwordHistorySize?: number; + /** @description Number of days until password expires. */ + passwordDaysExpiration?: number; + /** @description Salesforce account identifiers. */ + sfAccountIds?: string[]; + /** + * @description Type of organization. + * @enum {string} + */ + type?: "CUSTOMER" | "PARTNER" | "INTERNAL"; + /** @description List of role IDs that require two-factor authentication. */ + twoFARoles?: string[]; + /** @description Indicates if two-factor authentication is enabled for the organization. */ + twoFAEnabled?: boolean; + /** @description Salesforce My Domain name. */ + sfMyDomain?: string | null; + /** @description Salesforce My Domain suffix. */ + sfMyDomainSuffix?: string; + /** @description Indicates if Salesforce My Domain is verified. */ + sfMyDomainVerified?: boolean; + /** + * Format: date-time + * @description Timestamp when Salesforce My Domain was verified. + */ + sfMyDomainVerificationTimestamp?: string | null; + /** + * @description Salesforce identity federation status. + * @enum {string} + */ + sfIdentityFederation?: "DISABLED" | "ENABLED"; + /** @description Indicates if just-in-time user provisioning is enabled. */ + justInTimeUserProvisioningEnabled?: boolean; + /** @description List of allowed verifier types for authentication. */ + allowedVerifierTypes?: string[]; + /** @description Indicates if inactive users should be automatically disabled. */ + disableInactiveUsers?: boolean; + /** @description Number of days before a user is considered inactive. */ + inactiveUserDays?: number; + /** @description Unique identifier of the organization. */ + readonly id?: string; + }; + /** @description Standard error response format returned when API requests fail. */ + ErrorResponse: { + /** @description The list of errors. */ + errors?: { + /** @description Error message. */ + message?: string; + /** @description Error code. */ + code?: string; + /** @description Field-specific errors. */ + fieldErrors?: { + /** @description List of error codes. */ + codes?: string[]; + /** @description Arguments for the error message. */ + arguments?: unknown; + /** @description Default error message. */ + defaultMessage?: string; + /** @description Name of the object that failed validation. */ + objectName?: string; + /** @description The field that contained the erroneous value. */ + field?: string; + /** @description The value that was rejected. */ + rejectedValue?: unknown; + /** @description Whether this error was caused by failed binding (e.g. type mismatch). */ + bindingFailure?: boolean; + /** @description Error code. */ + code?: string; + }[] | null; + }[]; + }; + /** + * @description Filter for role tenant assignments. Format: ROLE_ENUM_NAME:instance_id,instance_id;ROLE_ENUM_NAME:instance_id + * - Role enum names are separated by semicolons (;) + * - Each role enum name is followed by a colon (:) and its tenant filters + * - Tenant filters are comma-separated (,) + * - Each tenant filter consists of a 4-character realm and 3-character instance_id separated by underscore (_) + * - A special case is an instance_id ending in _sbx, as it gives access to all sandboxes of a realm + * + * Example: CC_USER:aabc_prd,aabc_t12;LOGCENTER_USER:aamn_sbx + */ + RoleTenantFilter: string; + }; + responses: { + /** @description Request has been rate-limited. The X-RateLimit-* headers can be used to improve retry behavior. */ + RateLimitedError: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content?: never; + }; + }; + parameters: never; + requestBodies: never; + headers: { + /** @description Rate limit per minute. */ + "X-RateLimit-Limit": number; + /** @description The number of requests left in the current time window. */ + "X-RateLimit-Remaining": number; + /** @description The UTC timestamp at which the current rate limit window resets. */ + "X-RateLimit-Reset": string; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getUsers: { + parameters: { + query?: { + pageable?: components["schemas"]["Pageable"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "content": [ + * { + * "id": "a7f3c8e2-91d4-4b5a-8c6f-2e9d1a3b7f4c", + * "mail": "alice.johnson@example.com", + * "firstName": "Alice", + * "lastName": "Johnson", + * "displayName": "Alice Johnson", + * "mobilePhone": "+1-555-9876", + * "preferredLocale": "en_US", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd", + * "organizations": [ + * "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + * ], + * "primaryOrganization": "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b", + * "userState": "ENABLED", + * "createdAt": "2024-01-15T10:30:00Z", + * "lastModified": "2024-11-20T14:22:00Z", + * "lastLoginDate": "2024-12-03", + * "linkedToSfIdentity": false + * }, + * { + * "id": "c9b5e7f1-84a2-4d6c-9e3b-7f2a1c8d5e9a", + * "mail": "robert.williams@example.com", + * "firstName": "Robert", + * "lastName": "Williams", + * "displayName": "Robert Williams", + * "preferredLocale": "en_US", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:wxyz_stg", + * "organizations": [ + * "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + * ], + * "primaryOrganization": "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d", + * "userState": "INITIAL", + * "createdAt": "2024-12-04T09:15:00Z", + * "linkedToSfIdentity": false + * } + * ] + * } + */ + "application/json": components["schemas"]["UserCollection"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + createUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "mail": "john.doe@example.com", + * "firstName": "John", + * "lastName": "Doe", + * "primaryOrganization": "e39dbb7a-63bd-4972-980b-0f6fb3a24bd6", + * "organizations": [ + * "e39dbb7a-63bd-4972-980b-0f6fb3a24bd6" + * ], + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd" + * } + */ + "application/json": components["schemas"]["UserCreate"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + /** @description URL to read created user */ + Location?: string; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "id": "c9b5e7f1-84a2-4d6c-9e3b-7f2a1c8d5e9a", + * "mail": "robert.williams@example.com", + * "firstName": "Robert", + * "lastName": "Williams", + * "displayName": "Robert Williams", + * "primaryOrganization": "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d", + * "organizations": [ + * "d4f8a2c6-53e9-4b7a-9c1d-6e3f9a2b8c7d" + * ], + * "userState": "INITIAL", + * "createdAt": "2024-12-04T09:15:00Z", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd" + * } + */ + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + getUser: { + parameters: { + query?: { + /** + * @description Comma-separated list of fields that should be expanded in the response. Ensures that fully inlined organization and/or role objects get returned. + * @example organizations + */ + expand?: ("organizations" | "roles")[]; + }; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "id": "a7f3c8e2-91d4-4b5a-8c6f-2e9d1a3b7f4c", + * "mail": "alice.johnson@example.com", + * "firstName": "Alice", + * "lastName": "Johnson", + * "displayName": "Alice Johnson", + * "mobilePhone": "+1-555-9876", + * "preferredLocale": "en_US", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd", + * "organizations": [ + * "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b" + * ], + * "primaryOrganization": "b2c8f7a3-45d6-4e9a-8b1c-3f7e2d9a6c5b", + * "userState": "ENABLED", + * "createdAt": "2024-01-15T10:30:00Z", + * "lastModified": "2024-11-20T14:22:00Z", + * "lastLoginDate": "2024-12-03", + * "linkedToSfIdentity": false + * } + */ + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A user with this ID was not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "firstName": "Jane", + * "lastName": "Smith", + * "displayName": "Jane Smith", + * "mobilePhone": "+1-555-0123", + * "preferredLocale": "en_US", + * "roles": [ + * "bm-user" + * ], + * "roleTenantFilter": "ECOM_USER:abcd_prd" + * } + */ + "application/json": components["schemas"]["UserUpdate"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + purgeUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User has been purged. */ + 204: { + headers: { + "X-RateLimit-Limit": components["headers"]["X-RateLimit-Limit"]; + "X-RateLimit-Remaining": components["headers"]["X-RateLimit-Remaining"]; + "X-RateLimit-Reset": components["headers"]["X-RateLimit-Reset"]; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description A user with this ID was not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Precondition Failed - User must be in status DELETED to be purged. */ + 412: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + resetUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UserResetResource"]; + }; + }; + responses: { + /** @description User has been reset. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description User not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; + disableUser: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UserDeactivationResource"]; + }; + }; + responses: { + /** @description User has been disabled. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Access token is missing or invalid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description User not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["RateLimitedError"]; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/am-users-api.ts b/packages/b2c-tooling-sdk/src/clients/am-users-api.ts new file mode 100644 index 00000000..12fcc5cd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/am-users-api.ts @@ -0,0 +1,467 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager Users API client for B2C Commerce. + * + * Provides a fully typed client for the Account Manager Users REST API using + * openapi-fetch with OAuth authentication middleware. Used for managing + * user accounts, roles, and permissions in Account Manager. + * + * @module clients/am-users-api + */ +import createClient, {type Client, type Middleware} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './am-users-api.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; +import {getLogger} from '../logging/logger.js'; + +/** + * Re-export generated types for external use. + */ +export type {paths, components}; + +/** + * The typed Account Manager Users client - this is the openapi-fetch Client with full type safety. + * + * @see {@link createAccountManagerClient} for instantiation + */ +export type AccountManagerClient = Client; + +/** + * Helper type to extract response data from an operation. + */ +export type AccountManagerResponse = T extends {content: {'application/json': infer R}} ? R : never; + +/** + * Account Manager error response type from the generated schema. + */ +export type AccountManagerError = components['schemas']['ErrorResponse']; + +/** + * Middleware to transform pageable query parameters from bracket notation + * (pageable[size]=X&pageable[page]=Y) to flattened format (size=X&page=Y) + * that the Account Manager API expects. + */ +function createPageableTransformMiddleware(): Middleware { + const logger = getLogger(); + return { + async onRequest({request}) { + const url = new URL(request.url); + + // Check if URL has pageable[size] or pageable[page] parameters + const pageableSize = url.searchParams.get('pageable[size]'); + const pageablePage = url.searchParams.get('pageable[page]'); + + if (pageableSize !== null || pageablePage !== null) { + // Remove the bracket notation parameters + url.searchParams.delete('pageable[size]'); + url.searchParams.delete('pageable[page]'); + + // Add flattened parameters + if (pageableSize !== null) { + url.searchParams.set('size', pageableSize); + } + if (pageablePage !== null) { + url.searchParams.set('page', pageablePage); + } + + logger.trace( + { + originalUrl: request.url, + transformedUrl: url.toString(), + size: pageableSize, + page: pageablePage, + }, + '[AM-USERS] Transformed pageable query parameters from bracket to flattened notation', + ); + + return new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + }); + } + + return request; + }, + }; +} + +/** + * Configuration for creating an Account Manager client. + */ +export interface AccountManagerClientConfig { + /** + * Account Manager hostname. + * Defaults to: account.demandware.com + * + * @example "account.demandware.com" + */ + hostname?: string; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; +} + +/** + * Creates a typed Account Manager Users API client. + * + * Returns the openapi-fetch client directly, with authentication + * handled via middleware. This gives full access to all openapi-fetch + * features with type-safe paths, parameters, and responses. + * + * @param config - Account Manager client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Typed openapi-fetch client + * + * @example + * // Create Account Manager client with OAuth auth + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerClient({}, oauthStrategy); + * + * // List users + * const { data, error } = await client.GET('/dw/rest/v1/users', { + * params: { query: { pageable: { size: 25, page: 0 } } } + * }); + * + * // Get user by ID + * const { data, error } = await client.GET('/dw/rest/v1/users/{userId}', { + * params: { path: { userId: 'user-uuid' } } + * }); + * + * // Create user + * const { data, error } = await client.POST('/dw/rest/v1/users', { + * body: { + * mail: 'user@example.com', + * firstName: 'John', + * lastName: 'Doe', + * organizations: ['org-id'], + * primaryOrganization: 'org-id', + * } + * }); + */ +export function createAccountManagerClient( + config: AccountManagerClientConfig, + auth: AuthStrategy, +): AccountManagerClient { + const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${hostname}`, + }); + + // Core middleware: auth first + client.use(createAuthMiddleware(auth)); + + // Transform pageable query parameters from bracket notation to flattened format + // This is needed because the API expects size=X&page=Y, not pageable[size]=X&pageable[page]=Y + client.use(createPageableTransformMiddleware()); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('am-users-api')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) + client.use(createLoggingMiddleware('AM-USERS')); + + return client; +} + +/** + * Role name mappings between external and internal formats. + */ +export const ROLE_NAMES_MAP: Record = { + 'bm-admin': 'ECOM_ADMIN', + 'bm-user': 'ECOM_USER', +}; + +export const ROLE_NAMES_MAP_REVERSE: Record = { + ECOM_ADMIN: 'bm-admin', + ECOM_USER: 'bm-user', +}; + +/** + * Maps the role name to an internal role ID accepted by the API. + */ +export function mapToInternalRole(role: string): string { + if (ROLE_NAMES_MAP[role]) { + return ROLE_NAMES_MAP[role]; + } + return role.toUpperCase().replace(/-/g, '_'); +} + +/** + * Maps the internal role ID to role name. + */ +export function mapFromInternalRole(roleID: string): string { + if (ROLE_NAMES_MAP_REVERSE[roleID]) { + return ROLE_NAMES_MAP_REVERSE[roleID]; + } + return roleID.toLowerCase().replace(/_/g, '-'); +} + +/** + * User type from the generated schema. + */ +export type AccountManagerUser = components['schemas']['UserRead']; +export type UserCreate = components['schemas']['UserCreate']; +export type UserUpdate = components['schemas']['UserUpdate']; +export type UserCollection = components['schemas']['UserCollection']; +export type UserState = 'INITIAL' | 'ENABLED' | 'DELETED'; + +/** + * Options for listing users. + */ +export interface ListUsersOptions { + /** Page size (default: 20, min: 1, max: 4000) */ + size?: number; + /** Page number (default: 0) */ + page?: number; +} + +/** + * Retrieves details of a user by ID. + * + * @param client - Account Manager client + * @param userId - User ID (UUID) + * @returns User details + * @throws Error if user is not found or request fails + */ +export async function getUser(client: AccountManagerClient, userId: string): Promise { + const result = await client.GET('/dw/rest/v1/users/{userId}', { + params: {path: {userId}}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + if (result.response?.status === 404) { + throw new Error(`User ${userId} not found`); + } + throw new Error(error.error?.message || `Failed to get user: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Lists users with pagination. + * + * @param client - Account Manager client + * @param options - List options (size, page) + * @returns Paginated user collection + * @throws Error if request fails + */ +export async function listUsers(client: AccountManagerClient, options: ListUsersOptions = {}): Promise { + const {size = 20, page = 0} = options; + + const result = await client.GET('/dw/rest/v1/users', { + params: { + query: { + pageable: { + size, + page, + }, + }, + }, + }); + + if (result.error) { + const error = result.error as { + error?: {message?: string}; + errors?: Array<{message?: string; code?: string}>; + }; + + // Check for pagination out-of-bounds error + const errorMessage = error.errors?.[0]?.message || error.error?.message; + if (errorMessage?.includes('fromIndex') && errorMessage?.includes('toIndex')) { + throw new Error( + `Page ${page} is out of bounds. The requested page exceeds the available data. Try a lower page number.`, + ); + } + + throw new Error(errorMessage || `Failed to list users: ${JSON.stringify(result.error)}`); + } + + return result.data || {content: []}; +} + +/** + * Creates a new user. + * + * @param client - Account Manager client + * @param user - User details + * @returns Created user + * @throws Error if request fails + */ +export async function createUser(client: AccountManagerClient, user: UserCreate): Promise { + const result = await client.POST('/dw/rest/v1/users', { + body: user, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to create user: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Updates an existing user. + * + * @param client - Account Manager client + * @param userId - User ID + * @param changes - Changes to apply + * @returns Updated user + * @throws Error if request fails + */ +export async function updateUser( + client: AccountManagerClient, + userId: string, + changes: UserUpdate, +): Promise { + const result = await client.PUT('/dw/rest/v1/users/{userId}', { + params: {path: {userId}}, + body: changes, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to update user: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Disables a user (soft delete - sets userState to DELETED). + * Users must be disabled before they can be purged. + * + * @param client - Account Manager client + * @param userId - User ID + * @throws Error if request fails + */ +export async function deleteUser(client: AccountManagerClient, userId: string): Promise { + const result = await client.POST('/dw/rest/v1/users/{userId}/disable', { + params: {path: {userId}}, + body: {}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to delete user: ${JSON.stringify(result.error)}`); + } +} + +/** + * Purges a user (hard delete). + * Users must be in DELETED state before they can be purged. + * + * @param client - Account Manager client + * @param userId - User ID + * @throws Error if request fails + */ +export async function purgeUser(client: AccountManagerClient, userId: string): Promise { + const result = await client.DELETE('/dw/rest/v1/users/{userId}', { + params: {path: {userId}}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to purge user: ${JSON.stringify(result.error)}`); + } +} + +/** + * Resets a user to INITIAL state and sends activation instructions. + * + * @param client - Account Manager client + * @param userId - User ID + * @throws Error if request fails + */ +export async function resetUser(client: AccountManagerClient, userId: string): Promise { + const result = await client.POST('/dw/rest/v1/users/{userId}/reset', { + params: {path: {userId}}, + body: {}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to reset user: ${JSON.stringify(result.error)}`); + } +} + +/** + * Helper to find a user by login (email) from a list of users. + * This is a convenience function since the API doesn't have a direct search by login endpoint. + * + * @param client - Account Manager client + * @param login - User login (email) + * @returns User if found, undefined otherwise + * @throws Error if request fails + */ +export async function findUserByLogin( + client: AccountManagerClient, + login: string, +): Promise { + // Search through paginated results + let page = 0; + const pageSize = 100; + + while (true) { + const result = await client.GET('/dw/rest/v1/users', { + params: { + query: { + pageable: { + page, + size: pageSize, + }, + }, + }, + }); + + if (result.error) { + throw new Error(`Failed to search for user: ${JSON.stringify(result.error)}`); + } + + const users = result.data?.content || []; + const found = users.find((u) => u.mail === login); + + if (found) { + return found; + } + + // If we got fewer results than page size, we've reached the end + if (users.length < pageSize) { + return undefined; + } + + page++; + } +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 78fa9586..27dc8378 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -204,6 +204,49 @@ export type { components as ScapiSchemasComponents, } from './scapi-schemas.js'; +export { + createAccountManagerClient, + getUser, + listUsers, + createUser, + updateUser, + deleteUser, + purgeUser, + resetUser, + findUserByLogin, + mapToInternalRole, + mapFromInternalRole, + ROLE_NAMES_MAP, + ROLE_NAMES_MAP_REVERSE, +} from './am-users-api.js'; +export type { + AccountManagerClient, + AccountManagerClientConfig, + AccountManagerUser, + AccountManagerResponse, + AccountManagerError, + UserCreate, + UserUpdate, + UserCollection, + UserState, + ListUsersOptions, + paths as AccountManagerPaths, + components as AccountManagerComponents, +} from './am-users-api.js'; + +export {createAccountManagerRolesClient, getRole, listRoles} from './am-roles-api.js'; +export type { + AccountManagerRolesClient, + AccountManagerRolesClientConfig, + AccountManagerRole, + AccountManagerRolesResponse, + AccountManagerRolesError, + RoleCollection, + ListRolesOptions, + paths as AccountManagerRolesPaths, + components as AccountManagerRolesComponents, +} from './am-roles-api.js'; + export {createCdnZonesClient, CDN_ZONES_READ_SCOPES, CDN_ZONES_RW_SCOPES} from './cdn-zones.js'; export type { CdnZonesClient, diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 9d22231b..dfcd365d 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -53,7 +53,9 @@ export type HttpClientType = | 'custom-apis' | 'scapi-schemas' | 'cdn-zones' - | 'webdav'; + | 'webdav' + | 'am-users-api' + | 'am-roles-api'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index dafdadd7..1e736f8f 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -66,6 +66,7 @@ export { createSlasClient, createOdsClient, createCustomApisClient, + createAccountManagerClient, createCdnZonesClient, toOrganizationId, toTenantId, @@ -103,6 +104,26 @@ export type { CustomApisResponse, CustomApisPaths, CustomApisComponents, + AccountManagerClient, + AccountManagerClientConfig, + AccountManagerUser, + AccountManagerResponse, + AccountManagerError, + UserCreate, + UserUpdate, + UserCollection, + UserState, + AccountManagerPaths, + AccountManagerComponents, + AccountManagerRolesClient, + AccountManagerRolesClientConfig, + AccountManagerRole, + AccountManagerRolesResponse, + AccountManagerRolesError, + RoleCollection, + ListRolesOptions, + AccountManagerRolesPaths, + AccountManagerRolesComponents, CdnZonesClient, CdnZonesClientConfig, CdnZonesClientOptions, @@ -204,6 +225,23 @@ export { SandboxNotFoundError, } from './operations/ods/index.js'; +// Operations - Users +export { + getUser, + getUserByLogin, + listUsers, + createUser, + updateUser, + deleteUser, + purgeUser, + resetUser, + grantRole, + revokeRole, +} from './operations/users/index.js'; + +// Operations - Roles +export {getRole, listRoles} from './operations/roles/index.js'; + // Defaults export {DEFAULT_ACCOUNT_MANAGER_HOST, DEFAULT_ODS_HOST} from './defaults.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/roles/index.ts b/packages/b2c-tooling-sdk/src/operations/roles/index.ts new file mode 100644 index 00000000..71dc17cd --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/roles/index.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager role management operations. + * + * This module provides high-level functions for retrieving role information + * in Account Manager, including listing roles and getting role details. + * + * ## Core Role Functions + * + * - {@link getRole} - Get role details by ID + * - {@link listRoles} - List roles with pagination + * + * ## Usage + * + * ```typescript + * import {getRole, listRoles} from '@salesforce/b2c-tooling-sdk/operations/roles'; + * import {createAccountManagerRolesClient} from '@salesforce/b2c-tooling-sdk/clients'; + * import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + * + * const auth = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerRolesClient({}, auth); + * + * // Get a role by ID + * const role = await getRole(client, 'bm-admin'); + * + * // List roles + * const roles = await listRoles(client, {size: 25, page: 0}); + * + * // List roles filtered by target type + * const userRoles = await listRoles(client, {size: 25, page: 0, roleTargetType: 'User'}); + * ``` + * + * ## Authentication + * + * Role operations require OAuth authentication with appropriate Account Manager permissions. + * + * @module operations/roles + */ +export {getRole, listRoles} from '../../clients/am-roles-api.js'; +export type { + AccountManagerRolesClient, + AccountManagerRole, + RoleCollection, + ListRolesOptions, +} from '../../clients/am-roles-api.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/users/index.ts b/packages/b2c-tooling-sdk/src/operations/users/index.ts new file mode 100644 index 00000000..e93cc2db --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/users/index.ts @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager user management operations. + * + * This module provides high-level functions for managing users in Account Manager, + * including CRUD operations, role management, and user lifecycle operations. + * + * ## Core User Functions + * + * - {@link getUser} - Get user details by ID + * - {@link getUserByLogin} - Get user details by login (email) + * - {@link listUsers} - List users with pagination + * - {@link createUser} - Create a new user + * - {@link updateUser} - Update an existing user + * - {@link deleteUser} - Disable a user (soft delete) + * - {@link purgeUser} - Purge a disabled user (hard delete) + * - {@link resetUser} - Reset a user to INITIAL state + * + * ## Usage + * + * ```typescript + * import { + * getUserByLogin, + * listUsers, + * createUser, + * grantRole, + * } from '@salesforce/b2c-tooling-sdk/operations/users'; + * import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; + * import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + * + * const auth = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerClient({}, auth); + * + * // Get a user by login + * const user = await getUserByLogin(client, 'user@example.com'); + * + * // List users + * const users = await listUsers(client, { size: 25, page: 0 }); + * + * // Create a new user + * const newUser = await createUser(client, { + * mail: 'newuser@example.com', + * firstName: 'John', + * lastName: 'Doe', + * organizations: ['org-id'], + * primaryOrganization: 'org-id', + * }); + * ``` + * + * ## Authentication + * + * User operations require OAuth authentication with appropriate Account Manager permissions. + * + * @module operations/users + */ +import type {AccountManagerClient, AccountManagerUser, UserCreate, UserUpdate} from '../../clients/am-users-api.js'; +import { + getUser, + listUsers, + createUser as createUserApi, + updateUser as updateUserApi, + deleteUser, + purgeUser, + resetUser, + findUserByLogin, +} from '../../clients/am-users-api.js'; + +/** + * Options for creating a user. + */ +export interface CreateUserOptions { + /** User details */ + user: UserCreate; +} + +/** + * Options for updating a user. + */ +export interface UpdateUserOptions { + /** User ID */ + userId: string; + /** Changes to apply */ + changes: UserUpdate; +} + +/** + * Options for granting a role. + */ +export interface GrantRoleOptions { + /** User ID */ + userId: string; + /** Role to grant */ + role: string; + /** Optional scope for the role (tenant IDs, comma-separated) */ + scope?: string; +} + +/** + * Options for revoking a role. + */ +export interface RevokeRoleOptions { + /** User ID */ + userId: string; + /** Role to revoke */ + role: string; + /** Optional scope to remove (if not provided, removes entire role) */ + scope?: string; +} + +// Re-export primary API operations from client +export {getUser, listUsers, deleteUser, purgeUser, resetUser}; + +/** + * Retrieves details of a user by login (email). + * This searches through paginated results to find the user. + * + * @param client - Account Manager client + * @param login - User login (email) + * @returns User details + * @throws Error if user is not found + */ +export async function getUserByLogin(client: AccountManagerClient, login: string): Promise { + const user = await findUserByLogin(client, login); + if (!user) { + throw new Error(`User ${login} not found`); + } + return user; +} + +/** + * Creates a new user. + * + * @param client - Account Manager client + * @param options - Create options (user details) + * @returns Created user + */ +export async function createUser( + client: AccountManagerClient, + options: CreateUserOptions, +): Promise { + return createUserApi(client, options.user); +} + +/** + * Updates an existing user. + * + * @param client - Account Manager client + * @param options - Update options (userId, changes) + * @returns Updated user + */ +export async function updateUser( + client: AccountManagerClient, + options: UpdateUserOptions, +): Promise { + return updateUserApi(client, options.userId, options.changes); +} + +/** + * Grants a role to a user, optionally with scope. + * This updates the user's roles and roleTenantFilter. + * + * @param client - Account Manager client + * @param options - Grant options (userId, role, optional scope) + * @returns Updated user + */ +export async function grantRole(client: AccountManagerClient, options: GrantRoleOptions): Promise { + // First get the current user + const user = await getUser(client, options.userId); + + // Build updated roles + const currentRoles = Array.isArray(user.roles) + ? user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')) + : []; + const updatedRoles = currentRoles.includes(options.role) ? currentRoles : [...currentRoles, options.role]; + + // Build updated roleTenantFilter + let roleTenantFilter = user.roleTenantFilter || ''; + if (options.scope) { + const scopes = options.scope.split(','); + // Parse existing filter + const filters = roleTenantFilter.split(';').filter(Boolean); + const filterMap = new Map(); + for (const filter of filters) { + const [role, tenants] = filter.split(':'); + if (tenants) { + filterMap.set(role, tenants.split(',')); + } + } + // Add new scopes + const existingScopes = filterMap.get(options.role) || []; + const allScopes = [...new Set([...existingScopes, ...scopes])]; + filterMap.set(options.role, allScopes); + // Rebuild filter string + roleTenantFilter = Array.from(filterMap.entries()) + .map(([role, tenants]) => `${role}:${tenants.join(',')}`) + .join(';'); + } + + return updateUser(client, { + userId: options.userId, + changes: { + roles: updatedRoles, + roleTenantFilter: roleTenantFilter || undefined, + }, + }); +} + +/** + * Revokes a role from a user, optionally removing specific scope. + * + * @param client - Account Manager client + * @param options - Revoke options (userId, role, optional scope) + * @returns Updated user + */ +export async function revokeRole( + client: AccountManagerClient, + options: RevokeRoleOptions, +): Promise { + // First get the current user + const user = await getUser(client, options.userId); + + // Build updated roles + const currentRoles = Array.isArray(user.roles) + ? user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')) + : []; + let updatedRoles = currentRoles; + + // Build updated roleTenantFilter + let roleTenantFilter = user.roleTenantFilter || ''; + + if (!options.scope) { + // Remove entire role + updatedRoles = currentRoles.filter((r) => r !== options.role); + // Remove all scopes for this role + const filters = roleTenantFilter.split(';').filter(Boolean); + roleTenantFilter = filters.filter((f) => !f.startsWith(`${options.role}:`)).join(';'); + } else { + // Remove specific scope + const scopes = options.scope.split(','); + const filters = roleTenantFilter.split(';').filter(Boolean); + const filterMap = new Map(); + for (const filter of filters) { + const [role, tenants] = filter.split(':'); + if (tenants) { + filterMap.set(role, tenants.split(',')); + } + } + const existingScopes = filterMap.get(options.role) || []; + const updatedScopes = existingScopes.filter((s) => !scopes.includes(s)); + if (updatedScopes.length > 0) { + filterMap.set(options.role, updatedScopes); + } else { + filterMap.delete(options.role); + } + roleTenantFilter = Array.from(filterMap.entries()) + .map(([role, tenants]) => `${role}:${tenants.join(',')}`) + .join(';'); + } + + return updateUser(client, { + userId: options.userId, + changes: { + roles: updatedRoles.length > 0 ? updatedRoles : undefined, + roleTenantFilter: roleTenantFilter || undefined, + }, + }); +} + +// Re-export types for convenience +export type {AccountManagerUser, UserCreate, UserUpdate, UserCollection} from '../../clients/am-users-api.js'; diff --git a/packages/b2c-tooling-sdk/test/cli/role-command.test.ts b/packages/b2c-tooling-sdk/test/cli/role-command.test.ts new file mode 100644 index 00000000..acf9030f --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/role-command.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import {RoleCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; +import {stubParse} from '../helpers/stub-parse.js'; + +// Create a test command class +class TestRoleCommand extends RoleCommand { + static id = 'test:role'; + static description = 'Test role command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testAccountManagerRolesClient() { + return this.accountManagerRolesClient; + } +} + +describe('cli/role-command', () => { + let config: Config; + let command: TestRoleCommand; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + command = new TestRoleCommand([], config); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('accountManagerRolesClient', () => { + it('should create account manager roles client', async () => { + stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); + + await command.init(); + const client = command.testAccountManagerRolesClient(); + + expect(client).to.exist; + }); + + it('should use OAuth credentials from config', async () => { + stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); + + await command.init(); + const client = command.testAccountManagerRolesClient(); + + expect(client).to.exist; + // Client should be created with OAuth authentication + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/user-command.test.ts b/packages/b2c-tooling-sdk/test/cli/user-command.test.ts new file mode 100644 index 00000000..50993c95 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/user-command.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; +import {stubParse} from '../helpers/stub-parse.js'; + +// Create a test command class +class TestUserCommand extends UserCommand { + static id = 'test:user'; + static description = 'Test user command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testAccountManagerClient() { + return this.accountManagerClient; + } +} + +describe('cli/user-command', () => { + let config: Config; + let command: TestUserCommand; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + command = new TestUserCommand([], config); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('accountManagerClient', () => { + it('should create account manager client', async () => { + stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); + + await command.init(); + const client = command.testAccountManagerClient(); + + expect(client).to.exist; + }); + + it('should use OAuth credentials from config', async () => { + stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); + + await command.init(); + const client = command.testAccountManagerClient(); + + expect(client).to.exist; + // Client should be created with OAuth authentication + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts new file mode 100644 index 00000000..964aa57e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createAccountManagerRolesClient, getRole, listRoles} from '../../src/clients/am-roles-api.js'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('Account Manager Roles API Client', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerRolesClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('client creation', () => { + it('should create client with default host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerRolesClient({}, auth); + expect(client).to.exist; + }); + + it('should create client with custom host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerRolesClient({hostname: 'custom.host.com'}, auth); + expect(client).to.exist; + }); + }); + + describe('getRole', () => { + it('should get role by ID', async () => { + const mockRole = { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1', 'permission2'], + }; + + server.use( + http.get(`${BASE_URL}/roles/bm-admin`, () => { + return HttpResponse.json(mockRole); + }), + ); + + const role = await getRole(client, 'bm-admin'); + + expect(role).to.deep.equal(mockRole); + expect(role.id).to.equal('bm-admin'); + }); + + it('should throw error when role not found', async () => { + server.use( + http.get(`${BASE_URL}/roles/nonexistent`, () => { + return HttpResponse.json({error: {message: 'Role not found'}}, {status: 404}); + }), + ); + + try { + await getRole(client, 'nonexistent'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Role nonexistent not found'); + } + }); + }); + + describe('listRoles', () => { + it('should list roles with pagination', async () => { + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + }, + { + id: 'bm-user', + description: 'Business Manager User', + roleEnumName: 'ECOM_USER', + scope: 'INSTANCE', + targetType: 'User', + }, + ], + }; + + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await listRoles(client, {size: 20, page: 0}); + + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].id).to.equal('bm-admin'); + }); + + it('should use default pagination', async () => { + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json({content: []}); + }), + ); + + const result = await listRoles(client); + + expect(result.content).to.be.an('array'); + }); + + it('should filter by target type', async () => { + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('roleTargetType')).to.equal('User'); + return HttpResponse.json({content: []}); + }), + ); + + const result = await listRoles(client, {roleTargetType: 'User'}); + + expect(result.content).to.be.an('array'); + }); + + it('should handle out-of-bounds page error', async () => { + server.use( + http.get(`${BASE_URL}/roles`, () => { + return HttpResponse.json( + { + errors: [{message: 'fromIndex(60) > toIndex(55)', code: 'PAGINATION_ERROR'}], + }, + {status: 400}, + ); + }), + ); + + try { + await listRoles(client, {size: 20, page: 3}); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Page 3 is out of bounds'); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts new file mode 100644 index 00000000..5792fae2 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import { + createAccountManagerClient, + getUser, + listUsers, + createUser, + updateUser, + deleteUser, + resetUser, + findUserByLogin, + mapToInternalRole, + mapFromInternalRole, +} from '../../src/clients/am-users-api.js'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('Account Manager Users API Client', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('client creation', () => { + it('should create client with default host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerClient({}, auth); + expect(client).to.exist; + }); + + it('should create client with custom host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerClient({hostname: 'custom.host.com'}, auth); + expect(client).to.exist; + }); + }); + + describe('getUser', () => { + it('should get user by ID', async () => { + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ENABLED', + }; + + server.use( + http.get(`${BASE_URL}/users/user-123`, () => { + return HttpResponse.json(mockUser); + }), + ); + + const user = await getUser(client, 'user-123'); + + expect(user).to.deep.equal(mockUser); + expect(user.mail).to.equal('user@example.com'); + }); + + it('should throw error when user not found', async () => { + server.use( + http.get(`${BASE_URL}/users/nonexistent`, () => { + return HttpResponse.json({error: {message: 'User not found'}}, {status: 404}); + }), + ); + + try { + await getUser(client, 'nonexistent'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('User nonexistent not found'); + } + }); + }); + + describe('listUsers', () => { + it('should list users with pagination', async () => { + const mockUsers = { + content: [ + {id: 'user-1', mail: 'user1@example.com', firstName: 'John', lastName: 'Doe'}, + {id: 'user-2', mail: 'user2@example.com', firstName: 'Jane', lastName: 'Smith'}, + ], + }; + + server.use( + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockUsers); + }), + ); + + const result = await listUsers(client, {size: 20, page: 0}); + + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].mail).to.equal('user1@example.com'); + }); + + it('should use default pagination', async () => { + server.use( + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json({content: []}); + }), + ); + + const result = await listUsers(client); + + expect(result.content).to.be.an('array'); + }); + + it('should handle out-of-bounds page error', async () => { + server.use( + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json( + { + errors: [{message: 'fromIndex(60) > toIndex(55)', code: 'PAGINATION_ERROR'}], + }, + {status: 400}, + ); + }), + ); + + try { + await listUsers(client, {size: 20, page: 3}); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Page 3 is out of bounds'); + } + }); + }); + + describe('createUser', () => { + it('should create a new user', async () => { + const newUser = { + mail: 'newuser@example.com', + firstName: 'New', + lastName: 'User', + organizations: ['org-123'], + primaryOrganization: 'org-123', + }; + + const createdUser = { + id: 'user-456', + ...newUser, + userState: 'INITIAL', + }; + + server.use( + http.post(`${BASE_URL}/users`, async ({request}) => { + const body = await request.json(); + expect(body).to.deep.equal(newUser); + return HttpResponse.json(createdUser, {status: 201}); + }), + ); + + const result = await createUser(client, newUser); + + expect(result).to.deep.equal(createdUser); + expect(result.mail).to.equal('newuser@example.com'); + }); + }); + + describe('updateUser', () => { + it('should update an existing user', async () => { + const userId = 'user-123'; + const updates = { + firstName: 'Updated', + lastName: 'Name', + }; + + const updatedUser = { + id: userId, + mail: 'user@example.com', + ...updates, + }; + + server.use( + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = await request.json(); + expect(body).to.deep.equal(updates); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await updateUser(client, userId, updates); + + expect(result).to.deep.equal(updatedUser); + expect(result.firstName).to.equal('Updated'); + }); + }); + + describe('deleteUser', () => { + it('should delete a user', async () => { + const userId = 'user-123'; + + server.use( + http.post(`${BASE_URL}/users/${userId}/disable`, () => { + return HttpResponse.json({}, {status: 200}); + }), + ); + + await deleteUser(client, userId); + // Should not throw + }); + }); + + describe('resetUser', () => { + it('should reset a user', async () => { + const userId = 'user-123'; + + server.use( + http.post(`${BASE_URL}/users/${userId}/reset`, () => { + return HttpResponse.json({}, {status: 200}); + }), + ); + + await resetUser(client, userId); + // Should not throw + }); + }); + + describe('findUserByLogin', () => { + it('should find user by email', async () => { + const mockUsers = { + content: [{id: 'user-123', mail: 'user@example.com', firstName: 'John', lastName: 'Doe'}], + }; + + server.use( + http.get(`${BASE_URL}/users`, ({request}) => { + const url = new URL(request.url); + // findUserByLogin searches through paginated results + expect(url.searchParams.get('size')).to.equal('100'); + return HttpResponse.json(mockUsers); + }), + ); + + const user = await findUserByLogin(client, 'user@example.com'); + + expect(user).to.deep.equal(mockUsers.content[0]); + }); + + it('should return undefined when user not found', async () => { + server.use( + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: []}); + }), + ); + + const result = await findUserByLogin(client, 'nonexistent@example.com'); + + expect(result).to.be.undefined; + }); + + it('should search through multiple pages', async () => { + // Create 100 users for page 1 (full page size) + const page1Users = { + content: Array.from({length: 100}, (_, i) => ({ + id: `user-${i + 1}`, + mail: `user${i + 1}@example.com`, + })), + }; + + const page2Users = { + content: [{id: 'user-123', mail: 'user@example.com', firstName: 'John', lastName: 'Doe'}], + }; + + let callCount = 0; + server.use( + http.get(`${BASE_URL}/users`, ({request}) => { + callCount++; + const url = new URL(request.url); + const page = Number(url.searchParams.get('page') || '0'); + if (page === 0) { + return HttpResponse.json(page1Users); + } + return HttpResponse.json(page2Users); + }), + ); + + const user = await findUserByLogin(client, 'user@example.com'); + + expect(user).to.deep.equal(page2Users.content[0]); + expect(callCount).to.equal(2); + }); + }); + + describe('role mapping', () => { + it('should map role name to internal role ID', () => { + expect(mapToInternalRole('bm-admin')).to.equal('ECOM_ADMIN'); + expect(mapToInternalRole('bm-user')).to.equal('ECOM_USER'); + expect(mapToInternalRole('custom-role')).to.equal('CUSTOM_ROLE'); + }); + + it('should map internal role ID to role name', () => { + expect(mapFromInternalRole('bm-admin')).to.equal('bm-admin'); + expect(mapFromInternalRole('bm-user')).to.equal('bm-user'); + expect(mapFromInternalRole('CUSTOM_ROLE')).to.equal('custom-role'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts b/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts new file mode 100644 index 00000000..3114b887 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {getRole, listRoles} from '../../../src/operations/roles/index.js'; +import {createAccountManagerRolesClient} from '../../../src/clients/am-roles-api.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('operations/roles', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerRolesClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('getRole', () => { + it('should get role by ID', async () => { + const mockRole = { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + scope: 'INSTANCE', + targetType: 'User', + twoFAEnabled: false, + permissions: ['permission1', 'permission2'], + }; + + server.use( + http.get(`${BASE_URL}/roles/bm-admin`, () => { + return HttpResponse.json(mockRole); + }), + ); + + const result = await getRole(client, 'bm-admin'); + + expect(result).to.deep.equal(mockRole); + }); + }); + + describe('listRoles', () => { + it('should list roles with default options', async () => { + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + roleEnumName: 'ECOM_ADMIN', + }, + ], + }; + + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('20'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await listRoles(client); + + expect(result).to.deep.equal(mockRoles); + }); + + it('should list roles with pagination', async () => { + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + }, + ], + }; + + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('50'); + expect(url.searchParams.get('page')).to.equal('1'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await listRoles(client, {size: 50, page: 1}); + + expect(result).to.deep.equal(mockRoles); + }); + + it('should list roles with target type filter', async () => { + const mockRoles = { + content: [ + { + id: 'bm-admin', + description: 'Business Manager Administrator', + targetType: 'User', + }, + ], + }; + + server.use( + http.get(`${BASE_URL}/roles`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('roleTargetType')).to.equal('User'); + return HttpResponse.json(mockRoles); + }), + ); + + const result = await listRoles(client, {roleTargetType: 'User'}); + + expect(result).to.deep.equal(mockRoles); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/users/index.test.ts b/packages/b2c-tooling-sdk/test/operations/users/index.test.ts new file mode 100644 index 00000000..7cccaeb9 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/users/index.test.ts @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {getUserByLogin, createUser, updateUser, grantRole, revokeRole} from '../../../src/operations/users/index.js'; +import {createAccountManagerClient} from '../../../src/clients/am-users-api.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('operations/users', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('getUserByLogin', () => { + it('should get user by login', async () => { + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + }; + + server.use( + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: [mockUser]}); + }), + ); + + const result = await getUserByLogin(client, 'user@example.com'); + + expect(result).to.deep.equal(mockUser); + }); + + it('should throw error when user not found', async () => { + server.use( + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await getUserByLogin(client, 'nonexistent@example.com'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('User nonexistent@example.com not found'); + } + }); + }); + + describe('createUser', () => { + it('should create a user', async () => { + const userData = { + mail: 'newuser@example.com', + firstName: 'New', + lastName: 'User', + organizations: ['org-123'], + primaryOrganization: 'org-123', + }; + + const createdUser = { + id: 'user-456', + ...userData, + }; + + server.use( + http.post(`${BASE_URL}/users`, async ({request}) => { + const body = (await request.json()) as typeof userData; + expect(body).to.deep.equal(userData); + return HttpResponse.json(createdUser, {status: 201}); + }), + ); + + const result = await createUser(client, {user: userData}); + + expect(result).to.deep.equal(createdUser); + }); + }); + + describe('updateUser', () => { + it('should update a user', async () => { + const userId = 'user-123'; + const changes = { + firstName: 'Updated', + lastName: 'Name', + }; + + const updatedUser = { + id: userId, + mail: 'user@example.com', + ...changes, + }; + + server.use( + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as typeof changes; + expect(body).to.deep.equal(changes); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await updateUser(client, {userId, changes}); + + expect(result).to.deep.equal(updatedUser); + }); + }); + + describe('grantRole', () => { + it('should grant a role without scope', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: [], + }; + + const updatedUser = { + ...mockUser, + roles: ['bm-admin'], + }; + + let getUserCallCount = 0; + let updateUserCallCount = 0; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + getUserCallCount++; + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + updateUserCallCount++; + const body = (await request.json()) as {roles?: string[]}; + expect(body.roles).to.include('bm-admin'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await grantRole(client, {userId, role: 'bm-admin'}); + + expect(result).to.deep.equal(updatedUser); + expect(getUserCallCount).to.equal(1); + expect(updateUserCallCount).to.equal(1); + }); + + it('should grant a role with scope', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: [], + roleTenantFilter: '', + }; + + const updatedUser = { + ...mockUser, + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('bm-admin:tenant1,tenant2'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await grantRole(client, { + userId, + role: 'bm-admin', + scope: 'tenant1,tenant2', + }); + + expect(result).to.deep.equal(updatedUser); + }); + + it('should add scope to existing role', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1', + }; + + const updatedUser = { + ...mockUser, + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('tenant1'); + expect(body.roleTenantFilter).to.include('tenant2'); + return HttpResponse.json(updatedUser); + }), + ); + + await grantRole(client, { + userId, + role: 'bm-admin', + scope: 'tenant2', + }); + }); + }); + + describe('revokeRole', () => { + it('should revoke entire role', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: ['bm-admin', 'bm-user'], + roleTenantFilter: 'bm-admin:tenant1', + }; + + const updatedUser = { + ...mockUser, + roles: ['bm-user'], + roleTenantFilter: '', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roles?: string[]}; + expect(body.roles).to.not.include('bm-admin'); + expect(body.roles).to.include('bm-user'); + return HttpResponse.json(updatedUser); + }), + ); + + const result = await revokeRole(client, {userId, role: 'bm-admin'}); + + expect(result).to.deep.equal(updatedUser); + }); + + it('should revoke specific scope from role', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1,tenant2', + }; + + const updatedUser = { + ...mockUser, + roleTenantFilter: 'bm-admin:tenant1', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.include('tenant1'); + expect(body.roleTenantFilter).to.not.include('tenant2'); + return HttpResponse.json(updatedUser); + }), + ); + + await revokeRole(client, { + userId, + role: 'bm-admin', + scope: 'tenant2', + }); + }); + + it('should remove role when all scopes are revoked', async () => { + const userId = 'user-123'; + const mockUser = { + id: userId, + mail: 'user@example.com', + roles: ['bm-admin'], + roleTenantFilter: 'bm-admin:tenant1', + }; + + const updatedUser = { + ...mockUser, + roles: [], + roleTenantFilter: '', + }; + + server.use( + http.get(`${BASE_URL}/users/${userId}`, () => { + return HttpResponse.json(mockUser); + }), + http.put(`${BASE_URL}/users/${userId}`, async ({request}) => { + const body = (await request.json()) as {roleTenantFilter?: string}; + expect(body.roleTenantFilter).to.be.undefined; + return HttpResponse.json(updatedUser); + }), + ); + + await revokeRole(client, { + userId, + role: 'bm-admin', + scope: 'tenant1', + }); + }); + }); +}); From 004f7f47dcc4e9c0b182f946e9ac8c4c99413f1a Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Thu, 22 Jan 2026 00:28:06 +0530 Subject: [PATCH 02/15] @W-20893693: Add AM topic with user and role command --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af0e28e8..7a0e99d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: mocha: specifier: ^10 version: 10.8.2 + msw: + specifier: ^2.0.0 + version: 2.12.4(@types/node@18.19.130)(typescript@5.9.3) oclif: specifier: ^4 version: 4.22.44(@types/node@18.19.130) From 9aa8272e3a25130f1d078bb6d1332c7d4a547eea Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Thu, 22 Jan 2026 01:01:50 +0530 Subject: [PATCH 03/15] @W-20893693: Add AM topic with user and role command --- packages/b2c-tooling-sdk/test/cli/role-command.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/b2c-tooling-sdk/test/cli/role-command.test.ts b/packages/b2c-tooling-sdk/test/cli/role-command.test.ts index acf9030f..8fc9add7 100644 --- a/packages/b2c-tooling-sdk/test/cli/role-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/role-command.test.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ - import {expect} from 'chai'; import sinon from 'sinon'; import {Config} from '@oclif/core'; From 64be54b0ab4eaefb777c113f65aeb985e63022ba Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Thu, 22 Jan 2026 02:01:45 +0530 Subject: [PATCH 04/15] @W-20893693: Add AM topic with user and role command --- packages/b2c-tooling-sdk/test/cli/role-command.test.ts | 2 +- packages/b2c-tooling-sdk/test/cli/user-command.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/b2c-tooling-sdk/test/cli/role-command.test.ts b/packages/b2c-tooling-sdk/test/cli/role-command.test.ts index 8fc9add7..5f1f3bcf 100644 --- a/packages/b2c-tooling-sdk/test/cli/role-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/role-command.test.ts @@ -7,7 +7,7 @@ import {expect} from 'chai'; import sinon from 'sinon'; import {Config} from '@oclif/core'; import {RoleCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import {stubParse} from '../helpers/stub-parse.js'; // Create a test command class diff --git a/packages/b2c-tooling-sdk/test/cli/user-command.test.ts b/packages/b2c-tooling-sdk/test/cli/user-command.test.ts index 50993c95..1b1e843a 100644 --- a/packages/b2c-tooling-sdk/test/cli/user-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/user-command.test.ts @@ -8,7 +8,7 @@ import {expect} from 'chai'; import sinon from 'sinon'; import {Config} from '@oclif/core'; import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {isolateConfig, restoreConfig} from '../helpers/config-isolation.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import {stubParse} from '../helpers/stub-parse.js'; // Create a test command class From 8ce259a6176db6c2fa65cced92a9a4b29e428cb3 Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Thu, 22 Jan 2026 18:40:20 +0530 Subject: [PATCH 05/15] @W-20893693: Add AM - org topic --- packages/b2c-cli/package.json | 3 + packages/b2c-cli/src/commands/org/audit.ts | 128 +++++ packages/b2c-cli/src/commands/org/get.ts | 205 ++++++++ packages/b2c-cli/src/commands/org/list.ts | 170 ++++++ .../b2c-cli/test/commands/org/audit.test.ts | 373 ++++++++++++++ .../b2c-cli/test/commands/org/get.test.ts | 284 +++++++++++ .../b2c-cli/test/commands/org/list.test.ts | 345 +++++++++++++ .../b2c-cli/test/commands/role/get.test.ts | 3 +- .../b2c-cli/test/commands/role/grant.test.ts | 3 +- .../b2c-cli/test/commands/role/list.test.ts | 3 +- .../b2c-cli/test/commands/role/revoke.test.ts | 3 +- .../b2c-cli/test/commands/user/create.test.ts | 3 +- .../b2c-cli/test/commands/user/delete.test.ts | 3 +- .../b2c-cli/test/commands/user/get.test.ts | 3 +- .../b2c-cli/test/commands/user/list.test.ts | 3 +- .../b2c-cli/test/commands/user/update.test.ts | 3 +- packages/b2c-cli/test/helpers/role.ts | 61 --- packages/b2c-cli/test/helpers/test-setup.ts | 67 +++ packages/b2c-cli/test/helpers/user.ts | 54 -- packages/b2c-tooling-sdk/package.json | 11 + packages/b2c-tooling-sdk/src/cli/index.ts | 1 + .../b2c-tooling-sdk/src/cli/org-command.ts | 43 ++ .../src/clients/am-orgs-api.ts | 268 ++++++++++ packages/b2c-tooling-sdk/src/clients/index.ts | 11 + .../src/clients/middleware-registry.ts | 3 +- packages/b2c-tooling-sdk/src/index.ts | 10 + .../src/operations/orgs/index.ts | 124 +++++ .../test/cli/org-command.test.ts | 63 +++ .../test/clients/am-orgs-api.test.ts | 482 ++++++++++++++++++ .../test/operations/orgs/index.test.ts | 273 ++++++++++ 30 files changed, 2872 insertions(+), 134 deletions(-) create mode 100644 packages/b2c-cli/src/commands/org/audit.ts create mode 100644 packages/b2c-cli/src/commands/org/get.ts create mode 100644 packages/b2c-cli/src/commands/org/list.ts create mode 100644 packages/b2c-cli/test/commands/org/audit.test.ts create mode 100644 packages/b2c-cli/test/commands/org/get.test.ts create mode 100644 packages/b2c-cli/test/commands/org/list.test.ts delete mode 100644 packages/b2c-cli/test/helpers/role.ts delete mode 100644 packages/b2c-cli/test/helpers/user.ts create mode 100644 packages/b2c-tooling-sdk/src/cli/org-command.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/am-orgs-api.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/orgs/index.ts create mode 100644 packages/b2c-tooling-sdk/test/cli/org-command.test.ts create mode 100644 packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index c1b5b748..f4d69d61 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -147,6 +147,9 @@ "role": { "description": "Manage roles for Account Manager users" }, + "org": { + "description": "Manage Account Manager organizations" + }, "slas": { "description": "Manage SLAS API clients and credentials\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/slas.html", "subtopics": { diff --git a/packages/b2c-cli/src/commands/org/audit.ts b/packages/b2c-cli/src/commands/org/audit.ts new file mode 100644 index 00000000..a7316bb3 --- /dev/null +++ b/packages/b2c-cli/src/commands/org/audit.ts @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {OrgCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {getOrg, getOrgByName, getOrgAuditLogs} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import type {AuditLogRecord, AuditLogCollection} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const year = date.getFullYear(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${month}/${day}/${year} ${hours}:${minutes}:${seconds}`; +} + +const COLUMNS: Record> = { + timestamp: { + header: 'Timestamp', + get: (r) => (r.timestamp ? formatTimestamp(r.timestamp) : '-'), + }, + authorDisplayName: { + header: 'Author', + get: (r) => r.authorDisplayName || '-', + }, + authorEmail: { + header: 'Email', + get: (r) => r.authorEmail || '-', + }, + eventType: { + header: 'Event Type', + get: (r) => r.eventType || '-', + }, + eventMessage: { + header: 'Message', + get: (r) => r.eventMessage || '-', + }, +}; + +const DEFAULT_COLUMNS = ['timestamp', 'authorDisplayName', 'authorEmail', 'eventType', 'eventMessage']; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to get audit logs for an Account Manager organization. + */ +export default class OrgAudit extends OrgCommand { + static args = { + org: Args.string({ + description: 'Organization ID or name', + required: true, + }), + }; + + static description = t('commands.org.audit.description', 'Get audit logs for an Account Manager organization'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> org-id', + '<%= config.bin %> <%= command.id %> "My Organization"', + '<%= config.bin %> <%= command.id %> org-id --json', + ]; + + static flags = { + columns: Flags.string({ + description: 'Comma-separated list of columns to display', + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show extended columns', + }), + }; + + async run(): Promise { + const {org} = this.args; + + this.log(t('commands.org.audit.fetching', 'Fetching organization {{org}}...', {org})); + + // Get organization first (by ID or name) + let organization; + try { + organization = await getOrg(this.accountManagerOrgsClient, org); + } catch (error) { + // If not found by ID, try by name + if (error instanceof Error && error.message.includes('not found')) { + try { + organization = await getOrgByName(this.accountManagerOrgsClient, org); + } catch { + throw new Error(t('commands.org.audit.orgNotFound', 'Organization {{org}} not found', {org})); + } + } else { + throw error; + } + } + + this.log(t('commands.org.audit.fetchingLogs', 'Fetching audit logs...')); + + const result = await getOrgAuditLogs(this.accountManagerOrgsClient, organization.id); + + if (this.jsonEnabled()) { + return result; + } + + if (!result.content || result.content.length === 0) { + this.log(t('commands.org.audit.noResults', 'No audit records found')); + return result; + } + + // Determine columns to display + let columnsToShow = DEFAULT_COLUMNS; + if (this.flags.columns) { + columnsToShow = this.flags.columns.split(',').map((c) => c.trim()); + } else if (this.flags.extended) { + columnsToShow = Object.keys(COLUMNS); + } + + tableRenderer.render(result.content, columnsToShow); + + return result; + } +} diff --git a/packages/b2c-cli/src/commands/org/get.ts b/packages/b2c-cli/src/commands/org/get.ts new file mode 100644 index 00000000..c57d2532 --- /dev/null +++ b/packages/b2c-cli/src/commands/org/get.ts @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {OrgCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getOrg, getOrgByName} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import type {AccountManagerOrganization} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +/** + * Command to get details of a single Account Manager organization. + */ +export default class OrgGet extends OrgCommand { + static args = { + org: Args.string({ + description: 'Organization ID or name', + required: true, + }), + }; + + static description = t('commands.org.get.description', 'Get details of an Account Manager organization'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> org-id', + '<%= config.bin %> <%= command.id %> "My Organization"', + '<%= config.bin %> <%= command.id %> org-id --json', + ]; + + async run(): Promise { + const {org} = this.args; + + this.log(t('commands.org.get.fetching', 'Fetching organization {{org}}...', {org})); + + // Try to get by ID first, then by name + let organization: AccountManagerOrganization; + try { + organization = await getOrg(this.accountManagerOrgsClient, org); + } catch (error) { + // If not found by ID, try by name + if (error instanceof Error && error.message.includes('not found')) { + try { + organization = await getOrgByName(this.accountManagerOrgsClient, org); + } catch (nameError) { + // Preserve the specific error message if it's already a "not found" error + if (nameError instanceof Error && nameError.message.includes('not found')) { + throw nameError; + } + throw new Error(t('commands.org.get.notFound', 'Organization {{org}} not found', {org})); + } + } else { + throw error; + } + } + + if (this.jsonEnabled()) { + return organization; + } + + this.printOrgDetails(organization); + + return organization; + } + + private printAccountIds(ui: ReturnType, org: AccountManagerOrganization): void { + const sfAccountIds = org.sfAccountIds as string[] | undefined; + if (sfAccountIds && sfAccountIds.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Account Ids', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const accountId of sfAccountIds) { + ui.div({text: ` - ${accountId}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } + + private printAllowedVerifierTypes(ui: ReturnType, org: AccountManagerOrganization): void { + if (org.allowedVerifierTypes && org.allowedVerifierTypes.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Allowed Verifier Types', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const verifierType of org.allowedVerifierTypes) { + ui.div({text: ` - ${verifierType}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } + + private printBasicFields(ui: ReturnType, org: AccountManagerOrganization): void { + ui.div({text: 'Organization Details', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields: [string, string | undefined][] = [ + ['ID', org.id], + ['Name', org.name], + ['2FA Enabled', org.twoFAEnabled ? 'Yes' : 'No'], + ['VaaS Enabled', org.vaasEnabled ? 'Yes' : 'No'], + ['SF Identity', org.sfIdentityFederation ? 'Yes' : 'No'], + ]; + + for (const [label, value] of fields) { + if (value !== undefined) { + ui.div({text: `${label}:`, width: 20, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + } + + private printContactUsers(ui: ReturnType, org: AccountManagerOrganization): void { + const contactUsers = org.contactUsers as string[] | undefined; + if (contactUsers && contactUsers.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Contact Users', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const userId of contactUsers) { + ui.div({text: ` - ${userId}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } + + private printEmailDomains(ui: ReturnType, org: AccountManagerOrganization): void { + const emailsDomains = org.emailsDomains as string[] | undefined; + if (emailsDomains && emailsDomains.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Email Domains', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const domain of emailsDomains) { + ui.div({text: ` - ${domain}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } + + private printOrgDetails(org: AccountManagerOrganization): void { + const ui = cliui({width: process.stdout.columns || 80}); + + this.printBasicFields(ui, org); + this.printContactUsers(ui, org); + this.printAllowedVerifierTypes(ui, org); + this.printAccountIds(ui, org); + this.printRealms(ui, org); + this.printEmailDomains(ui, org); + this.printTwoFARoles(ui, org); + this.printPasswordPolicy(ui, org); + + ux.stdout(ui.toString()); + } + + private printPasswordPolicy(ui: ReturnType, org: AccountManagerOrganization): void { + const passwordMinEntropy = org.passwordMinEntropy as number | undefined; + const passwordHistorySize = org.passwordHistorySize as number | undefined; + const passwordDaysExpiration = org.passwordDaysExpiration as number | undefined; + + // Only show section if at least one password policy attribute exists + if (passwordMinEntropy === undefined && passwordHistorySize === undefined && passwordDaysExpiration === undefined) { + return; + } + + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Password Policy', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + const fields: [string, string | undefined][] = [ + ['Minimum Password Length', passwordMinEntropy === undefined ? undefined : passwordMinEntropy.toString()], + ['Length of Password History', passwordHistorySize === undefined ? undefined : passwordHistorySize.toString()], + [ + 'Days Until Password Expires', + passwordDaysExpiration === undefined ? undefined : passwordDaysExpiration.toString(), + ], + ]; + + for (const [label, value] of fields) { + if (value === undefined) { + continue; + } + ui.div({text: `${label}:`, width: 30, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]}); + } + } + + private printRealms(ui: ReturnType, org: AccountManagerOrganization): void { + if (org.realms && org.realms.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: 'Realms', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + ui.div({text: org.realms.join(', '), padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + + private printTwoFARoles(ui: ReturnType, org: AccountManagerOrganization): void { + if (org.twoFARoles && org.twoFARoles.length > 0) { + ui.div({text: '', padding: [0, 0, 0, 0]}); + ui.div({text: '2FA Roles', padding: [1, 0, 0, 0]}); + ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]}); + + for (const role of org.twoFARoles) { + ui.div({text: ` - ${role}`, padding: [0, 0, 0, 0]}, {text: '', padding: [0, 0, 0, 0]}); + } + } + } +} diff --git a/packages/b2c-cli/src/commands/org/list.ts b/packages/b2c-cli/src/commands/org/list.ts new file mode 100644 index 00000000..426ac375 --- /dev/null +++ b/packages/b2c-cli/src/commands/org/list.ts @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags, Errors} from '@oclif/core'; +import {OrgCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import {listOrgs} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import type {AccountManagerOrganization, OrganizationCollection} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +const COLUMNS: Record> = { + id: { + header: 'ID', + get: (o) => o.id || '-', + }, + name: { + header: 'Name', + get: (o) => o.name || '-', + }, + realms: { + header: 'Realms', + get: (o) => (o.realms && o.realms.length > 0 ? o.realms.length.toString() : '0'), + }, + emailsDomains: { + header: 'Email Domains', + get(o) { + const domains = o.emailsDomains as string[] | undefined; + return domains && domains.length > 0 ? domains.length.toString() : '0'; + }, + }, + twoFARoles: { + header: '2FA Roles', + get: (o) => (o.twoFARoles && o.twoFARoles.length > 0 ? o.twoFARoles.length.toString() : '0'), + }, + twoFAEnabled: { + header: '2FA Enabled', + get: (o) => (o.twoFAEnabled ? 'Yes' : 'No'), + }, + allowedVerifierTypes: { + header: 'Verifier Types', + get: (o) => + o.allowedVerifierTypes && o.allowedVerifierTypes.length > 0 ? o.allowedVerifierTypes.length.toString() : '0', + }, + vaasEnabled: { + header: 'VaaS Enabled', + get: (o) => (o.vaasEnabled ? 'Yes' : 'No'), + }, + sfIdentityFederation: { + header: 'SF Identity', + get: (o) => (o.sfIdentityFederation ? 'Yes' : 'No'), + }, + passwordMinEntropy: { + header: 'Min Password Length', + get(o) { + const minEntropy = o.passwordMinEntropy as number | undefined; + return minEntropy === undefined ? '-' : minEntropy.toString(); + }, + }, +}; + +const DEFAULT_COLUMNS = [ + 'id', + 'name', + 'realms', + 'emailsDomains', + 'twoFAEnabled', + 'vaasEnabled', + 'sfIdentityFederation', + 'passwordMinEntropy', +]; + +const tableRenderer = new TableRenderer(COLUMNS); + +/** + * Command to list Account Manager organizations. + */ +export default class OrgList extends OrgCommand { + static description = t('commands.org.list.description', 'List Account Manager organizations'); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --size 50', + '<%= config.bin %> <%= command.id %> --all', + '<%= config.bin %> <%= command.id %> --extended', + '<%= config.bin %> <%= command.id %> --json', + ]; + + static flags = { + size: Flags.integer({ + char: 's', + description: 'Page size (default: 25, min: 1, max: 5000)', + }), + page: Flags.integer({ + description: 'Page number (zero-based index, default: 0, min: 0)', + }), + all: Flags.boolean({ + char: 'a', + description: 'Return all organizations (uses max page size of 5000)', + }), + columns: Flags.string({ + description: 'Comma-separated list of columns to display', + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show extended columns', + }), + }; + + async run(): Promise { + const {size = 25, page = 0, all = false} = this.flags; + + // Validate size + if (size !== undefined) { + if (size < 1) { + throw new Errors.CLIError(t('commands.org.list.errors.sizeMin', 'Size must be at least 1')); + } + if (size > 5000) { + throw new Errors.CLIError(t('commands.org.list.errors.sizeMax', 'Size cannot exceed 5000')); + } + } + + // Validate page + if (page !== undefined && page < 0) { + throw new Errors.CLIError(t('commands.org.list.errors.pageMin', 'Page must be a non-negative integer')); + } + + this.log(t('commands.org.list.fetching', 'Fetching organizations...')); + + const result = await listOrgs(this.accountManagerOrgsClient, { + size, + page, + all, + }); + + if (this.jsonEnabled()) { + return result; + } + + if (!result.content || result.content.length === 0) { + this.log(t('commands.org.list.noResults', 'No organizations found')); + return result; + } + + // Determine columns to display + let columnsToShow = DEFAULT_COLUMNS; + if (this.flags.columns) { + columnsToShow = this.flags.columns.split(',').map((c) => c.trim()); + } else if (this.flags.extended) { + columnsToShow = Object.keys(COLUMNS); + } + + tableRenderer.render(result.content, columnsToShow); + + // Show pagination hint if more pages available + const totalPages = result.totalPages ?? 0; + const currentPage = result.number ?? 0; + if (totalPages > 1 && currentPage < totalPages - 1) { + this.log( + t('commands.org.list.morePages', 'More organizations available. Use --page {{page}} to view the next page.', { + page: currentPage + 1, + }), + ); + } + + return result; + } +} diff --git a/packages/b2c-cli/test/commands/org/audit.test.ts b/packages/b2c-cli/test/commands/org/audit.test.ts new file mode 100644 index 00000000..d66cc399 --- /dev/null +++ b/packages/b2c-cli/test/commands/org/audit.test.ts @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import OrgAudit from '../../../src/commands/org/audit.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for org audit command CLI logic. + * Tests output formatting, error handling, column selection. + * SDK tests cover the actual API calls. + */ +describe('org audit', () => { + const server = setupServer(); + + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1'], + twoFAEnabled: true, + vaasEnabled: false, + sfIdentityFederation: true, + }; + + const mockAuditLogs = [ + { + timestamp: '2025-01-15T10:30:45Z', + authorDisplayName: 'John Doe', + authorEmail: 'john.doe@example.com', + eventType: 'USER_CREATED', + eventMessage: 'User created successfully', + }, + { + timestamp: '2025-01-16T14:20:30Z', + authorDisplayName: 'Jane Smith', + authorEmail: 'jane.smith@example.com', + eventType: 'USER_UPDATED', + eventMessage: 'User updated', + }, + ]; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require org as argument', () => { + expect(OrgAudit.args).to.have.property('org'); + expect(OrgAudit.args.org.required).to.be.true; + }); + + it('should have correct description', () => { + expect(OrgAudit.description).to.be.a('string'); + expect(OrgAudit.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(OrgAudit.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return audit logs in JSON mode', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].eventType).to.equal('USER_CREATED'); + expect(result.content![0].authorDisplayName).to.equal('John Doe'); + }); + + it('should return audit logs in non-JSON mode', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + (command as any).flags = {}; + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should handle empty audit logs', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: []}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.deep.equal([]); + }); + + it('should get organization by name when ID lookup fails', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'Test Organization'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/Test%20Organization`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [mockOrg]}); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should handle 404 errors with custom message', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'nonexistent-org'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/nonexistent-org`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + const errorMessage = (error as Error).message; + expect(errorMessage).to.include('not found'); + } + }); + + it('should handle column selection with --columns flag', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + (command as any).flags = {columns: 'timestamp,authorDisplayName,eventType'}; + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should handle extended columns with --extended flag', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + (command as any).flags = {extended: true}; + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: mockAuditLogs}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should format timestamps correctly', async () => { + const command = new OrgAudit([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const logsWithTimestamps = [ + { + timestamp: '2025-07-31T10:30:45Z', + authorDisplayName: 'Test User', + authorEmail: 'test@example.com', + eventType: 'TEST_EVENT', + eventMessage: 'Test message', + }, + ]; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({content: logsWithTimestamps}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.have.lengthOf(1); + expect(result.content![0].timestamp).to.equal('2025-07-31T10:30:45Z'); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/org/get.test.ts b/packages/b2c-cli/test/commands/org/get.test.ts new file mode 100644 index 00000000..2e8ae3fa --- /dev/null +++ b/packages/b2c-cli/test/commands/org/get.test.ts @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import OrgGet from '../../../src/commands/org/get.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for org get command CLI logic. + * Tests output formatting, error handling. + * SDK tests cover the actual API calls. + */ +describe('org get', () => { + const server = setupServer(); + + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1', 'realm2'], + emailsDomains: ['example.com'], + twoFARoles: ['role1', 'role2'], + twoFAEnabled: true, + allowedVerifierTypes: ['TOTP', 'SMS'], + vaasEnabled: false, + sfIdentityFederation: true, + passwordMinEntropy: 8, + passwordHistorySize: 5, + passwordDaysExpiration: 90, + contactUsers: ['user-1', 'user-2'], + sfAccountIds: ['account-1', 'account-2'], + }; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require org as argument', () => { + expect(OrgGet.args).to.have.property('org'); + expect(OrgGet.args.org.required).to.be.true; + }); + + it('should have correct description', () => { + expect(OrgGet.description).to.be.a('string'); + expect(OrgGet.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(OrgGet.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return organization data in JSON mode', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOrg); + expect(result.id).to.equal('org-123'); + expect(result.name).to.equal('Test Organization'); + }); + + it('should return organization data in non-JSON mode', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOrg); + }); + + it('should get organization by name when ID lookup fails', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'Test Organization'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/Test%20Organization`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [mockOrg]}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOrg); + expect(result.name).to.equal('Test Organization'); + }); + + it('should handle 404 errors with custom message', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'nonexistent-org'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/nonexistent-org`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + const errorMessage = (error as Error).message; + expect(errorMessage).to.include('not found'); + } + }); + + it('should handle organization with password policy attributes', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const orgWithPasswordPolicy = { + ...mockOrg, + passwordMinEntropy: 10, + passwordHistorySize: 3, + passwordDaysExpiration: 60, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(orgWithPasswordPolicy); + }), + ); + + const result = await command.run(); + + expect(result.passwordMinEntropy).to.equal(10); + expect(result.passwordHistorySize).to.equal(3); + expect(result.passwordDaysExpiration).to.equal(60); + }); + + it('should handle organization without password policy attributes', async () => { + const command = new OrgGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {org: 'org-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const orgWithoutPasswordPolicy = { + ...mockOrg, + passwordMinEntropy: undefined, + passwordHistorySize: undefined, + passwordDaysExpiration: undefined, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(orgWithoutPasswordPolicy); + }), + ); + + const result = await command.run(); + + expect(result.passwordMinEntropy).to.be.undefined; + expect(result.passwordHistorySize).to.be.undefined; + expect(result.passwordDaysExpiration).to.be.undefined; + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/org/list.test.ts b/packages/b2c-cli/test/commands/org/list.test.ts new file mode 100644 index 00000000..a4e9b20d --- /dev/null +++ b/packages/b2c-cli/test/commands/org/list.test.ts @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import OrgList from '../../../src/commands/org/list.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Unit tests for org list command CLI logic. + * Tests column selection, pagination validation, output formatting. + * SDK tests cover the actual API calls. + */ +describe('org list', () => { + const server = setupServer(); + + const mockOrgs = [ + { + id: 'org-1', + name: 'Organization 1', + realms: ['realm1', 'realm2'], + emailsDomains: ['example.com'], + twoFARoles: ['role1'], + twoFAEnabled: true, + allowedVerifierTypes: ['TOTP'], + vaasEnabled: false, + sfIdentityFederation: true, + passwordMinEntropy: 8, + }, + { + id: 'org-2', + name: 'Organization 2', + realms: ['realm3'], + emailsDomains: ['test.com', 'example.org'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: true, + sfIdentityFederation: false, + passwordMinEntropy: 12, + }, + ]; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should have correct description', () => { + expect(OrgList.description).to.be.a('string'); + expect(OrgList.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(OrgList.enableJsonFlag).to.be.true; + }); + }); + + describe('pagination validation', () => { + it('should validate size parameter - minimum', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {size: 0}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Size must be at least 1/); + } + }); + + it('should validate size parameter - maximum', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {size: 5001}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Size cannot exceed 5000/); + } + }); + + it('should validate page parameter - negative', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {page: -1}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.match(/Page must be a non-negative integer/); + } + }); + }); + + describe('output formatting', () => { + it('should return organization collection in JSON mode', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: mockOrgs, totalElements: 2, totalPages: 1, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.not.be.undefined; + expect(result.content).to.have.lengthOf(2); + expect(result.content![0].id).to.equal('org-1'); + expect(result.content![0].name).to.equal('Organization 1'); + }); + + it('should handle empty results', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: [], totalElements: 0, totalPages: 0, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result.content).to.deep.equal([]); + }); + + it('should return data in non-JSON mode', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: mockOrgs, totalElements: 2, totalPages: 1, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should use default pagination when not specified', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + let capturedPage: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + capturedPage = url.searchParams.get('page'); + return HttpResponse.json({content: [], totalElements: 0, totalPages: 0, number: 0, size: 25}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('25'); + expect(capturedPage).to.equal('0'); + }); + + it('should pass custom pagination parameters', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {size: 50, page: 2}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + let capturedPage: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + capturedPage = url.searchParams.get('page'); + return HttpResponse.json({content: [], totalElements: 0, totalPages: 0, number: 2, size: 50}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('50'); + expect(capturedPage).to.equal('2'); + }); + + it('should use max page size when --all flag is set', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {all: true}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + let capturedSize: null | string = null; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + capturedSize = url.searchParams.get('size'); + return HttpResponse.json({content: [], totalElements: 0, totalPages: 0, number: 0, size: 5000}); + }), + ); + + await command.run(); + + expect(capturedSize).to.equal('5000'); + }); + + it('should handle column selection with --columns flag', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {columns: 'id,name,realms'}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: mockOrgs, totalElements: 2, totalPages: 1, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + + it('should handle extended columns with --extended flag', async () => { + const command = new OrgList([], {} as any); + (command as any).flags = {extended: true}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json({content: mockOrgs, totalElements: 2, totalPages: 1, number: 0, size: 25}); + }), + ); + + const result = await command.run(); + + expect(result).to.have.property('content'); + expect(result.content).to.have.lengthOf(2); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/role/get.test.ts b/packages/b2c-cli/test/commands/role/get.test.ts index e36e2aff..26e160bb 100644 --- a/packages/b2c-cli/test/commands/role/get.test.ts +++ b/packages/b2c-cli/test/commands/role/get.test.ts @@ -7,9 +7,8 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -/* eslint-disable @typescript-eslint/no-explicit-any */ import RoleGet from '../../../src/commands/role/get.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/role.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/role/grant.test.ts b/packages/b2c-cli/test/commands/role/grant.test.ts index 8727eab3..6e66c848 100644 --- a/packages/b2c-cli/test/commands/role/grant.test.ts +++ b/packages/b2c-cli/test/commands/role/grant.test.ts @@ -7,9 +7,8 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -/* eslint-disable @typescript-eslint/no-explicit-any */ import RoleGrant from '../../../src/commands/role/grant.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/role.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/role/list.test.ts b/packages/b2c-cli/test/commands/role/list.test.ts index 5df375d2..77172685 100644 --- a/packages/b2c-cli/test/commands/role/list.test.ts +++ b/packages/b2c-cli/test/commands/role/list.test.ts @@ -7,9 +7,8 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -/* eslint-disable @typescript-eslint/no-explicit-any */ import RoleList from '../../../src/commands/role/list.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/role.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/role/revoke.test.ts b/packages/b2c-cli/test/commands/role/revoke.test.ts index 5ec75b19..7a332f41 100644 --- a/packages/b2c-cli/test/commands/role/revoke.test.ts +++ b/packages/b2c-cli/test/commands/role/revoke.test.ts @@ -7,9 +7,8 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -/* eslint-disable @typescript-eslint/no-explicit-any */ import RoleRevoke from '../../../src/commands/role/revoke.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/role.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/user/create.test.ts b/packages/b2c-cli/test/commands/user/create.test.ts index b8fcb8ca..ce0f8363 100644 --- a/packages/b2c-cli/test/commands/user/create.test.ts +++ b/packages/b2c-cli/test/commands/user/create.test.ts @@ -7,9 +7,8 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -/* eslint-disable @typescript-eslint/no-explicit-any */ import UserCreate from '../../../src/commands/user/create.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/user/delete.test.ts b/packages/b2c-cli/test/commands/user/delete.test.ts index 639c33ee..27d131a2 100644 --- a/packages/b2c-cli/test/commands/user/delete.test.ts +++ b/packages/b2c-cli/test/commands/user/delete.test.ts @@ -7,9 +7,8 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -/* eslint-disable @typescript-eslint/no-explicit-any */ import UserDelete from '../../../src/commands/user/delete.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/user/get.test.ts b/packages/b2c-cli/test/commands/user/get.test.ts index 48ededdf..ee40c605 100644 --- a/packages/b2c-cli/test/commands/user/get.test.ts +++ b/packages/b2c-cli/test/commands/user/get.test.ts @@ -7,9 +7,8 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -/* eslint-disable @typescript-eslint/no-explicit-any */ import UserGet from '../../../src/commands/user/get.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/user/list.test.ts b/packages/b2c-cli/test/commands/user/list.test.ts index ee1f6a75..7216bd37 100644 --- a/packages/b2c-cli/test/commands/user/list.test.ts +++ b/packages/b2c-cli/test/commands/user/list.test.ts @@ -7,9 +7,8 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -/* eslint-disable @typescript-eslint/no-explicit-any */ import UserList from '../../../src/commands/user/list.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/user/update.test.ts b/packages/b2c-cli/test/commands/user/update.test.ts index 47f132c8..6432b514 100644 --- a/packages/b2c-cli/test/commands/user/update.test.ts +++ b/packages/b2c-cli/test/commands/user/update.test.ts @@ -7,9 +7,8 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -/* eslint-disable @typescript-eslint/no-explicit-any */ import UserUpdate from '../../../src/commands/user/update.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/user.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/helpers/role.ts b/packages/b2c-cli/test/helpers/role.ts deleted file mode 100644 index 92d3ced4..00000000 --- a/packages/b2c-cli/test/helpers/role.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export function stubCommandConfigAndLogger(command: any, accountManagerHost = 'account.test.demandware.com'): void { - Object.defineProperty(command, 'config', { - value: { - findConfigFile: () => ({ - read: () => ({}), - }), - }, - configurable: true, - }); - - Object.defineProperty(command, 'logger', { - value: {info() {}, debug() {}, warn() {}, error() {}}, - configurable: true, - }); - - Object.defineProperty(command, 'resolvedConfig', { - value: { - values: { - accountManagerHost, - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - }, - hasOAuthConfig() { - return Boolean(this.values.clientId); - }, - }, - configurable: true, - }); -} - -export function stubJsonEnabled(command: any, enabled: boolean): void { - command.jsonEnabled = () => enabled; -} - -export function stubAccountManagerRolesClient(command: any, client: any): void { - Object.defineProperty(command, 'accountManagerRolesClient', { - get: () => client, - configurable: true, - }); -} - -export function stubAccountManagerClient(command: any, client: any): void { - Object.defineProperty(command, 'accountManagerClient', { - get: () => client, - configurable: true, - }); -} - -export function makeCommandThrowOnError(command: any): void { - command.error = (msg: string) => { - throw new Error(msg); - }; -} diff --git a/packages/b2c-cli/test/helpers/test-setup.ts b/packages/b2c-cli/test/helpers/test-setup.ts index 4c7a6601..4c55b2f2 100644 --- a/packages/b2c-cli/test/helpers/test-setup.ts +++ b/packages/b2c-cli/test/helpers/test-setup.ts @@ -70,3 +70,70 @@ export async function createTestCommand Promise}>( await command.init(); return command as T; } + +/** + * Stubs command config and logger for testing. + * @param command - The command instance to stub + * @param accountManagerHost - Account Manager hostname (default: 'account.test.demandware.com') + */ +export function stubCommandConfigAndLogger(command: any, accountManagerHost = 'account.test.demandware.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); + + Object.defineProperty(command, 'resolvedConfig', { + value: { + values: { + accountManagerHost, + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + hasOAuthConfig() { + return Boolean(this.values.clientId); + }, + }, + configurable: true, + }); +} + +/** + * Stubs the JSON enabled flag for a command. + * @param command - The command instance to stub + * @param enabled - Whether JSON mode is enabled + */ +export function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +/** + * Stubs a client property on a command. + * @param command - The command instance to stub + * @param propertyName - The name of the client property (e.g., 'accountManagerClient', 'accountManagerRolesClient', 'accountManagerOrgsClient') + * @param client - The client instance to stub + */ +export function stubClient(command: any, propertyName: string, client: any): void { + Object.defineProperty(command, propertyName, { + get: () => client, + configurable: true, + }); +} + +/** + * Makes a command throw on error instead of using oclif's error handling. + * @param command - The command instance to modify + */ +export function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} diff --git a/packages/b2c-cli/test/helpers/user.ts b/packages/b2c-cli/test/helpers/user.ts deleted file mode 100644 index a427cc10..00000000 --- a/packages/b2c-cli/test/helpers/user.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export function stubCommandConfigAndLogger(command: any, accountManagerHost = 'account.test.demandware.com'): void { - Object.defineProperty(command, 'config', { - value: { - findConfigFile: () => ({ - read: () => ({}), - }), - }, - configurable: true, - }); - - Object.defineProperty(command, 'logger', { - value: {info() {}, debug() {}, warn() {}, error() {}}, - configurable: true, - }); - - Object.defineProperty(command, 'resolvedConfig', { - value: { - values: { - accountManagerHost, - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - }, - hasOAuthConfig() { - return Boolean(this.values.clientId); - }, - }, - configurable: true, - }); -} - -export function stubJsonEnabled(command: any, enabled: boolean): void { - command.jsonEnabled = () => enabled; -} - -export function stubAccountManagerClient(command: any, client: any): void { - Object.defineProperty(command, 'accountManagerClient', { - get: () => client, - configurable: true, - }); -} - -export function makeCommandThrowOnError(command: any): void { - command.error = (msg: string) => { - throw new Error(msg); - }; -} diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 5c6e33cb..7f45fcc7 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -145,6 +145,17 @@ "default": "./dist/cjs/operations/roles/index.js" } }, + "./operations/orgs": { + "development": "./src/operations/orgs/index.ts", + "import": { + "types": "./dist/esm/operations/orgs/index.d.ts", + "default": "./dist/esm/operations/orgs/index.js" + }, + "require": { + "types": "./dist/cjs/operations/orgs/index.d.ts", + "default": "./dist/cjs/operations/orgs/index.js" + } + }, "./cli": { "development": "./src/cli/index.ts", "import": { diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 31ce0fad..503d51cb 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -101,6 +101,7 @@ export {MrtCommand} from './mrt-command.js'; export {OdsCommand} from './ods-command.js'; export {UserCommand} from './user-command.js'; export {RoleCommand} from './role-command.js'; +export {OrgCommand} from './org-command.js'; export {WebDavCommand, WEBDAV_ROOTS, VALID_ROOTS} from './webdav-command.js'; export type {WebDavRootKey} from './webdav-command.js'; diff --git a/packages/b2c-tooling-sdk/src/cli/org-command.ts b/packages/b2c-tooling-sdk/src/cli/org-command.ts new file mode 100644 index 00000000..b1880b73 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/org-command.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Command} from '@oclif/core'; +import {OAuthCommand} from './oauth-command.js'; +import {createAccountManagerOrgsClient} from '../clients/am-orgs-api.js'; +import type {AccountManagerOrgsClient} from '../clients/am-orgs-api.js'; + +/** + * Base command for Account Manager organization operations. + * + * Extends OAuthCommand with Account Manager Organizations client setup. + * + * @example + * export default class OrgList extends OrgCommand { + * async run(): Promise { + * const orgs = await this.accountManagerOrgsClient.listOrgs(); + * // ... + * } + * } + */ +export abstract class OrgCommand extends OAuthCommand { + private _accountManagerOrgsClient?: AccountManagerOrgsClient; + + /** + * Gets the Account Manager Organizations client, creating it if necessary. + */ + protected get accountManagerOrgsClient(): AccountManagerOrgsClient { + if (!this._accountManagerOrgsClient) { + this.requireOAuthCredentials(); + const authStrategy = this.getOAuthStrategy(); + this._accountManagerOrgsClient = createAccountManagerOrgsClient( + { + hostname: this.accountManagerHost, + }, + authStrategy, + ); + } + return this._accountManagerOrgsClient; + } +} diff --git a/packages/b2c-tooling-sdk/src/clients/am-orgs-api.ts b/packages/b2c-tooling-sdk/src/clients/am-orgs-api.ts new file mode 100644 index 00000000..f7670777 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/am-orgs-api.ts @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager Organizations API client for B2C Commerce. + * + * Provides a client for the Account Manager Organizations REST API using + * fetch with OAuth authentication middleware. Used for managing + * organizations in Account Manager. + * + * @module clients/am-orgs-api + */ +import type {AuthStrategy} from '../auth/types.js'; +import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; +import {getLogger} from '../logging/logger.js'; + +/** + * Account Manager Organization type. + */ +export interface AccountManagerOrganization { + id: string; + name: string; + realms: string[]; + twoFARoles: string[]; + twoFAEnabled: boolean; + allowedVerifierTypes: string[]; + vaasEnabled: boolean; + sfIdentityFederation: boolean; + [key: string]: unknown; +} + +/** + * Account Manager Organization collection response. + */ +export interface OrganizationCollection { + content: AccountManagerOrganization[]; + totalElements?: number; + totalPages?: number; + number?: number; + size?: number; + [key: string]: unknown; +} + +/** + * Account Manager audit log record. + */ +export interface AuditLogRecord { + timestamp: string; + authorDisplayName: string; + authorEmail?: string; + eventType: string; + eventMessage: string; + [key: string]: unknown; +} + +/** + * Audit log collection response. + */ +export interface AuditLogCollection { + content: AuditLogRecord[]; + totalElements?: number; + totalPages?: number; + number?: number; + size?: number; + [key: string]: unknown; +} + +/** + * Options for listing organizations. + */ +export interface ListOrgsOptions { + /** Page size (default: 25, max: 5000) */ + size?: number; + /** Page number (0-based, default: 0) */ + page?: number; + /** Return all orgs (uses max page size of 5000) */ + all?: boolean; +} + +/** + * Transforms the API organization representation to an external format. + * Removes internal properties like 'links' that should not be exposed. + * + * @param org - The original organization object + * @returns The transformed organization object + */ +function toExternalOrg(org: AccountManagerOrganization): AccountManagerOrganization { + // Create a copy to avoid mutating the original + const transformed = {...org}; + // Always delete the links property + delete transformed.links; + return transformed; +} + +/** + * Configuration for creating an Account Manager Organizations client. + */ +export interface AccountManagerOrgsClientConfig { + /** + * Account Manager hostname. + * Defaults to: account.demandware.com + */ + hostname?: string; +} + +/** + * Account Manager Organizations API client. + */ +export interface AccountManagerOrgsClient { + /** + * Get organization by ID. + */ + getOrg(orgId: string): Promise; + + /** + * Get organization by name (searches for exact or partial match). + */ + getOrgByName(name: string): Promise; + + /** + * List organizations with pagination. + */ + listOrgs(options?: ListOrgsOptions): Promise; + + /** + * Get audit logs for an organization. + */ + getOrgAuditLogs(orgId: string): Promise; +} + +/** + * Creates an Account Manager Organizations API client. + * + * @param config - Account Manager client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Organizations API client + * + * @example + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerOrgsClient({}, oauthStrategy); + * + * // List organizations + * const orgs = await client.listOrgs({ size: 25, page: 0 }); + * + * // Get organization by ID + * const org = await client.getOrg('org-id'); + */ +export function createAccountManagerOrgsClient( + config: AccountManagerOrgsClientConfig, + auth: AuthStrategy, +): AccountManagerOrgsClient { + const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const baseUrl = `https://${hostname}/dw/rest/v1`; + const logger = getLogger(); + + /** + * Makes an authenticated request to the Account Manager API. + */ + async function makeRequest(path: string, options: RequestInit = {}): Promise { + const url = `${baseUrl}${path}`; + const headers = new Headers(options.headers); + + // Add authentication header + if (auth.getAuthorizationHeader) { + const authHeader = await auth.getAuthorizationHeader(); + headers.set('Authorization', authHeader); + } + + logger.trace({url, method: options.method || 'GET'}, '[AM-ORGS] Making request'); + + const response = await fetch(url, { + ...options, + headers, + }); + + logger.trace({url, status: response.status, statusText: response.statusText}, '[AM-ORGS] Received response'); + + // Handle errors + if (response.status === 401) { + throw new Error('Authentication invalid. Please (re-)authenticate.'); + } + if (response.status === 403) { + throw new Error('Operation forbidden. Please make sure you have the permission to perform this operation.'); + } + if (response.status >= 400) { + throw new Error(`Operation failed. Error code ${response.status}`); + } + + if (!response.ok) { + throw new Error(`Request failed: ${response.statusText}`); + } + + return response.json() as Promise; + } + + return { + async getOrg(orgId: string): Promise { + logger.debug({orgId}, '[AM-ORGS] Getting organization by ID'); + try { + const org = await makeRequest(`/organizations/${orgId}`); + return toExternalOrg(org); + } catch (error) { + if (error instanceof Error && error.message.includes('Error code 404')) { + throw new Error(`Organization ${orgId} not found`); + } + throw error; + } + }, + + async getOrgByName(name: string): Promise { + logger.debug({name}, '[AM-ORGS] Getting organization by name'); + const encodedName = encodeURIComponent(name); + let result: OrganizationCollection; + try { + result = await makeRequest( + `/organizations/search/findByName?startsWith=${encodedName}&ignoreCase=false`, + ); + } catch (error) { + if (error instanceof Error && error.message.includes('Error code 404')) { + throw new Error(`Organization ${name} not found`); + } + throw error; + } + + if (result.content.length === 0) { + throw new Error(`Organization ${name} not found`); + } + + if (result.content.length > 1) { + // Attempt to find exact match + const exactMatch = result.content.find((org) => org.name === name); + if (exactMatch) { + return toExternalOrg(exactMatch); + } + throw new Error(`Organization name "${name}" is ambiguous. Multiple organizations found.`); + } + + return toExternalOrg(result.content[0]); + }, + + async listOrgs(options: ListOrgsOptions = {}): Promise { + const {size = 25, page = 0, all = false} = options; + const pageSize = all ? 5000 : size; + + logger.debug({size: pageSize, page}, '[AM-ORGS] Listing organizations'); + + const result = await makeRequest(`/organizations?page=${page}&size=${pageSize}`); + + // Remove links from all organizations in the collection + return { + ...result, + content: result.content.map((org) => toExternalOrg(org)), + }; + }, + + async getOrgAuditLogs(orgId: string): Promise { + logger.debug({orgId}, '[AM-ORGS] Getting audit logs for organization'); + const logs = await makeRequest(`/organizations/${orgId}/audit-log-records`); + return logs; + }, + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 27dc8378..b6066a2c 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -247,6 +247,17 @@ export type { components as AccountManagerRolesComponents, } from './am-roles-api.js'; +export {createAccountManagerOrgsClient} from './am-orgs-api.js'; +export type { + AccountManagerOrgsClient, + AccountManagerOrgsClientConfig, + AccountManagerOrganization, + OrganizationCollection, + AuditLogRecord, + AuditLogCollection, + ListOrgsOptions, +} from './am-orgs-api.js'; + export {createCdnZonesClient, CDN_ZONES_READ_SCOPES, CDN_ZONES_RW_SCOPES} from './cdn-zones.js'; export type { CdnZonesClient, diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index dfcd365d..e0e36cad 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -55,7 +55,8 @@ export type HttpClientType = | 'cdn-zones' | 'webdav' | 'am-users-api' - | 'am-roles-api'; + | 'am-roles-api' + | 'am-orgs-api'; /** * Middleware interface compatible with openapi-fetch. diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 1e736f8f..d1db8f2d 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -124,6 +124,13 @@ export type { ListRolesOptions, AccountManagerRolesPaths, AccountManagerRolesComponents, + AccountManagerOrgsClient, + AccountManagerOrgsClientConfig, + AccountManagerOrganization, + OrganizationCollection, + AuditLogRecord, + AuditLogCollection, + ListOrgsOptions, CdnZonesClient, CdnZonesClientConfig, CdnZonesClientOptions, @@ -242,6 +249,9 @@ export { // Operations - Roles export {getRole, listRoles} from './operations/roles/index.js'; +// Operations - Organizations +export {getOrg, getOrgByName, listOrgs, getOrgAuditLogs} from './operations/orgs/index.js'; + // Defaults export {DEFAULT_ACCOUNT_MANAGER_HOST, DEFAULT_ODS_HOST} from './defaults.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/orgs/index.ts b/packages/b2c-tooling-sdk/src/operations/orgs/index.ts new file mode 100644 index 00000000..8ddf28d0 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/orgs/index.ts @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager organization management operations. + * + * This module provides high-level functions for managing organizations in Account Manager, + * including retrieving organization details and audit logs. + * + * ## Core Organization Functions + * + * - {@link getOrg} - Get organization details by ID + * - {@link getOrgByName} - Get organization details by name + * - {@link listOrgs} - List organizations with pagination + * - {@link getOrgAuditLogs} - Get audit logs for an organization + * + * ## Usage + * + * ```typescript + * import { + * getOrg, + * getOrgByName, + * listOrgs, + * getOrgAuditLogs, + * } from '@salesforce/b2c-tooling-sdk/operations/orgs'; + * import {createAccountManagerOrgsClient} from '@salesforce/b2c-tooling-sdk/clients'; + * import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + * + * const auth = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerOrgsClient({}, auth); + * + * // Get an organization by ID + * const org = await getOrg(client, 'org-id'); + * + * // Get an organization by name + * const org = await getOrgByName(client, 'My Organization'); + * + * // List organizations + * const orgs = await listOrgs(client, {size: 25, page: 0}); + * + * // Get audit logs + * const logs = await getOrgAuditLogs(client, 'org-id'); + * ``` + * + * ## Authentication + * + * Organization operations require OAuth authentication with appropriate Account Manager permissions. + * + * @module operations/orgs + */ +import type { + AccountManagerOrgsClient, + AccountManagerOrganization, + OrganizationCollection, + AuditLogCollection, + ListOrgsOptions, +} from '../../clients/am-orgs-api.js'; + +// Re-export types +export type { + AccountManagerOrgsClient, + AccountManagerOrganization, + OrganizationCollection, + AuditLogCollection, + ListOrgsOptions, +} from '../../clients/am-orgs-api.js'; + +/** + * Gets an organization by ID. + * + * @param client - Account Manager Organizations client + * @param orgId - Organization ID + * @returns Organization details + * @throws Error if organization is not found + */ +export async function getOrg(client: AccountManagerOrgsClient, orgId: string): Promise { + return client.getOrg(orgId); +} + +/** + * Gets an organization by name (searches for exact or partial match). + * + * @param client - Account Manager Organizations client + * @param name - Organization name + * @returns Organization details + * @throws Error if organization is not found or ambiguous + */ +export async function getOrgByName( + client: AccountManagerOrgsClient, + name: string, +): Promise { + return client.getOrgByName(name); +} + +/** + * Lists organizations with pagination. + * + * @param client - Account Manager Organizations client + * @param options - List options (size, page, all) + * @returns Organization collection + */ +export async function listOrgs( + client: AccountManagerOrgsClient, + options?: ListOrgsOptions, +): Promise { + return client.listOrgs(options); +} + +/** + * Gets audit logs for an organization. + * + * @param client - Account Manager Organizations client + * @param orgId - Organization ID + * @returns Audit log collection + */ +export async function getOrgAuditLogs(client: AccountManagerOrgsClient, orgId: string): Promise { + return client.getOrgAuditLogs(orgId); +} diff --git a/packages/b2c-tooling-sdk/test/cli/org-command.test.ts b/packages/b2c-tooling-sdk/test/cli/org-command.test.ts new file mode 100644 index 00000000..93f038df --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/org-command.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import {OrgCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../helpers/stub-parse.js'; + +// Create a test command class +class TestOrgCommand extends OrgCommand { + static id = 'test:org'; + static description = 'Test org command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testAccountManagerOrgsClient() { + return this.accountManagerOrgsClient; + } +} + +describe('cli/org-command', () => { + let config: Config; + let command: TestOrgCommand; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + command = new TestOrgCommand([], config); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + describe('accountManagerOrgsClient', () => { + it('should create account manager orgs client', async () => { + stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); + + await command.init(); + const client = command.testAccountManagerOrgsClient(); + + expect(client).to.exist; + }); + + it('should use OAuth credentials from config', async () => { + stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); + + await command.init(); + const client = command.testAccountManagerOrgsClient(); + + expect(client).to.exist; + // Client should be created with OAuth authentication + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts new file mode 100644 index 00000000..666054de --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts @@ -0,0 +1,482 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createAccountManagerOrgsClient} from '../../src/clients/am-orgs-api.js'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('Account Manager Organizations API Client', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerOrgsClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('client creation', () => { + it('should create client with default host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerOrgsClient({}, auth); + expect(client).to.exist; + }); + + it('should create client with custom host', () => { + const auth = new MockAuthStrategy(); + const client = createAccountManagerOrgsClient({hostname: 'custom.host.com'}, auth); + expect(client).to.exist; + }); + }); + + describe('getOrg', () => { + it('should get organization by ID', async () => { + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1', 'realm2'], + twoFARoles: ['role1'], + twoFAEnabled: true, + allowedVerifierTypes: ['TOTP'], + vaasEnabled: false, + sfIdentityFederation: true, + passwordMinEntropy: 8, + links: {self: {href: '/organizations/org-123'}}, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json(mockOrg); + }), + ); + + const org = await client.getOrg('org-123'); + + expect(org).to.not.have.property('links'); + expect(org.id).to.equal('org-123'); + expect(org.name).to.equal('Test Organization'); + expect(org.realms).to.deep.equal(['realm1', 'realm2']); + }); + + it('should throw error when organization not found', async () => { + server.use( + http.get(`${BASE_URL}/organizations/nonexistent`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + ); + + try { + await client.getOrg('nonexistent'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Organization nonexistent not found'); + } + }); + + it('should handle authentication errors', async () => { + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json({error: {message: 'Unauthorized'}}, {status: 401}); + }), + ); + + try { + await client.getOrg('org-123'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Authentication invalid'); + } + }); + + it('should handle permission errors', async () => { + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json({error: {message: 'Forbidden'}}, {status: 403}); + }), + ); + + try { + await client.getOrg('org-123'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Operation forbidden'); + } + }); + }); + + describe('getOrgByName', () => { + it('should get organization by exact name match', async () => { + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + links: {self: {href: '/organizations/org-123'}}, + }; + + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('startsWith')).to.equal('Test Organization'); + expect(url.searchParams.get('ignoreCase')).to.equal('false'); + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({content: [mockOrg]}); + }), + ); + + const org = await client.getOrgByName('Test Organization'); + + expect(org).to.not.have.property('links'); + expect(org.id).to.equal('org-123'); + expect(org.name).to.equal('Test Organization'); + }); + + it('should find exact match when multiple results returned', async () => { + const exactMatch = { + id: 'org-123', + name: 'Test Organization', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + const partialMatch = { + id: 'org-456', + name: 'Test Organization Extended', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [exactMatch, partialMatch]}); + }), + ); + + const org = await client.getOrgByName('Test Organization'); + + expect(org.id).to.equal('org-123'); + expect(org.name).to.equal('Test Organization'); + }); + + it('should throw error when organization not found', async () => { + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await client.getOrgByName('Nonexistent Org'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Organization Nonexistent Org not found'); + } + }); + + it('should throw error when organization name is ambiguous', async () => { + const org1 = { + id: 'org-1', + name: 'Test Org', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + const org2 = { + id: 'org-2', + name: 'Test Org Extended', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [org1, org2]}); + }), + ); + + try { + await client.getOrgByName('Ambiguous Name'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('is ambiguous'); + } + }); + + it('should handle 404 errors from API', async () => { + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + ); + + try { + await client.getOrgByName('Test Org'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Organization Test Org not found'); + } + }); + }); + + describe('listOrgs', () => { + it('should list organizations with default pagination', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: ['realm1'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + links: {self: {href: '/organizations/org-1'}}, + }, + { + id: 'org-2', + name: 'Organization 2', + realms: ['realm2'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + links: {self: {href: '/organizations/org-2'}}, + }, + ], + totalElements: 2, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('25'); + expect(url.searchParams.get('page')).to.equal('0'); + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await client.listOrgs(); + + expect(result.content).to.have.lengthOf(2); + expect(result.content[0]).to.not.have.property('links'); + expect(result.content[1]).to.not.have.property('links'); + expect(result.content[0].id).to.equal('org-1'); + expect(result.content[1].id).to.equal('org-2'); + }); + + it('should list organizations with custom pagination', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }, + ], + totalElements: 50, + totalPages: 2, + number: 1, + size: 50, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('50'); + expect(url.searchParams.get('page')).to.equal('1'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await client.listOrgs({size: 50, page: 1}); + + expect(result.content).to.have.lengthOf(1); + expect(result.number).to.equal(1); + expect(result.size).to.equal(50); + }); + + it('should use max page size when all flag is set', async () => { + const mockOrgs = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: 5000, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('5000'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await client.listOrgs({all: true}); + + expect(result.size).to.equal(5000); + }); + + it('should remove links from all organizations in collection', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + links: {self: {href: '/organizations/org-1'}}, + }, + ], + totalElements: 1, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, () => { + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await client.listOrgs(); + + expect(result.content[0]).to.not.have.property('links'); + expect(result.content[0].id).to.equal('org-1'); + }); + }); + + describe('getOrgAuditLogs', () => { + it('should get audit logs for organization', async () => { + const mockLogs = { + content: [ + { + timestamp: '2025-01-15T10:30:45Z', + authorDisplayName: 'John Doe', + authorEmail: 'john.doe@example.com', + eventType: 'USER_CREATED', + eventMessage: 'User created successfully', + }, + { + timestamp: '2025-01-16T14:20:30Z', + authorDisplayName: 'Jane Smith', + authorEmail: 'jane.smith@example.com', + eventType: 'USER_UPDATED', + eventMessage: 'User updated', + }, + ], + totalElements: 2, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json(mockLogs); + }), + ); + + const result = await client.getOrgAuditLogs('org-123'); + + expect(result.content).to.have.lengthOf(2); + expect(result.content[0].eventType).to.equal('USER_CREATED'); + expect(result.content[0].authorDisplayName).to.equal('John Doe'); + expect(result.content[1].eventType).to.equal('USER_UPDATED'); + }); + + it('should handle empty audit logs', async () => { + const mockLogs = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json(mockLogs); + }), + ); + + const result = await client.getOrgAuditLogs('org-123'); + + expect(result.content).to.have.lengthOf(0); + expect(result.totalElements).to.equal(0); + }); + + it('should handle authentication errors', async () => { + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json({error: {message: 'Unauthorized'}}, {status: 401}); + }), + ); + + try { + await client.getOrgAuditLogs('org-123'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Authentication invalid'); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts b/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts new file mode 100644 index 00000000..a06f467d --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {getOrg, getOrgByName, listOrgs, getOrgAuditLogs} from '../../../src/operations/orgs/index.js'; +import {createAccountManagerOrgsClient} from '../../../src/clients/am-orgs-api.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; + +describe('operations/orgs', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createAccountManagerOrgsClient({hostname: TEST_HOST}, mockAuth); + }); + + describe('getOrg', () => { + it('should get organization by ID', async () => { + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1', 'realm2'], + twoFARoles: ['role1'], + twoFAEnabled: true, + allowedVerifierTypes: ['TOTP'], + vaasEnabled: false, + sfIdentityFederation: true, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123`, () => { + return HttpResponse.json(mockOrg); + }), + ); + + const result = await getOrg(client, 'org-123'); + + expect(result).to.deep.equal(mockOrg); + expect(result.id).to.equal('org-123'); + expect(result.name).to.equal('Test Organization'); + }); + + it('should throw error when organization not found', async () => { + server.use( + http.get(`${BASE_URL}/organizations/nonexistent`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + ); + + try { + await getOrg(client, 'nonexistent'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('not found'); + } + }); + }); + + describe('getOrgByName', () => { + it('should get organization by name', async () => { + const mockOrg = { + id: 'org-123', + name: 'Test Organization', + realms: ['realm1'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }; + + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: [mockOrg]}); + }), + ); + + const result = await getOrgByName(client, 'Test Organization'); + + expect(result).to.deep.equal(mockOrg); + expect(result.name).to.equal('Test Organization'); + }); + + it('should throw error when organization not found', async () => { + server.use( + http.get(`${BASE_URL}/organizations/search/findByName`, () => { + return HttpResponse.json({content: []}); + }), + ); + + try { + await getOrgByName(client, 'Nonexistent Org'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('not found'); + } + }); + }); + + describe('listOrgs', () => { + it('should list organizations with default options', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: ['realm1'], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }, + ], + totalElements: 1, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('25'); + expect(url.searchParams.get('page')).to.equal('0'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await listOrgs(client); + + expect(result).to.deep.equal(mockOrgs); + }); + + it('should list organizations with pagination', async () => { + const mockOrgs = { + content: [ + { + id: 'org-1', + name: 'Organization 1', + realms: [], + twoFARoles: [], + twoFAEnabled: false, + allowedVerifierTypes: [], + vaasEnabled: false, + sfIdentityFederation: false, + }, + ], + totalElements: 50, + totalPages: 2, + number: 1, + size: 50, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('50'); + expect(url.searchParams.get('page')).to.equal('1'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await listOrgs(client, {size: 50, page: 1}); + + expect(result).to.deep.equal(mockOrgs); + }); + + it('should list all organizations when all flag is set', async () => { + const mockOrgs = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: 5000, + }; + + server.use( + http.get(`${BASE_URL}/organizations`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('size')).to.equal('5000'); + return HttpResponse.json(mockOrgs); + }), + ); + + const result = await listOrgs(client, {all: true}); + + expect(result.size).to.equal(5000); + }); + }); + + describe('getOrgAuditLogs', () => { + it('should get audit logs for organization', async () => { + const mockLogs = { + content: [ + { + timestamp: '2025-01-15T10:30:45Z', + authorDisplayName: 'John Doe', + authorEmail: 'john.doe@example.com', + eventType: 'USER_CREATED', + eventMessage: 'User created successfully', + }, + { + timestamp: '2025-01-16T14:20:30Z', + authorDisplayName: 'Jane Smith', + authorEmail: 'jane.smith@example.com', + eventType: 'USER_UPDATED', + eventMessage: 'User updated', + }, + ], + totalElements: 2, + totalPages: 1, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json(mockLogs); + }), + ); + + const result = await getOrgAuditLogs(client, 'org-123'); + + expect(result).to.deep.equal(mockLogs); + expect(result.content).to.have.lengthOf(2); + expect(result.content[0].eventType).to.equal('USER_CREATED'); + }); + + it('should handle empty audit logs', async () => { + const mockLogs = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: 25, + }; + + server.use( + http.get(`${BASE_URL}/organizations/org-123/audit-log-records`, () => { + return HttpResponse.json(mockLogs); + }), + ); + + const result = await getOrgAuditLogs(client, 'org-123'); + + expect(result.content).to.have.lengthOf(0); + }); + }); +}); From 97a7604be57f2816a4098a506a24357b5fcd9460 Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Thu, 22 Jan 2026 18:58:17 +0530 Subject: [PATCH 06/15] @W-20893693: Adding AM - org topic --- docs/api-readme.md | 41 ++++- docs/cli/index.md | 1 + docs/cli/org.md | 246 +++++++++++++++++++++++++++++ packages/b2c-cli/README.md | 24 +++ packages/b2c-tooling-sdk/README.md | 36 +++++ 5 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 docs/cli/org.md diff --git a/docs/api-readme.md b/docs/api-readme.md index 327da4fb..43ff4919 100644 --- a/docs/api-readme.md +++ b/docs/api-readme.md @@ -242,7 +242,7 @@ const { data, error } = await instance.ocapi.PATCH('/code_versions/{code_version ## Account Manager Operations -The SDK provides operations for managing users and roles. +The SDK provides operations for managing users, roles, and organizations. ### User Management @@ -350,6 +350,45 @@ const userRoles = await listRoles(client, { }); ``` +### Organization Management + +```typescript +import { + createAccountManagerOrgsClient, + getOrg, + getOrgByName, + listOrgs, + getOrgAuditLogs, +} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +// Create Account Manager Organizations client +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerOrgsClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Get organization by ID +const org = await getOrg(client, 'org-123'); + +// Get organization by name +const orgByName = await getOrgByName(client, 'My Organization'); + +// List organizations with pagination +const orgs = await listOrgs(client, { size: 25, page: 0 }); + +// List all organizations (uses max page size of 5000) +const allOrgs = await listOrgs(client, { all: true }); + +// Get audit logs for an organization +const auditLogs = await getOrgAuditLogs(client, 'org-123'); +``` + ### Required Permissions Account Manager operations require: diff --git a/docs/cli/index.md b/docs/cli/index.md index 42a3d147..5139903a 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -47,6 +47,7 @@ These flags are available on all commands that interact with B2C instances: - [User Management](./user) - Manage Account Manager users (list, create, update, delete, reset) - [Role Management](./role) - Manage Account Manager roles and role assignments +- [Organization Management](./org) - Manage Account Manager organizations (list, view details, audit logs) ### Utilities diff --git a/docs/cli/org.md b/docs/cli/org.md new file mode 100644 index 00000000..b6591d3d --- /dev/null +++ b/docs/cli/org.md @@ -0,0 +1,246 @@ +--- +description: Commands for managing Account Manager organizations including listing, viewing details, and accessing audit logs. +--- + +# Organization Management Commands + +Commands for managing organizations in Account Manager. + +## Global Org Flags + +These flags are available on all org commands: + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (e.g., `account.demandware.com`) | + +## Authentication + +Org commands require an Account Manager API Client with OAuth authentication. + +### Required Configuration + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID for Account Manager | +| `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret for Account Manager | + +### Required Roles + +The API client must have the following role: +- `sfcc.accountmanager.user.manage` - Required for organization management operations + +### Configuration + +```bash +# Set Account Manager host +export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com + +# Set OAuth credentials +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-client-secret + +# List organizations +b2c org list +``` + +--- + +## b2c org list + +List organizations in Account Manager with pagination support. + +### Usage + +```bash +b2c org list [FLAGS] +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--page` | Page number (0-based) | `0` | +| `--size`, `-s` | Number of results per page (1-5000) | `25` | +| `--all`, `-a` | Return all organizations (uses max page size of 5000) | `false` | +| `--columns` | Comma-separated list of columns to display | Default columns | +| `--extended`, `-x` | Show all available columns | `false` | +| `--json` | Output results as JSON | `false` | + +### Default Columns + +- ID +- Name +- Realms +- Email Domains +- 2FA Enabled +- VaaS Enabled +- SF Identity +- Min Password Length + +### Extended Columns + +- 2FA Roles +- Verifier Types + +### Examples + +```bash +# List first page of organizations (default: 25 per page) +b2c org list + +# List organizations with custom page size +b2c org list --size 50 + +# Get second page of results +b2c org list --page 1 --size 25 + +# Get all organizations (uses max page size of 5000) +b2c org list --all + +# Show all columns +b2c org list --extended + +# Show only specific columns +b2c org list --columns id,name,twoFAEnabled + +# Output as JSON +b2c org list --json + +# Using environment variables +export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-client-secret +b2c org list +``` + +### Output + +Displays a table of organizations with the selected columns. If more pages are available, an info message is displayed at the end. + +### Notes + +- Page size must be between 1 and 5000 +- Page number must be a non-negative integer (0-based) +- If the requested page exceeds available data, an error is returned +- The `--all` flag uses a page size of 5000 to fetch all organizations in a single request + +--- + +## b2c org get + +Get detailed information about a specific organization. + +### Usage + +```bash +b2c org get +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `ORG` | Organization ID or name | Yes | + +### Flags + +| Flag | Description | +|------|-------------| +| `--json` | Output results as JSON | + +### Examples + +```bash +# Get organization details by ID +b2c org get org-123 + +# Get organization details by name +b2c org get "My Organization" + +# Output as JSON +b2c org get org-123 --json +``` + +### Output + +When not using `--json`, displays formatted organization information including: + +- **Organization Details**: ID, Name, 2FA Enabled, VaaS Enabled, SF Identity +- **Contact Users**: List of contact user IDs +- **Allowed Verifier Types**: List of allowed verifier types +- **Account Ids**: List of Salesforce account IDs +- **Password Policy**: Minimum Password Length, Length of Password History, Days Until Password Expires +- **Realms**: Comma-separated list of realm names +- **Email Domains**: List of allowed email domains +- **2FA Roles**: List of roles that require 2FA + +### Notes + +- Organization can be identified by ID or name +- If organization is not found, an error is returned +- Name matching is case-sensitive and requires an exact match + +--- + +## b2c org audit + +Get audit logs for an Account Manager organization. + +### Usage + +```bash +b2c org audit [FLAGS] +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `ORG` | Organization ID or name | Yes | + +### Flags + +| Flag | Description | +|------|-------------| +| `--columns` | Comma-separated list of columns to display | +| `--extended`, `-x` | Show all available columns | +| `--json` | Output results as JSON | + +### Default Columns + +- Timestamp +- Author +- Email +- Event Type +- Message + +### Examples + +```bash +# Get audit logs for an organization by ID +b2c org audit org-123 + +# Get audit logs for an organization by name +b2c org audit "My Organization" + +# Show all columns +b2c org audit org-123 --extended + +# Show only specific columns +b2c org audit org-123 --columns timestamp,eventType,eventMessage + +# Output as JSON +b2c org audit org-123 --json +``` + +### Output + +Displays a table of audit log records with the selected columns. Timestamps are formatted as `MM/DD/YYYY HH:MM:SS` for readability. + +### Notes + +- Organization can be identified by ID or name +- If organization is not found, an error is returned +- If no audit records are found, a message is displayed +- Timestamps are displayed in a human-readable format with zero-padding for consistent spacing diff --git a/packages/b2c-cli/README.md b/packages/b2c-cli/README.md index dd020eba..bfd2ccef 100644 --- a/packages/b2c-cli/README.md +++ b/packages/b2c-cli/README.md @@ -231,6 +231,30 @@ b2c role grant user@example.com --role bm-admin --scope "tenant1,tenant2" b2c role revoke user@example.com --role bm-admin ``` +### Organization Management (Account Manager) + +Manage organizations in Account Manager. + +```sh +# List organizations with pagination +b2c org list --page 0 --size 25 + +# List all organizations +b2c org list --all + +# Get organization details by ID +b2c org get org-123 + +# Get organization details by name +b2c org get "My Organization" + +# Get audit logs for an organization +b2c org audit org-123 + +# Get audit logs with extended columns +b2c org audit org-123 --extended +``` + ### Authentication Get OAuth tokens for scripting. diff --git a/packages/b2c-tooling-sdk/README.md b/packages/b2c-tooling-sdk/README.md index 5aacf633..0b7119cf 100644 --- a/packages/b2c-tooling-sdk/README.md +++ b/packages/b2c-tooling-sdk/README.md @@ -210,6 +210,41 @@ const userRoles = await listRoles(client, { }); ``` +### Account Manager Organization Management + +```typescript +import { + getOrg, + getOrgByName, + listOrgs, + getOrgAuditLogs, +} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import {createAccountManagerOrgsClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new OAuthStrategy({ + clientId: 'your-client-id', + clientSecret: 'your-client-secret', +}); + +const client = createAccountManagerOrgsClient({}, auth); + +// Get organization by ID +const org = await getOrg(client, 'org-123'); + +// Get organization by name +const orgByName = await getOrgByName(client, 'My Organization'); + +// List organizations with pagination +const orgs = await listOrgs(client, {size: 25, page: 0}); + +// List all organizations (uses max page size of 5000) +const allOrgs = await listOrgs(client, {all: true}); + +// Get audit logs for an organization +const auditLogs = await getOrgAuditLogs(client, 'org-123'); +``` + ## Module Exports The SDK provides subpath exports for tree-shaking and organization: @@ -226,6 +261,7 @@ The SDK provides subpath exports for tree-shaking and organization: | `@salesforce/b2c-tooling-sdk/operations/sites` | Site management | | `@salesforce/b2c-tooling-sdk/operations/users` | Account Manager user management | | `@salesforce/b2c-tooling-sdk/operations/roles` | Account Manager role management | +| `@salesforce/b2c-tooling-sdk/operations/orgs` | Account Manager organization management | | `@salesforce/b2c-tooling-sdk/discovery` | Workspace type detection (PWA Kit, SFRA, etc.) | | `@salesforce/b2c-tooling-sdk/cli` | CLI utilities (BaseCommand, table rendering) | | `@salesforce/b2c-tooling-sdk/logging` | Structured logging utilities | From e03bf829f4982b147e4642c553d56ee792657802 Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Thu, 22 Jan 2026 20:10:39 +0530 Subject: [PATCH 07/15] @W-20893693: Adding AM - org topic --- packages/b2c-cli/test/commands/org/audit.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/org/get.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/org/list.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/role/get.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/role/grant.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/role/list.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/role/revoke.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/user/create.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/user/delete.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/user/get.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/user/list.test.ts | 8 ++++++++ packages/b2c-cli/test/commands/user/update.test.ts | 8 ++++++++ 12 files changed, 96 insertions(+) diff --git a/packages/b2c-cli/test/commands/org/audit.test.ts b/packages/b2c-cli/test/commands/org/audit.test.ts index d66cc399..d6273dad 100644 --- a/packages/b2c-cli/test/commands/org/audit.test.ts +++ b/packages/b2c-cli/test/commands/org/audit.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import OrgAudit from '../../../src/commands/org/audit.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -60,8 +62,14 @@ describe('org audit', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/org/get.test.ts b/packages/b2c-cli/test/commands/org/get.test.ts index 2e8ae3fa..3ab90e52 100644 --- a/packages/b2c-cli/test/commands/org/get.test.ts +++ b/packages/b2c-cli/test/commands/org/get.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import OrgGet from '../../../src/commands/org/get.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -51,8 +53,14 @@ describe('org get', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/org/list.test.ts b/packages/b2c-cli/test/commands/org/list.test.ts index a4e9b20d..3d8a8d1b 100644 --- a/packages/b2c-cli/test/commands/org/list.test.ts +++ b/packages/b2c-cli/test/commands/org/list.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import OrgList from '../../../src/commands/org/list.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -61,8 +63,14 @@ describe('org list', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/role/get.test.ts b/packages/b2c-cli/test/commands/role/get.test.ts index 26e160bb..37bd1597 100644 --- a/packages/b2c-cli/test/commands/role/get.test.ts +++ b/packages/b2c-cli/test/commands/role/get.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import RoleGet from '../../../src/commands/role/get.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -34,8 +36,14 @@ describe('role get', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/role/grant.test.ts b/packages/b2c-cli/test/commands/role/grant.test.ts index 6e66c848..d479eb96 100644 --- a/packages/b2c-cli/test/commands/role/grant.test.ts +++ b/packages/b2c-cli/test/commands/role/grant.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import RoleGrant from '../../../src/commands/role/grant.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -34,8 +36,14 @@ describe('role grant', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/role/list.test.ts b/packages/b2c-cli/test/commands/role/list.test.ts index 77172685..43d39bba 100644 --- a/packages/b2c-cli/test/commands/role/list.test.ts +++ b/packages/b2c-cli/test/commands/role/list.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import RoleList from '../../../src/commands/role/list.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -34,8 +36,14 @@ describe('role list', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/role/revoke.test.ts b/packages/b2c-cli/test/commands/role/revoke.test.ts index 7a332f41..23c3fabb 100644 --- a/packages/b2c-cli/test/commands/role/revoke.test.ts +++ b/packages/b2c-cli/test/commands/role/revoke.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import RoleRevoke from '../../../src/commands/role/revoke.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -34,8 +36,14 @@ describe('role revoke', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/user/create.test.ts b/packages/b2c-cli/test/commands/user/create.test.ts index ce0f8363..14157c13 100644 --- a/packages/b2c-cli/test/commands/user/create.test.ts +++ b/packages/b2c-cli/test/commands/user/create.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import UserCreate from '../../../src/commands/user/create.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -34,8 +36,14 @@ describe('user create', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/user/delete.test.ts b/packages/b2c-cli/test/commands/user/delete.test.ts index 27d131a2..76bb7a55 100644 --- a/packages/b2c-cli/test/commands/user/delete.test.ts +++ b/packages/b2c-cli/test/commands/user/delete.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import UserDelete from '../../../src/commands/user/delete.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -34,8 +36,14 @@ describe('user delete', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/user/get.test.ts b/packages/b2c-cli/test/commands/user/get.test.ts index ee40c605..ae3ba832 100644 --- a/packages/b2c-cli/test/commands/user/get.test.ts +++ b/packages/b2c-cli/test/commands/user/get.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import UserGet from '../../../src/commands/user/get.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -34,8 +36,14 @@ describe('user get', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/user/list.test.ts b/packages/b2c-cli/test/commands/user/list.test.ts index 7216bd37..0b159004 100644 --- a/packages/b2c-cli/test/commands/user/list.test.ts +++ b/packages/b2c-cli/test/commands/user/list.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import UserList from '../../../src/commands/user/list.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -59,8 +61,14 @@ describe('user list', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { diff --git a/packages/b2c-cli/test/commands/user/update.test.ts b/packages/b2c-cli/test/commands/user/update.test.ts index 6432b514..62cebab1 100644 --- a/packages/b2c-cli/test/commands/user/update.test.ts +++ b/packages/b2c-cli/test/commands/user/update.test.ts @@ -5,8 +5,10 @@ */ import {expect} from 'chai'; +import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import UserUpdate from '../../../src/commands/user/update.js'; import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; @@ -34,8 +36,14 @@ describe('user update', () => { server.listen({onUnhandledRequest: 'error'}); }); + beforeEach(() => { + isolateConfig(); + }); + afterEach(() => { + sinon.restore(); server.resetHandlers(); + restoreConfig(); }); after(() => { From 650aba1fa7805e8d31dbccc7586cb2543e0cb6e9 Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Thu, 22 Jan 2026 20:47:28 +0530 Subject: [PATCH 08/15] @W-20893693: Adding AM - org topic --- packages/b2c-tooling-sdk/README.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/b2c-tooling-sdk/README.md b/packages/b2c-tooling-sdk/README.md index 0b7119cf..b357d80a 100644 --- a/packages/b2c-tooling-sdk/README.md +++ b/packages/b2c-tooling-sdk/README.md @@ -213,12 +213,7 @@ const userRoles = await listRoles(client, { ### Account Manager Organization Management ```typescript -import { - getOrg, - getOrgByName, - listOrgs, - getOrgAuditLogs, -} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import {getOrg, getOrgByName, listOrgs, getOrgAuditLogs} from '@salesforce/b2c-tooling-sdk/operations/orgs'; import {createAccountManagerOrgsClient} from '@salesforce/b2c-tooling-sdk/clients'; import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; From a6128f41ba7d2a146ea5b189f3e7717b2bd398ac Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Fri, 23 Jan 2026 08:46:57 +0530 Subject: [PATCH 09/15] @W-20893693: Updating AM users client naming convention --- docs/api-readme.md | 4 +-- packages/b2c-cli/src/commands/role/grant.ts | 4 +-- packages/b2c-cli/src/commands/role/revoke.ts | 4 +-- packages/b2c-cli/src/commands/user/create.ts | 2 +- packages/b2c-cli/src/commands/user/delete.ts | 6 ++-- packages/b2c-cli/src/commands/user/get.ts | 2 +- packages/b2c-cli/src/commands/user/list.ts | 2 +- packages/b2c-cli/src/commands/user/reset.ts | 4 +-- packages/b2c-cli/src/commands/user/update.ts | 4 +-- packages/b2c-cli/test/helpers/test-setup.ts | 2 +- packages/b2c-tooling-sdk/README.md | 4 +-- .../b2c-tooling-sdk/src/cli/user-command.ts | 20 +++++------ .../src/clients/am-users-api.ts | 33 ++++++++++--------- packages/b2c-tooling-sdk/src/clients/index.ts | 6 ++-- packages/b2c-tooling-sdk/src/index.ts | 8 +++-- .../src/operations/orgs/index.ts | 1 - .../src/operations/roles/index.ts | 7 +--- .../src/operations/users/index.ts | 24 +++++++++----- .../test/cli/user-command.test.ts | 12 +++---- .../test/clients/am-users-api.test.ts | 10 +++--- .../test/operations/users/index.test.ts | 6 ++-- 21 files changed, 86 insertions(+), 79 deletions(-) diff --git a/docs/api-readme.md b/docs/api-readme.md index 43ff4919..4dc70450 100644 --- a/docs/api-readme.md +++ b/docs/api-readme.md @@ -248,7 +248,6 @@ The SDK provides operations for managing users, roles, and organizations. ```typescript import { - createAccountManagerClient, listUsers, getUserByLogin, createUser, @@ -258,6 +257,7 @@ import { grantRole, revokeRole, } from '@salesforce/b2c-tooling-sdk/operations/users'; +import {createAccountManagerUsersClient} from '@salesforce/b2c-tooling-sdk/clients'; import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; // Create Account Manager client @@ -266,7 +266,7 @@ const auth = new OAuthStrategy({ clientSecret: 'your-client-secret', }); -const client = createAccountManagerClient( +const client = createAccountManagerUsersClient( { accountManagerHost: 'account.demandware.com' }, auth, ); diff --git a/packages/b2c-cli/src/commands/role/grant.ts b/packages/b2c-cli/src/commands/role/grant.ts index 9ef666f9..0586b76e 100644 --- a/packages/b2c-cli/src/commands/role/grant.ts +++ b/packages/b2c-cli/src/commands/role/grant.ts @@ -47,7 +47,7 @@ export default class RoleGrant extends UserCommand { this.log(t('commands.role.grant.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerClient, login); + const user = await getUserByLogin(this.accountManagerUsersClient, login); if (!user.id) { this.error(t('commands.role.grant.noId', 'User does not have an ID')); @@ -60,7 +60,7 @@ export default class RoleGrant extends UserCommand { }), ); - const updatedUser = await grantRole(this.accountManagerClient, { + const updatedUser = await grantRole(this.accountManagerUsersClient, { userId: user.id, role, scope, diff --git a/packages/b2c-cli/src/commands/role/revoke.ts b/packages/b2c-cli/src/commands/role/revoke.ts index c417fb93..9c240f73 100644 --- a/packages/b2c-cli/src/commands/role/revoke.ts +++ b/packages/b2c-cli/src/commands/role/revoke.ts @@ -47,7 +47,7 @@ export default class RoleRevoke extends UserCommand { this.log(t('commands.role.revoke.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerClient, login); + const user = await getUserByLogin(this.accountManagerUsersClient, login); if (!user.id) { this.error(t('commands.role.revoke.noId', 'User does not have an ID')); @@ -60,7 +60,7 @@ export default class RoleRevoke extends UserCommand { }), ); - const updatedUser = await revokeRole(this.accountManagerClient, { + const updatedUser = await revokeRole(this.accountManagerUsersClient, { userId: user.id, role, scope, diff --git a/packages/b2c-cli/src/commands/user/create.ts b/packages/b2c-cli/src/commands/user/create.ts index 594fe42c..ef03f8ca 100644 --- a/packages/b2c-cli/src/commands/user/create.ts +++ b/packages/b2c-cli/src/commands/user/create.ts @@ -48,7 +48,7 @@ export default class UserCreate extends UserCommand { this.log(t('commands.user.create.creating', 'Creating user {{mail}} in organization {{org}}...', {mail, org})); - const user = await createUser(this.accountManagerClient, { + const user = await createUser(this.accountManagerUsersClient, { user: { mail, firstName, diff --git a/packages/b2c-cli/src/commands/user/delete.ts b/packages/b2c-cli/src/commands/user/delete.ts index 61bac341..4c0e5c1f 100644 --- a/packages/b2c-cli/src/commands/user/delete.ts +++ b/packages/b2c-cli/src/commands/user/delete.ts @@ -41,7 +41,7 @@ export default class UserDelete extends UserCommand { this.log(t('commands.user.delete.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerClient, login); + const user = await getUserByLogin(this.accountManagerUsersClient, login); if (!user.id) { this.error(t('commands.user.delete.noId', 'User does not have an ID')); @@ -53,14 +53,14 @@ export default class UserDelete extends UserCommand { login, }), ); - await purgeUser(this.accountManagerClient, user.id); + await purgeUser(this.accountManagerUsersClient, user.id); } else { this.log( t('commands.user.delete.deleting', 'Deleting user {{login}}...', { login, }), ); - await deleteUser(this.accountManagerClient, user.id); + await deleteUser(this.accountManagerUsersClient, user.id); } if (this.jsonEnabled()) { diff --git a/packages/b2c-cli/src/commands/user/get.ts b/packages/b2c-cli/src/commands/user/get.ts index a921d9e3..73feb754 100644 --- a/packages/b2c-cli/src/commands/user/get.ts +++ b/packages/b2c-cli/src/commands/user/get.ts @@ -35,7 +35,7 @@ export default class UserGet extends UserCommand { this.log(t('commands.user.get.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerClient, login); + const user = await getUserByLogin(this.accountManagerUsersClient, login); if (this.jsonEnabled()) { return user; diff --git a/packages/b2c-cli/src/commands/user/list.ts b/packages/b2c-cli/src/commands/user/list.ts index 00d71652..1fad7019 100644 --- a/packages/b2c-cli/src/commands/user/list.ts +++ b/packages/b2c-cli/src/commands/user/list.ts @@ -137,7 +137,7 @@ export default class UserList extends UserCommand { this.log(t('commands.user.list.fetching', 'Fetching users...')); - const result = await listUsers(this.accountManagerClient, { + const result = await listUsers(this.accountManagerUsersClient, { size: pageSize, page: pageNumber, }); diff --git a/packages/b2c-cli/src/commands/user/reset.ts b/packages/b2c-cli/src/commands/user/reset.ts index eadb2c73..e51d00f4 100644 --- a/packages/b2c-cli/src/commands/user/reset.ts +++ b/packages/b2c-cli/src/commands/user/reset.ts @@ -33,7 +33,7 @@ export default class UserReset extends UserCommand { this.log(t('commands.user.reset.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerClient, login); + const user = await getUserByLogin(this.accountManagerUsersClient, login); if (!user.id) { this.error(t('commands.user.reset.noId', 'User does not have an ID')); @@ -41,7 +41,7 @@ export default class UserReset extends UserCommand { this.log(t('commands.user.reset.resetting', 'Resetting password for user {{login}}...', {login})); - await resetUser(this.accountManagerClient, user.id); + await resetUser(this.accountManagerUsersClient, user.id); if (this.jsonEnabled()) { return; diff --git a/packages/b2c-cli/src/commands/user/update.ts b/packages/b2c-cli/src/commands/user/update.ts index 30237c9c..8be83cf0 100644 --- a/packages/b2c-cli/src/commands/user/update.ts +++ b/packages/b2c-cli/src/commands/user/update.ts @@ -44,7 +44,7 @@ export default class UserUpdate extends UserCommand { this.log(t('commands.user.update.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerClient, login); + const user = await getUserByLogin(this.accountManagerUsersClient, login); if (!user.id) { this.error(t('commands.user.update.noId', 'User does not have an ID')); @@ -64,7 +64,7 @@ export default class UserUpdate extends UserCommand { this.log(t('commands.user.update.updating', 'Updating user {{login}}...', {login})); - const updatedUser = await updateUser(this.accountManagerClient, { + const updatedUser = await updateUser(this.accountManagerUsersClient, { userId: user.id, changes: changes as UserUpdateType, }); diff --git a/packages/b2c-cli/test/helpers/test-setup.ts b/packages/b2c-cli/test/helpers/test-setup.ts index 4c55b2f2..ad47c8cd 100644 --- a/packages/b2c-cli/test/helpers/test-setup.ts +++ b/packages/b2c-cli/test/helpers/test-setup.ts @@ -118,7 +118,7 @@ export function stubJsonEnabled(command: any, enabled: boolean): void { /** * Stubs a client property on a command. * @param command - The command instance to stub - * @param propertyName - The name of the client property (e.g., 'accountManagerClient', 'accountManagerRolesClient', 'accountManagerOrgsClient') + * @param propertyName - The name of the client property (e.g., 'accountManagerUsersClient', 'accountManagerRolesClient', 'accountManagerOrgsClient') * @param client - The client instance to stub */ export function stubClient(command: any, propertyName: string, client: any): void { diff --git a/packages/b2c-tooling-sdk/README.md b/packages/b2c-tooling-sdk/README.md index b357d80a..f106192d 100644 --- a/packages/b2c-tooling-sdk/README.md +++ b/packages/b2c-tooling-sdk/README.md @@ -138,7 +138,7 @@ import { grantRole, revokeRole, } from '@salesforce/b2c-tooling-sdk/operations/users'; -import {createAccountManagerClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {createAccountManagerUsersClient} from '@salesforce/b2c-tooling-sdk/clients'; import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; const auth = new OAuthStrategy({ @@ -146,7 +146,7 @@ const auth = new OAuthStrategy({ clientSecret: 'your-client-secret', }); -const client = createAccountManagerClient({}, auth); +const client = createAccountManagerUsersClient({}, auth); // List users with pagination const users = await listUsers(client, {size: 25, page: 0}); diff --git a/packages/b2c-tooling-sdk/src/cli/user-command.ts b/packages/b2c-tooling-sdk/src/cli/user-command.ts index f7b85cf0..139fdddd 100644 --- a/packages/b2c-tooling-sdk/src/cli/user-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/user-command.ts @@ -5,39 +5,39 @@ */ import {Command} from '@oclif/core'; import {OAuthCommand} from './oauth-command.js'; -import {createAccountManagerClient} from '../clients/am-users-api.js'; -import type {AccountManagerClient} from '../clients/am-users-api.js'; +import {createAccountManagerUsersClient} from '../clients/am-users-api.js'; +import type {AccountManagerUsersClient} from '../clients/am-users-api.js'; /** * Base command for Account Manager user operations. * - * Extends OAuthCommand with Account Manager client setup. + * Extends OAuthCommand with Account Manager Users client setup. * * @example * export default class UserList extends UserCommand { * async run(): Promise { - * const users = await this.accountManagerClient.listUsers(); + * const users = await this.accountManagerUsersClient.listUsers(); * // ... * } * } */ export abstract class UserCommand extends OAuthCommand { - private _accountManagerClient?: AccountManagerClient; + private _accountManagerUsersClient?: AccountManagerUsersClient; /** - * Gets the Account Manager client, creating it if necessary. + * Gets the Account Manager Users client, creating it if necessary. */ - protected get accountManagerClient(): AccountManagerClient { - if (!this._accountManagerClient) { + protected get accountManagerUsersClient(): AccountManagerUsersClient { + if (!this._accountManagerUsersClient) { this.requireOAuthCredentials(); const authStrategy = this.getOAuthStrategy(); - this._accountManagerClient = createAccountManagerClient( + this._accountManagerUsersClient = createAccountManagerUsersClient( { hostname: this.accountManagerHost, }, authStrategy, ); } - return this._accountManagerClient; + return this._accountManagerUsersClient; } } diff --git a/packages/b2c-tooling-sdk/src/clients/am-users-api.ts b/packages/b2c-tooling-sdk/src/clients/am-users-api.ts index 12fcc5cd..8cb624dd 100644 --- a/packages/b2c-tooling-sdk/src/clients/am-users-api.ts +++ b/packages/b2c-tooling-sdk/src/clients/am-users-api.ts @@ -28,9 +28,9 @@ export type {paths, components}; /** * The typed Account Manager Users client - this is the openapi-fetch Client with full type safety. * - * @see {@link createAccountManagerClient} for instantiation + * @see {@link createAccountManagerUsersClient} for instantiation */ -export type AccountManagerClient = Client; +export type AccountManagerUsersClient = Client; /** * Helper type to extract response data from an operation. @@ -95,7 +95,7 @@ function createPageableTransformMiddleware(): Middleware { /** * Configuration for creating an Account Manager client. */ -export interface AccountManagerClientConfig { +export interface AccountManagerUsersClientConfig { /** * Account Manager hostname. * Defaults to: account.demandware.com @@ -129,7 +129,7 @@ export interface AccountManagerClientConfig { * clientSecret: 'your-client-secret', * }); * - * const client = createAccountManagerClient({}, oauthStrategy); + * const client = createAccountManagerUsersClient({}, oauthStrategy); * * // List users * const { data, error } = await client.GET('/dw/rest/v1/users', { @@ -152,10 +152,10 @@ export interface AccountManagerClientConfig { * } * }); */ -export function createAccountManagerClient( - config: AccountManagerClientConfig, +export function createAccountManagerUsersClient( + config: AccountManagerUsersClientConfig, auth: AuthStrategy, -): AccountManagerClient { +): AccountManagerUsersClient { const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; @@ -241,7 +241,7 @@ export interface ListUsersOptions { * @returns User details * @throws Error if user is not found or request fails */ -export async function getUser(client: AccountManagerClient, userId: string): Promise { +export async function getUser(client: AccountManagerUsersClient, userId: string): Promise { const result = await client.GET('/dw/rest/v1/users/{userId}', { params: {path: {userId}}, }); @@ -269,7 +269,10 @@ export async function getUser(client: AccountManagerClient, userId: string): Pro * @returns Paginated user collection * @throws Error if request fails */ -export async function listUsers(client: AccountManagerClient, options: ListUsersOptions = {}): Promise { +export async function listUsers( + client: AccountManagerUsersClient, + options: ListUsersOptions = {}, +): Promise { const {size = 20, page = 0} = options; const result = await client.GET('/dw/rest/v1/users', { @@ -311,7 +314,7 @@ export async function listUsers(client: AccountManagerClient, options: ListUsers * @returns Created user * @throws Error if request fails */ -export async function createUser(client: AccountManagerClient, user: UserCreate): Promise { +export async function createUser(client: AccountManagerUsersClient, user: UserCreate): Promise { const result = await client.POST('/dw/rest/v1/users', { body: user, }); @@ -338,7 +341,7 @@ export async function createUser(client: AccountManagerClient, user: UserCreate) * @throws Error if request fails */ export async function updateUser( - client: AccountManagerClient, + client: AccountManagerUsersClient, userId: string, changes: UserUpdate, ): Promise { @@ -367,7 +370,7 @@ export async function updateUser( * @param userId - User ID * @throws Error if request fails */ -export async function deleteUser(client: AccountManagerClient, userId: string): Promise { +export async function deleteUser(client: AccountManagerUsersClient, userId: string): Promise { const result = await client.POST('/dw/rest/v1/users/{userId}/disable', { params: {path: {userId}}, body: {}, @@ -387,7 +390,7 @@ export async function deleteUser(client: AccountManagerClient, userId: string): * @param userId - User ID * @throws Error if request fails */ -export async function purgeUser(client: AccountManagerClient, userId: string): Promise { +export async function purgeUser(client: AccountManagerUsersClient, userId: string): Promise { const result = await client.DELETE('/dw/rest/v1/users/{userId}', { params: {path: {userId}}, }); @@ -405,7 +408,7 @@ export async function purgeUser(client: AccountManagerClient, userId: string): P * @param userId - User ID * @throws Error if request fails */ -export async function resetUser(client: AccountManagerClient, userId: string): Promise { +export async function resetUser(client: AccountManagerUsersClient, userId: string): Promise { const result = await client.POST('/dw/rest/v1/users/{userId}/reset', { params: {path: {userId}}, body: {}, @@ -427,7 +430,7 @@ export async function resetUser(client: AccountManagerClient, userId: string): P * @throws Error if request fails */ export async function findUserByLogin( - client: AccountManagerClient, + client: AccountManagerUsersClient, login: string, ): Promise { // Search through paginated results diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index b6066a2c..005adfbb 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -205,7 +205,7 @@ export type { } from './scapi-schemas.js'; export { - createAccountManagerClient, + createAccountManagerUsersClient, getUser, listUsers, createUser, @@ -220,8 +220,8 @@ export { ROLE_NAMES_MAP_REVERSE, } from './am-users-api.js'; export type { - AccountManagerClient, - AccountManagerClientConfig, + AccountManagerUsersClient, + AccountManagerUsersClientConfig, AccountManagerUser, AccountManagerResponse, AccountManagerError, diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index d1db8f2d..539a5a17 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -66,7 +66,9 @@ export { createSlasClient, createOdsClient, createCustomApisClient, - createAccountManagerClient, + createAccountManagerUsersClient, + createAccountManagerRolesClient, + createAccountManagerOrgsClient, createCdnZonesClient, toOrganizationId, toTenantId, @@ -104,8 +106,8 @@ export type { CustomApisResponse, CustomApisPaths, CustomApisComponents, - AccountManagerClient, - AccountManagerClientConfig, + AccountManagerUsersClient, + AccountManagerUsersClientConfig, AccountManagerUser, AccountManagerResponse, AccountManagerError, diff --git a/packages/b2c-tooling-sdk/src/operations/orgs/index.ts b/packages/b2c-tooling-sdk/src/operations/orgs/index.ts index 8ddf28d0..6d3f1a39 100644 --- a/packages/b2c-tooling-sdk/src/operations/orgs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/orgs/index.ts @@ -64,7 +64,6 @@ import type { // Re-export types export type { - AccountManagerOrgsClient, AccountManagerOrganization, OrganizationCollection, AuditLogCollection, diff --git a/packages/b2c-tooling-sdk/src/operations/roles/index.ts b/packages/b2c-tooling-sdk/src/operations/roles/index.ts index 71dc17cd..6fff3864 100644 --- a/packages/b2c-tooling-sdk/src/operations/roles/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/roles/index.ts @@ -45,9 +45,4 @@ * @module operations/roles */ export {getRole, listRoles} from '../../clients/am-roles-api.js'; -export type { - AccountManagerRolesClient, - AccountManagerRole, - RoleCollection, - ListRolesOptions, -} from '../../clients/am-roles-api.js'; +export type {AccountManagerRole, RoleCollection, ListRolesOptions} from '../../clients/am-roles-api.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/users/index.ts b/packages/b2c-tooling-sdk/src/operations/users/index.ts index e93cc2db..6f9b3dd2 100644 --- a/packages/b2c-tooling-sdk/src/operations/users/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/users/index.ts @@ -29,7 +29,7 @@ * createUser, * grantRole, * } from '@salesforce/b2c-tooling-sdk/operations/users'; - * import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; + * import { createAccountManagerUsersClient } from '@salesforce/b2c-tooling-sdk/clients'; * import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; * * const auth = new OAuthStrategy({ @@ -37,7 +37,7 @@ * clientSecret: 'your-client-secret', * }); * - * const client = createAccountManagerClient({}, auth); + * const client = createAccountManagerUsersClient({}, auth); * * // Get a user by login * const user = await getUserByLogin(client, 'user@example.com'); @@ -61,7 +61,12 @@ * * @module operations/users */ -import type {AccountManagerClient, AccountManagerUser, UserCreate, UserUpdate} from '../../clients/am-users-api.js'; +import type { + AccountManagerUsersClient, + AccountManagerUser, + UserCreate, + UserUpdate, +} from '../../clients/am-users-api.js'; import { getUser, listUsers, @@ -127,7 +132,7 @@ export {getUser, listUsers, deleteUser, purgeUser, resetUser}; * @returns User details * @throws Error if user is not found */ -export async function getUserByLogin(client: AccountManagerClient, login: string): Promise { +export async function getUserByLogin(client: AccountManagerUsersClient, login: string): Promise { const user = await findUserByLogin(client, login); if (!user) { throw new Error(`User ${login} not found`); @@ -143,7 +148,7 @@ export async function getUserByLogin(client: AccountManagerClient, login: string * @returns Created user */ export async function createUser( - client: AccountManagerClient, + client: AccountManagerUsersClient, options: CreateUserOptions, ): Promise { return createUserApi(client, options.user); @@ -157,7 +162,7 @@ export async function createUser( * @returns Updated user */ export async function updateUser( - client: AccountManagerClient, + client: AccountManagerUsersClient, options: UpdateUserOptions, ): Promise { return updateUserApi(client, options.userId, options.changes); @@ -171,7 +176,10 @@ export async function updateUser( * @param options - Grant options (userId, role, optional scope) * @returns Updated user */ -export async function grantRole(client: AccountManagerClient, options: GrantRoleOptions): Promise { +export async function grantRole( + client: AccountManagerUsersClient, + options: GrantRoleOptions, +): Promise { // First get the current user const user = await getUser(client, options.userId); @@ -221,7 +229,7 @@ export async function grantRole(client: AccountManagerClient, options: GrantRole * @returns Updated user */ export async function revokeRole( - client: AccountManagerClient, + client: AccountManagerUsersClient, options: RevokeRoleOptions, ): Promise { // First get the current user diff --git a/packages/b2c-tooling-sdk/test/cli/user-command.test.ts b/packages/b2c-tooling-sdk/test/cli/user-command.test.ts index 1b1e843a..99a44bf3 100644 --- a/packages/b2c-tooling-sdk/test/cli/user-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/user-command.test.ts @@ -21,8 +21,8 @@ class TestUserCommand extends UserCommand { } // Expose protected methods for testing - public testAccountManagerClient() { - return this.accountManagerClient; + public testAccountManagerUsersClient() { + return this.accountManagerUsersClient; } } @@ -41,12 +41,12 @@ describe('cli/user-command', () => { restoreConfig(); }); - describe('accountManagerClient', () => { - it('should create account manager client', async () => { + describe('accountManagerUsersClient', () => { + it('should create account manager users client', async () => { stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); await command.init(); - const client = command.testAccountManagerClient(); + const client = command.testAccountManagerUsersClient(); expect(client).to.exist; }); @@ -55,7 +55,7 @@ describe('cli/user-command', () => { stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); await command.init(); - const client = command.testAccountManagerClient(); + const client = command.testAccountManagerUsersClient(); expect(client).to.exist; // Client should be created with OAuth authentication diff --git a/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts index 5792fae2..2ad13cec 100644 --- a/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts @@ -8,7 +8,7 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import { - createAccountManagerClient, + createAccountManagerUsersClient, getUser, listUsers, createUser, @@ -39,24 +39,24 @@ describe('Account Manager Users API Client', () => { server.close(); }); - let client: ReturnType; + let client: ReturnType; let mockAuth: MockAuthStrategy; beforeEach(() => { mockAuth = new MockAuthStrategy(); - client = createAccountManagerClient({hostname: TEST_HOST}, mockAuth); + client = createAccountManagerUsersClient({hostname: TEST_HOST}, mockAuth); }); describe('client creation', () => { it('should create client with default host', () => { const auth = new MockAuthStrategy(); - const client = createAccountManagerClient({}, auth); + const client = createAccountManagerUsersClient({}, auth); expect(client).to.exist; }); it('should create client with custom host', () => { const auth = new MockAuthStrategy(); - const client = createAccountManagerClient({hostname: 'custom.host.com'}, auth); + const client = createAccountManagerUsersClient({hostname: 'custom.host.com'}, auth); expect(client).to.exist; }); }); diff --git a/packages/b2c-tooling-sdk/test/operations/users/index.test.ts b/packages/b2c-tooling-sdk/test/operations/users/index.test.ts index 7cccaeb9..7344f594 100644 --- a/packages/b2c-tooling-sdk/test/operations/users/index.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/users/index.test.ts @@ -8,7 +8,7 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {getUserByLogin, createUser, updateUser, grantRole, revokeRole} from '../../../src/operations/users/index.js'; -import {createAccountManagerClient} from '../../../src/clients/am-users-api.js'; +import {createAccountManagerUsersClient} from '../../../src/clients/am-users-api.js'; import {MockAuthStrategy} from '../../helpers/mock-auth.js'; const TEST_HOST = 'account.test.demandware.com'; @@ -29,12 +29,12 @@ describe('operations/users', () => { server.close(); }); - let client: ReturnType; + let client: ReturnType; let mockAuth: MockAuthStrategy; beforeEach(() => { mockAuth = new MockAuthStrategy(); - client = createAccountManagerClient({hostname: TEST_HOST}, mockAuth); + client = createAccountManagerUsersClient({hostname: TEST_HOST}, mockAuth); }); describe('getUserByLogin', () => { From 629a395f08ec475c473db904552b1689a21f5a7d Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Tue, 27 Jan 2026 19:59:59 +0530 Subject: [PATCH 10/15] @W-20893693: Adding AM topic with users, role and org subtopics --- docs/cli/account-manager.md | 791 +++++++++++ docs/cli/index.md | 9 +- docs/cli/org.md | 246 ---- docs/cli/role.md | 286 ---- docs/cli/user.md | 352 ----- packages/b2c-cli/README.md | 34 +- packages/b2c-cli/package.json | 21 +- .../src/commands/{org => am/orgs}/audit.ts | 13 +- .../src/commands/{org => am/orgs}/get.ts | 11 +- .../src/commands/{org => am/orgs}/list.ts | 9 +- .../src/commands/{role => am/roles}/get.ts | 9 +- .../src/commands/{role => am/roles}/grant.ts | 19 +- .../src/commands/{role => am/roles}/list.ts | 9 +- .../src/commands/{role => am/roles}/revoke.ts | 19 +- .../src/commands/{user => am/users}/create.ts | 21 +- .../src/commands/{user => am/users}/delete.ts | 17 +- .../src/commands/{user => am/users}/get.ts | 80 +- .../src/commands/{user => am/users}/list.ts | 9 +- .../src/commands/{user => am/users}/reset.ts | 15 +- .../src/commands/{user => am/users}/update.ts | 18 +- .../commands/{org => am/orgs}/audit.test.ts | 4 +- .../commands/{org => am/orgs}/get.test.ts | 4 +- .../commands/{org => am/orgs}/list.test.ts | 4 +- .../commands/{role => am/roles}/get.test.ts | 4 +- .../commands/{role => am/roles}/grant.test.ts | 4 +- .../commands/{role => am/roles}/list.test.ts | 4 +- .../{role => am/roles}/revoke.test.ts | 4 +- .../{user => am/users}/create.test.ts | 4 +- .../{user => am/users}/delete.test.ts | 4 +- .../test/commands/am/users/get.test.ts | 466 +++++++ .../commands/{user => am/users}/list.test.ts | 4 +- .../{user => am/users}/update.test.ts | 4 +- .../b2c-cli/test/commands/user/get.test.ts | 193 --- .../b2c-tooling-sdk/src/cli/am-command.ts | 61 + packages/b2c-tooling-sdk/src/cli/index.ts | 4 +- .../b2c-tooling-sdk/src/cli/org-command.ts | 43 - .../b2c-tooling-sdk/src/cli/role-command.ts | 43 - .../b2c-tooling-sdk/src/cli/user-command.ts | 43 - .../b2c-tooling-sdk/src/clients/am-api.ts | 1188 +++++++++++++++++ .../src/clients/am-orgs-api.ts | 268 ---- .../src/clients/am-roles-api.ts | 263 ---- .../src/clients/am-users-api.ts | 470 ------- packages/b2c-tooling-sdk/src/clients/index.ts | 27 +- packages/b2c-tooling-sdk/src/index.ts | 9 +- .../src/operations/orgs/index.ts | 4 +- .../src/operations/roles/index.ts | 4 +- .../src/operations/users/index.ts | 11 +- ...org-command.test.ts => am-command.test.ts} | 33 +- .../test/cli/role-command.test.ts | 63 - .../test/cli/user-command.test.ts | 64 - .../test/clients/am-orgs-api.test.ts | 2 +- .../test/clients/am-roles-api.test.ts | 2 +- .../test/clients/am-users-api.test.ts | 2 +- .../test/operations/orgs/index.test.ts | 2 +- .../test/operations/roles/index.test.ts | 2 +- .../test/operations/users/index.test.ts | 2 +- 56 files changed, 2765 insertions(+), 2536 deletions(-) create mode 100644 docs/cli/account-manager.md delete mode 100644 docs/cli/org.md delete mode 100644 docs/cli/role.md delete mode 100644 docs/cli/user.md rename packages/b2c-cli/src/commands/{org => am/orgs}/audit.ts (86%) rename packages/b2c-cli/src/commands/{org => am/orgs}/get.ts (94%) rename packages/b2c-cli/src/commands/{org => am/orgs}/list.ts (93%) rename packages/b2c-cli/src/commands/{role => am/roles}/get.ts (88%) rename packages/b2c-cli/src/commands/{role => am/roles}/grant.ts (80%) rename packages/b2c-cli/src/commands/{role => am/roles}/list.ts (93%) rename packages/b2c-cli/src/commands/{role => am/roles}/revoke.ts (80%) rename packages/b2c-cli/src/commands/{user => am/users}/create.ts (78%) rename packages/b2c-cli/src/commands/{user => am/users}/delete.ts (78%) rename packages/b2c-cli/src/commands/{user => am/users}/get.ts (64%) rename packages/b2c-cli/src/commands/{user => am/users}/list.ts (93%) rename packages/b2c-cli/src/commands/{user => am/users}/reset.ts (75%) rename packages/b2c-cli/src/commands/{user => am/users}/update.ts (80%) rename packages/b2c-cli/test/commands/{org => am/orgs}/audit.test.ts (99%) rename packages/b2c-cli/test/commands/{org => am/orgs}/get.test.ts (98%) rename packages/b2c-cli/test/commands/{org => am/orgs}/list.test.ts (98%) rename packages/b2c-cli/test/commands/{role => am/roles}/get.test.ts (97%) rename packages/b2c-cli/test/commands/{role => am/roles}/grant.test.ts (98%) rename packages/b2c-cli/test/commands/{role => am/roles}/list.test.ts (98%) rename packages/b2c-cli/test/commands/{role => am/roles}/revoke.test.ts (98%) rename packages/b2c-cli/test/commands/{user => am/users}/create.test.ts (97%) rename packages/b2c-cli/test/commands/{user => am/users}/delete.test.ts (97%) create mode 100644 packages/b2c-cli/test/commands/am/users/get.test.ts rename packages/b2c-cli/test/commands/{user => am/users}/list.test.ts (98%) rename packages/b2c-cli/test/commands/{user => am/users}/update.test.ts (98%) delete mode 100644 packages/b2c-cli/test/commands/user/get.test.ts create mode 100644 packages/b2c-tooling-sdk/src/cli/am-command.ts delete mode 100644 packages/b2c-tooling-sdk/src/cli/org-command.ts delete mode 100644 packages/b2c-tooling-sdk/src/cli/role-command.ts delete mode 100644 packages/b2c-tooling-sdk/src/cli/user-command.ts create mode 100644 packages/b2c-tooling-sdk/src/clients/am-api.ts delete mode 100644 packages/b2c-tooling-sdk/src/clients/am-orgs-api.ts delete mode 100644 packages/b2c-tooling-sdk/src/clients/am-roles-api.ts delete mode 100644 packages/b2c-tooling-sdk/src/clients/am-users-api.ts rename packages/b2c-tooling-sdk/test/cli/{org-command.test.ts => am-command.test.ts} (55%) delete mode 100644 packages/b2c-tooling-sdk/test/cli/role-command.test.ts delete mode 100644 packages/b2c-tooling-sdk/test/cli/user-command.test.ts diff --git a/docs/cli/account-manager.md b/docs/cli/account-manager.md new file mode 100644 index 00000000..4ede4249 --- /dev/null +++ b/docs/cli/account-manager.md @@ -0,0 +1,791 @@ +--- +description: Commands for managing Account Manager resources including users, roles, and organizations. +--- + +# Account Manager Commands + +Commands for managing Account Manager resources including users, roles, role assignments, and organizations. + +## Global Flags + +These flags are available on all Account Manager commands: + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (e.g., `account.demandware.com`) | + +## Authentication + +All Account Manager commands require an Account Manager API Client with OAuth authentication. + +### Required Configuration + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID for Account Manager | +| `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret for Account Manager | + +### Required Roles + +The API client must have the following role: +- `sfcc.accountmanager.user.manage` - Required for all Account Manager operations + +### Configuration + +```bash +# Set Account Manager host +export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com + +# Set OAuth credentials +export SFCC_CLIENT_ID=my-client-id +export SFCC_CLIENT_SECRET=my-client-secret +``` + +--- + +## User Management + +Commands for managing users in Account Manager. + +### b2c am users list + +List users in Account Manager with pagination support. + +#### Usage + +```bash +b2c am users list [FLAGS] +``` + +#### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--page` | Page number (0-based) | `0` | +| `--size` | Number of results per page (1-4000) | `20` | +| `--columns` | Comma-separated list of columns to display | Default columns | +| `--extended` | Show all available columns | `false` | +| `--json` | Output results as JSON | `false` | + +#### Default Columns + +- Email +- First Name +- Last Name +- State +- Password Expired +- 2FA Enabled +- Linked to SF +- Last Login + +#### Extended Columns + +- Roles +- Organizations + +#### Examples + +```bash +# List first page of users (default: 20 per page) +b2c am users list + +# List users with custom page size +b2c am users list --size 50 + +# Get second page of results +b2c am users list --page 1 --size 25 + +# Show all columns including roles and organizations +b2c am users list --extended + +# Show only specific columns +b2c am users list --columns mail,firstName,userState + +# Output as JSON +b2c am users list --json +``` + +#### Notes + +- Page size must be between 1 and 4000 +- Page number must be a non-negative integer (0-based) +- If the requested page exceeds available data, an error is returned + +--- + +### b2c am users get + +Get detailed information about a specific user. + +#### Usage + +```bash +b2c am users get +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--expand` | Comma-separated list of fields to expand. Valid values: `organizations`, `roles` | +| `--expand-all` | Expand both organizations and roles (equivalent to `--expand organizations,roles`) | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Get user details +b2c am users get user@example.com + +# Get user with expanded organizations and roles +b2c am users get user@example.com --expand-all + +# Get user with expanded organizations only +b2c am users get user@example.com --expand organizations + +# Get user with expanded organizations and roles (comma-separated) +b2c am users get user@example.com --expand organizations,roles + +# Output as JSON +b2c am users get user@example.com --json + +# Output as JSON with expanded fields +b2c am users get user@example.com --expand-all --json +``` + +#### Output + +When not using `--json`, displays formatted user information including: + +- Basic Information: ID, Email, Name, State, Organization, etc. +- Organizations: List of organization IDs (or full organization objects if expanded) +- Roles: List of role IDs (or full role objects if expanded) +- Role Tenant Filters: Role-specific tenant scope mappings + +When using `--expand` or `--expand-all`, the organizations and roles fields contain full objects instead of just IDs, providing additional details like organization names and role descriptions. + +#### Notes + +- User is identified by email address (login) +- If user is not found, an error is returned +- Use `--expand` or `--expand-all` to retrieve full organization and role objects instead of just IDs +- Invalid expand values will result in an error message listing the valid options + +--- + +### b2c am users create + +Create a new user in Account Manager. + +#### Usage + +```bash +b2c am users create --org --mail [FLAGS] +``` + +#### Required Flags + +| Flag | Description | +|------|-------------| +| `--org` | Organization ID where the user will be created | +| `--mail` | User email address (login) | + +#### Optional Flags + +| Flag | Description | +|------|-------------| +| `--first-name` | User's first name | +| `--last-name` | User's last name | +| `--display-name` | Display name | +| `--preferred-locale` | Preferred locale (e.g., `en_US`) | +| `--business-phone` | Business phone number | +| `--home-phone` | Home phone number | +| `--mobile-phone` | Mobile phone number | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Create a basic user +b2c am users create --org org-123 --mail user@example.com \ + --first-name John --last-name Doe + +# Create a user with additional details +b2c am users create --org org-123 --mail user@example.com \ + --first-name John --last-name Doe \ + --display-name "John Doe" \ + --preferred-locale en_US \ + --business-phone "+1-555-123-4567" + +# Output as JSON +b2c am users create --org org-123 --mail user@example.com \ + --first-name John --last-name Doe --json +``` + +#### Notes + +- User will be created in INITIAL state +- User must be assigned roles separately using `b2c am roles grant` +- The user's primary organization is set to the specified `--org` + +--- + +### b2c am users update + +Update an existing user's information. + +#### Usage + +```bash +b2c am users update [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--first-name` | Update first name | +| `--last-name` | Update last name | +| `--display-name` | Update display name | +| `--preferred-locale` | Update preferred locale | +| `--business-phone` | Update business phone | +| `--home-phone` | Update home phone | +| `--mobile-phone` | Update mobile phone | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Update user's first name +b2c am users update user@example.com --first-name Jane + +# Update multiple fields +b2c am users update user@example.com \ + --first-name Jane \ + --last-name Smith \ + --display-name "Jane Smith" + +# Output as JSON +b2c am users update user@example.com --first-name Jane --json +``` + +#### Notes + +- At least one field must be provided to update +- Only specified fields will be updated; other fields remain unchanged +- If user is not found, an error is returned + +--- + +### b2c am users reset + +Reset a user to INITIAL state, clearing password expiration and allowing password reset. + +#### Usage + +```bash +b2c am users reset +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Examples + +```bash +# Reset user to INITIAL state +b2c am users reset user@example.com +``` + +#### Notes + +- Resets the user's state to INITIAL +- Clears password expiration timestamp +- User will need to set a new password on next login + +--- + +### b2c am users delete + +Delete (disable) a user in Account Manager. + +#### Usage + +```bash +b2c am users delete [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--purge` | Permanently delete the user (hard delete). User must be in DELETED state first. | + +#### Examples + +```bash +# Soft delete (disable) a user +b2c am users delete user@example.com + +# Permanently delete a user (must be in DELETED state first) +b2c am users delete user@example.com --purge +``` + +#### Notes + +- By default, this performs a soft delete (disables the user) +- Soft delete sets the user state to DELETED +- Use `--purge` for permanent deletion (hard delete) +- Purging requires the user to already be in DELETED state +- Deletion is permanent and cannot be undone + +--- + +## Role Management + +Commands for managing roles and role assignments in Account Manager. + +### b2c am roles list + +List roles in Account Manager with pagination support. + +#### Usage + +```bash +b2c am roles list [FLAGS] +``` + +#### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--page` | Page number (0-based) | `0` | +| `--size` | Number of results per page (1-4000) | `20` | +| `--target-type` | Filter by target type (`User` or `ApiClient`) | All types | +| `--columns` | Comma-separated list of columns to display | Default columns | +| `--extended` | Show all available columns | `false` | +| `--json` | Output results as JSON | `false` | + +#### Default Columns + +- ID +- Description +- Scope +- Internal Role + +#### Extended Columns + +- Target Type + +#### Examples + +```bash +# List first page of roles (default: 20 per page) +b2c am roles list + +# List roles with custom page size +b2c am roles list --size 50 + +# Get second page of results +b2c am roles list --page 1 --size 25 + +# Filter roles by target type +b2c am roles list --target-type User + +# Show all columns +b2c am roles list --extended + +# Show only specific columns +b2c am roles list --columns id,description + +# Output as JSON +b2c am roles list --json +``` + +#### Notes + +- Page size must be between 1 and 4000 +- Page number must be a non-negative integer (0-based) +- If the requested page exceeds available data, an error is returned +- Target type filter accepts `User` or `ApiClient` + +--- + +### b2c am roles get + +Get detailed information about a specific role. + +#### Usage + +```bash +b2c am roles get +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `ROLE_ID` | Role identifier (e.g., `bm-admin`, `SLAS_ORGANIZATION_ADMIN`) | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Get role details +b2c am roles get bm-admin + +# Get internal role details +b2c am roles get SLAS_ORGANIZATION_ADMIN + +# Output as JSON +b2c am roles get bm-admin --json +``` + +#### Output + +When not using `--json`, displays formatted role information including: + +- Basic Information: ID, Description, Scope, Target Type +- Internal Role: Whether this is an internal role + +#### Notes + +- Role ID can be either the external role name (e.g., `bm-admin`) or internal role enum name (e.g., `ECOM_ADMIN`) +- If role is not found, an error is returned + +--- + +### b2c am roles grant + +Grant a role to a user, optionally with tenant scope. + +#### Usage + +```bash +b2c am roles grant --role [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Required Flags + +| Flag | Description | +|------|-------------| +| `--role`, `-r` | Role ID to grant (e.g., `bm-admin`) | Yes | + +#### Optional Flags + +| Flag | Description | +|------|-------------| +| `--scope`, `-s` | Tenant scope (comma-separated list of tenant IDs). If not provided, grants role without scope restrictions. | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Grant a role without scope +b2c am roles grant user@example.com --role bm-admin + +# Grant a role with single tenant scope +b2c am roles grant user@example.com --role bm-admin --scope tenant1 + +# Grant a role with multiple tenant scopes +b2c am roles grant user@example.com --role bm-admin --scope "tenant1,tenant2" + +# Using short flags +b2c am roles grant user@example.com -r bm-admin -s tenant1 + +# Output as JSON +b2c am roles grant user@example.com --role bm-admin --scope tenant1 --json +``` + +#### Notes + +- If the user already has the role, the scope will be updated if `--scope` is provided +- If `--scope` is not provided, the role is granted without tenant restrictions +- If `--scope` is provided, it replaces any existing scope for that role +- Multiple scopes can be specified as a comma-separated list +- If user is not found, an error is returned + +--- + +### b2c am roles revoke + +Revoke a role from a user, optionally removing specific tenant scope. + +#### Usage + +```bash +b2c am roles revoke --role [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `LOGIN` | User email address | Yes | + +#### Required Flags + +| Flag | Description | +|------|-------------| +| `--role`, `-r` | Role ID to revoke (e.g., `bm-admin`) | Yes | + +#### Optional Flags + +| Flag | Description | +|------|-------------| +| `--scope`, `-s` | Tenant scope to remove (comma-separated). If not provided, removes the entire role. | +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Revoke entire role +b2c am roles revoke user@example.com --role bm-admin + +# Revoke specific tenant scope (keeps role for other tenants) +b2c am roles revoke user@example.com --role bm-admin --scope tenant1 + +# Revoke multiple tenant scopes +b2c am roles revoke user@example.com --role bm-admin --scope "tenant1,tenant2" + +# Using short flags +b2c am roles revoke user@example.com -r bm-admin -s tenant1 + +# Output as JSON +b2c am roles revoke user@example.com --role bm-admin --scope tenant1 --json +``` + +#### Notes + +- If `--scope` is not provided, the entire role is removed from the user +- If `--scope` is provided, only the specified tenant scopes are removed +- If all scopes are removed, the role itself is removed +- Multiple scopes can be specified as a comma-separated list +- If user is not found, an error is returned + +--- + +## Organization Management + +Commands for managing organizations in Account Manager. + +### b2c am orgs list + +List organizations in Account Manager with pagination support. + +#### Usage + +```bash +b2c am orgs list [FLAGS] +``` + +#### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--page` | Page number (0-based) | `0` | +| `--size`, `-s` | Number of results per page (1-5000) | `25` | +| `--all`, `-a` | Return all organizations (uses max page size of 5000) | `false` | +| `--columns` | Comma-separated list of columns to display | Default columns | +| `--extended`, `-x` | Show all available columns | `false` | +| `--json` | Output results as JSON | `false` | + +#### Default Columns + +- ID +- Name +- Realms +- Email Domains +- 2FA Enabled +- VaaS Enabled +- SF Identity +- Min Password Length + +#### Extended Columns + +- 2FA Roles +- Verifier Types + +#### Examples + +```bash +# List first page of organizations (default: 25 per page) +b2c am orgs list + +# List organizations with custom page size +b2c am orgs list --size 50 + +# Get second page of results +b2c am orgs list --page 1 --size 25 + +# Get all organizations (uses max page size of 5000) +b2c am orgs list --all + +# Show all columns +b2c am orgs list --extended + +# Show only specific columns +b2c am orgs list --columns id,name,twoFAEnabled + +# Output as JSON +b2c am orgs list --json +``` + +#### Notes + +- Page size must be between 1 and 5000 +- Page number must be a non-negative integer (0-based) +- If the requested page exceeds available data, an error is returned +- The `--all` flag uses a page size of 5000 to fetch all organizations in a single request + +--- + +### b2c am orgs get + +Get detailed information about a specific organization. + +#### Usage + +```bash +b2c am orgs get +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `ORG` | Organization ID or name | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--json` | Output results as JSON | + +#### Examples + +```bash +# Get organization details by ID +b2c am orgs get org-123 + +# Get organization details by name +b2c am orgs get "My Organization" + +# Output as JSON +b2c am orgs get org-123 --json +``` + +#### Output + +When not using `--json`, displays formatted organization information including: + +- **Organization Details**: ID, Name, 2FA Enabled, VaaS Enabled, SF Identity +- **Contact Users**: List of contact user IDs +- **Allowed Verifier Types**: List of allowed verifier types +- **Account Ids**: List of Salesforce account IDs +- **Password Policy**: Minimum Password Length, Length of Password History, Days Until Password Expires +- **Realms**: Comma-separated list of realm names +- **Email Domains**: List of allowed email domains +- **2FA Roles**: List of roles that require 2FA + +#### Notes + +- Organization can be identified by ID or name +- If organization is not found, an error is returned +- Name matching is case-sensitive and requires an exact match + +--- + +### b2c am orgs audit + +Get audit logs for an Account Manager organization. + +#### Usage + +```bash +b2c am orgs audit [FLAGS] +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `ORG` | Organization ID or name | Yes | + +#### Flags + +| Flag | Description | +|------|-------------| +| `--columns` | Comma-separated list of columns to display | +| `--extended`, `-x` | Show all available columns | +| `--json` | Output results as JSON | + +#### Default Columns + +- Timestamp +- Author +- Email +- Event Type +- Message + +#### Examples + +```bash +# Get audit logs for an organization by ID +b2c am orgs audit org-123 + +# Get audit logs for an organization by name +b2c am orgs audit "My Organization" + +# Show all columns +b2c am orgs audit org-123 --extended + +# Show only specific columns +b2c am orgs audit org-123 --columns timestamp,eventType,eventMessage + +# Output as JSON +b2c am orgs audit org-123 --json +``` + +#### Output + +Displays a table of audit log records with the selected columns. Timestamps are formatted as `MM/DD/YYYY HH:MM:SS` for readability. + +#### Notes + +- Organization can be identified by ID or name +- If organization is not found, an error is returned +- If no audit records are found, a message is displayed +- Timestamps are displayed in a human-readable format with zero-padding for consistent spacing diff --git a/docs/cli/index.md b/docs/cli/index.md index 5139903a..a191c7e7 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -45,9 +45,12 @@ These flags are available on all commands that interact with B2C instances: ### Account Management -- [User Management](./user) - Manage Account Manager users (list, create, update, delete, reset) -- [Role Management](./role) - Manage Account Manager roles and role assignments -- [Organization Management](./org) - Manage Account Manager organizations (list, view details, audit logs) +- [Account Manager Commands](./account-manager) - Manage Account Manager users, roles, and organizations + +All Account Manager commands are under the `am` topic: +- `b2c am users ...` - User management commands +- `b2c am roles ...` - Role management commands +- `b2c am orgs ...` - Organization management commands ### Utilities diff --git a/docs/cli/org.md b/docs/cli/org.md deleted file mode 100644 index b6591d3d..00000000 --- a/docs/cli/org.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -description: Commands for managing Account Manager organizations including listing, viewing details, and accessing audit logs. ---- - -# Organization Management Commands - -Commands for managing organizations in Account Manager. - -## Global Org Flags - -These flags are available on all org commands: - -| Flag | Environment Variable | Description | -|------|---------------------|-------------| -| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (e.g., `account.demandware.com`) | - -## Authentication - -Org commands require an Account Manager API Client with OAuth authentication. - -### Required Configuration - -| Flag | Environment Variable | Description | -|------|---------------------|-------------| -| `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID for Account Manager | -| `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret for Account Manager | - -### Required Roles - -The API client must have the following role: -- `sfcc.accountmanager.user.manage` - Required for organization management operations - -### Configuration - -```bash -# Set Account Manager host -export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com - -# Set OAuth credentials -export SFCC_CLIENT_ID=my-client-id -export SFCC_CLIENT_SECRET=my-client-secret - -# List organizations -b2c org list -``` - ---- - -## b2c org list - -List organizations in Account Manager with pagination support. - -### Usage - -```bash -b2c org list [FLAGS] -``` - -### Flags - -| Flag | Description | Default | -|------|-------------|---------| -| `--page` | Page number (0-based) | `0` | -| `--size`, `-s` | Number of results per page (1-5000) | `25` | -| `--all`, `-a` | Return all organizations (uses max page size of 5000) | `false` | -| `--columns` | Comma-separated list of columns to display | Default columns | -| `--extended`, `-x` | Show all available columns | `false` | -| `--json` | Output results as JSON | `false` | - -### Default Columns - -- ID -- Name -- Realms -- Email Domains -- 2FA Enabled -- VaaS Enabled -- SF Identity -- Min Password Length - -### Extended Columns - -- 2FA Roles -- Verifier Types - -### Examples - -```bash -# List first page of organizations (default: 25 per page) -b2c org list - -# List organizations with custom page size -b2c org list --size 50 - -# Get second page of results -b2c org list --page 1 --size 25 - -# Get all organizations (uses max page size of 5000) -b2c org list --all - -# Show all columns -b2c org list --extended - -# Show only specific columns -b2c org list --columns id,name,twoFAEnabled - -# Output as JSON -b2c org list --json - -# Using environment variables -export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com -export SFCC_CLIENT_ID=my-client-id -export SFCC_CLIENT_SECRET=my-client-secret -b2c org list -``` - -### Output - -Displays a table of organizations with the selected columns. If more pages are available, an info message is displayed at the end. - -### Notes - -- Page size must be between 1 and 5000 -- Page number must be a non-negative integer (0-based) -- If the requested page exceeds available data, an error is returned -- The `--all` flag uses a page size of 5000 to fetch all organizations in a single request - ---- - -## b2c org get - -Get detailed information about a specific organization. - -### Usage - -```bash -b2c org get -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `ORG` | Organization ID or name | Yes | - -### Flags - -| Flag | Description | -|------|-------------| -| `--json` | Output results as JSON | - -### Examples - -```bash -# Get organization details by ID -b2c org get org-123 - -# Get organization details by name -b2c org get "My Organization" - -# Output as JSON -b2c org get org-123 --json -``` - -### Output - -When not using `--json`, displays formatted organization information including: - -- **Organization Details**: ID, Name, 2FA Enabled, VaaS Enabled, SF Identity -- **Contact Users**: List of contact user IDs -- **Allowed Verifier Types**: List of allowed verifier types -- **Account Ids**: List of Salesforce account IDs -- **Password Policy**: Minimum Password Length, Length of Password History, Days Until Password Expires -- **Realms**: Comma-separated list of realm names -- **Email Domains**: List of allowed email domains -- **2FA Roles**: List of roles that require 2FA - -### Notes - -- Organization can be identified by ID or name -- If organization is not found, an error is returned -- Name matching is case-sensitive and requires an exact match - ---- - -## b2c org audit - -Get audit logs for an Account Manager organization. - -### Usage - -```bash -b2c org audit [FLAGS] -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `ORG` | Organization ID or name | Yes | - -### Flags - -| Flag | Description | -|------|-------------| -| `--columns` | Comma-separated list of columns to display | -| `--extended`, `-x` | Show all available columns | -| `--json` | Output results as JSON | - -### Default Columns - -- Timestamp -- Author -- Email -- Event Type -- Message - -### Examples - -```bash -# Get audit logs for an organization by ID -b2c org audit org-123 - -# Get audit logs for an organization by name -b2c org audit "My Organization" - -# Show all columns -b2c org audit org-123 --extended - -# Show only specific columns -b2c org audit org-123 --columns timestamp,eventType,eventMessage - -# Output as JSON -b2c org audit org-123 --json -``` - -### Output - -Displays a table of audit log records with the selected columns. Timestamps are formatted as `MM/DD/YYYY HH:MM:SS` for readability. - -### Notes - -- Organization can be identified by ID or name -- If organization is not found, an error is returned -- If no audit records are found, a message is displayed -- Timestamps are displayed in a human-readable format with zero-padding for consistent spacing diff --git a/docs/cli/role.md b/docs/cli/role.md deleted file mode 100644 index 9709ea0e..00000000 --- a/docs/cli/role.md +++ /dev/null @@ -1,286 +0,0 @@ ---- -description: Commands for managing Account Manager roles including listing roles, viewing role details, and granting/revoking roles to users. ---- - -# Role Management Commands - -Commands for managing roles and role assignments in Account Manager. - -## Global Role Flags - -These flags are available on all role commands: - -| Flag | Environment Variable | Description | -|------|---------------------|-------------| -| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (e.g., `account.demandware.com`) | - -## Authentication - -Role commands require an Account Manager API Client with OAuth authentication. - -### Required Configuration - -| Flag | Environment Variable | Description | -|------|---------------------|-------------| -| `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID for Account Manager | -| `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret for Account Manager | - -### Required Roles - -The API client must have the following role: -- `sfcc.accountmanager.user.manage` - Required for role assignment operations - -### Configuration - -```bash -# Set Account Manager host -export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com - -# Set OAuth credentials -export SFCC_CLIENT_ID=my-client-id -export SFCC_CLIENT_SECRET=my-client-secret - -# List roles -b2c role list -``` - ---- - -## b2c role list - -List roles in Account Manager with pagination support. - -### Usage - -```bash -b2c role list [FLAGS] -``` - -### Flags - -| Flag | Description | Default | -|------|-------------|---------| -| `--page` | Page number (0-based) | `0` | -| `--size` | Number of results per page (1-4000) | `20` | -| `--target-type` | Filter by target type (`User` or `ApiClient`) | All types | -| `--columns` | Comma-separated list of columns to display | Default columns | -| `--extended` | Show all available columns | `false` | -| `--json` | Output results as JSON | `false` | - -### Default Columns - -- ID -- Description -- Scope -- Internal Role - -### Extended Columns - -- Target Type - -### Examples - -```bash -# List first page of roles (default: 20 per page) -b2c role list - -# List roles with custom page size -b2c role list --size 50 - -# Get second page of results -b2c role list --page 1 --size 25 - -# Filter roles by target type -b2c role list --target-type User - -# Show all columns -b2c role list --extended - -# Show only specific columns -b2c role list --columns id,description - -# Output as JSON -b2c role list --json - -# Using environment variables -export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com -export SFCC_CLIENT_ID=my-client-id -export SFCC_CLIENT_SECRET=my-client-secret -b2c role list -``` - -### Output - -Displays a table of roles with the selected columns. If more pages are available, an info message is displayed at the end. - -### Notes - -- Page size must be between 1 and 4000 -- Page number must be a non-negative integer (0-based) -- If the requested page exceeds available data, an error is returned -- Target type filter accepts `User` or `ApiClient` - ---- - -## b2c role get - -Get detailed information about a specific role. - -### Usage - -```bash -b2c role get -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `ROLE_ID` | Role identifier (e.g., `bm-admin`, `SLAS_ORGANIZATION_ADMIN`) | Yes | - -### Flags - -| Flag | Description | -|------|-------------| -| `--json` | Output results as JSON | - -### Examples - -```bash -# Get role details -b2c role get bm-admin - -# Get internal role details -b2c role get SLAS_ORGANIZATION_ADMIN - -# Output as JSON -b2c role get bm-admin --json -``` - -### Output - -When not using `--json`, displays formatted role information including: - -- Basic Information: ID, Description, Scope, Target Type -- Internal Role: Whether this is an internal role - -### Notes - -- Role ID can be either the external role name (e.g., `bm-admin`) or internal role enum name (e.g., `ECOM_ADMIN`) -- If role is not found, an error is returned - ---- - -## b2c role grant - -Grant a role to a user, optionally with tenant scope. - -### Usage - -```bash -b2c role grant --role [FLAGS] -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `LOGIN` | User email address | Yes | - -### Required Flags - -| Flag | Description | -|------|-------------| -| `--role`, `-r` | Role ID to grant (e.g., `bm-admin`) | Yes | - -### Optional Flags - -| Flag | Description | -|------|-------------| -| `--scope`, `-s` | Tenant scope (comma-separated list of tenant IDs). If not provided, grants role without scope restrictions. | -| `--json` | Output results as JSON | - -### Examples - -```bash -# Grant a role without scope -b2c role grant user@example.com --role bm-admin - -# Grant a role with single tenant scope -b2c role grant user@example.com --role bm-admin --scope tenant1 - -# Grant a role with multiple tenant scopes -b2c role grant user@example.com --role bm-admin --scope "tenant1,tenant2" - -# Using short flags -b2c role grant user@example.com -r bm-admin -s tenant1 - -# Output as JSON -b2c role grant user@example.com --role bm-admin --scope tenant1 --json -``` - -### Notes - -- If the user already has the role, the scope will be updated if `--scope` is provided -- If `--scope` is not provided, the role is granted without tenant restrictions -- If `--scope` is provided, it replaces any existing scope for that role -- Multiple scopes can be specified as a comma-separated list -- If user is not found, an error is returned - ---- - -## b2c role revoke - -Revoke a role from a user, optionally removing specific tenant scope. - -### Usage - -```bash -b2c role revoke --role [FLAGS] -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `LOGIN` | User email address | Yes | - -### Required Flags - -| Flag | Description | -|------|-------------| -| `--role`, `-r` | Role ID to revoke (e.g., `bm-admin`) | Yes | - -### Optional Flags - -| Flag | Description | -|------|-------------| -| `--scope`, `-s` | Tenant scope to remove (comma-separated). If not provided, removes the entire role. | -| `--json` | Output results as JSON | - -### Examples - -```bash -# Revoke entire role -b2c role revoke user@example.com --role bm-admin - -# Revoke specific tenant scope (keeps role for other tenants) -b2c role revoke user@example.com --role bm-admin --scope tenant1 - -# Revoke multiple tenant scopes -b2c role revoke user@example.com --role bm-admin --scope "tenant1,tenant2" - -# Using short flags -b2c role revoke user@example.com -r bm-admin -s tenant1 - -# Output as JSON -b2c role revoke user@example.com --role bm-admin --scope tenant1 --json -``` - -### Notes - -- If `--scope` is not provided, the entire role is removed from the user -- If `--scope` is provided, only the specified tenant scopes are removed -- If all scopes are removed, the role itself is removed -- Multiple scopes can be specified as a comma-separated list -- If user is not found, an error is returned diff --git a/docs/cli/user.md b/docs/cli/user.md deleted file mode 100644 index e2d1f401..00000000 --- a/docs/cli/user.md +++ /dev/null @@ -1,352 +0,0 @@ ---- -description: Commands for managing Account Manager users including listing, creating, updating, and deleting users. ---- - -# User Management Commands - -Commands for managing users in Account Manager. - -## Global User Flags - -These flags are available on all user commands: - -| Flag | Environment Variable | Description | -|------|---------------------|-------------| -| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (e.g., `account.demandware.com`) | - -## Authentication - -User commands require an Account Manager API Client with OAuth authentication. - -### Required Configuration - -| Flag | Environment Variable | Description | -|------|---------------------|-------------| -| `--client-id` | `SFCC_CLIENT_ID` | OAuth client ID for Account Manager | -| `--client-secret` | `SFCC_CLIENT_SECRET` | OAuth client secret for Account Manager | - -### Required Roles - -The API client must have the following role: -- `sfcc.accountmanager.user.manage` - Required for all user management operations - -### Configuration - -```bash -# Set Account Manager host -export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com - -# Set OAuth credentials -export SFCC_CLIENT_ID=my-client-id -export SFCC_CLIENT_SECRET=my-client-secret - -# List users -b2c user list -``` - ---- - -## b2c user list - -List users in Account Manager with pagination support. - -### Usage - -```bash -b2c user list [FLAGS] -``` - -### Flags - -| Flag | Description | Default | -|------|-------------|---------| -| `--page` | Page number (0-based) | `0` | -| `--size` | Number of results per page (1-4000) | `20` | -| `--columns` | Comma-separated list of columns to display | Default columns | -| `--extended` | Show all available columns | `false` | -| `--json` | Output results as JSON | `false` | - -### Default Columns - -- Email -- First Name -- Last Name -- State -- Password Expired -- 2FA Enabled -- Linked to SF -- Last Login - -### Extended Columns - -- Roles -- Organizations - -### Examples - -```bash -# List first page of users (default: 20 per page) -b2c user list - -# List users with custom page size -b2c user list --size 50 - -# Get second page of results -b2c user list --page 1 --size 25 - -# Show all columns including roles and organizations -b2c user list --extended - -# Show only specific columns -b2c user list --columns mail,firstName,userState - -# Output as JSON -b2c user list --json - -# Using environment variables -export SFCC_ACCOUNT_MANAGER_HOST=account.demandware.com -export SFCC_CLIENT_ID=my-client-id -export SFCC_CLIENT_SECRET=my-client-secret -b2c user list -``` - -### Output - -Displays a table of users with the selected columns. If more pages are available, an info message is displayed at the end. - -### Notes - -- Page size must be between 1 and 4000 -- Page number must be a non-negative integer (0-based) -- If the requested page exceeds available data, an error is returned - ---- - -## b2c user get - -Get detailed information about a specific user. - -### Usage - -```bash -b2c user get -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `LOGIN` | User email address | Yes | - -### Flags - -| Flag | Description | -|------|-------------| -| `--json` | Output results as JSON | - -### Examples - -```bash -# Get user details -b2c user get user@example.com - -# Output as JSON -b2c user get user@example.com --json -``` - -### Output - -When not using `--json`, displays formatted user information including: - -- Basic Information: ID, Email, Name, State, Organization, etc. -- Organizations: List of organization IDs -- Roles: List of role IDs -- Role Tenant Filters: Role-specific tenant scope mappings - -### Notes - -- User is identified by email address (login) -- If user is not found, an error is returned - ---- - -## b2c user create - -Create a new user in Account Manager. - -### Usage - -```bash -b2c user create --org --mail [FLAGS] -``` - -### Required Flags - -| Flag | Description | -|------|-------------| -| `--org` | Organization ID where the user will be created | -| `--mail` | User email address (login) | - -### Optional Flags - -| Flag | Description | -|------|-------------| -| `--first-name` | User's first name | -| `--last-name` | User's last name | -| `--display-name` | Display name | -| `--preferred-locale` | Preferred locale (e.g., `en_US`) | -| `--business-phone` | Business phone number | -| `--home-phone` | Home phone number | -| `--mobile-phone` | Mobile phone number | -| `--json` | Output results as JSON | - -### Examples - -```bash -# Create a basic user -b2c user create --org org-123 --mail user@example.com \ - --first-name John --last-name Doe - -# Create a user with additional details -b2c user create --org org-123 --mail user@example.com \ - --first-name John --last-name Doe \ - --display-name "John Doe" \ - --preferred-locale en_US \ - --business-phone "+1-555-123-4567" - -# Output as JSON -b2c user create --org org-123 --mail user@example.com \ - --first-name John --last-name Doe --json -``` - -### Notes - -- User will be created in INITIAL state -- User must be assigned roles separately using `b2c role grant` -- The user's primary organization is set to the specified `--org` - ---- - -## b2c user update - -Update an existing user's information. - -### Usage - -```bash -b2c user update [FLAGS] -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `LOGIN` | User email address | Yes | - -### Flags - -| Flag | Description | -|------|-------------| -| `--first-name` | Update first name | -| `--last-name` | Update last name | -| `--display-name` | Update display name | -| `--preferred-locale` | Update preferred locale | -| `--business-phone` | Update business phone | -| `--home-phone` | Update home phone | -| `--mobile-phone` | Update mobile phone | -| `--json` | Output results as JSON | - -### Examples - -```bash -# Update user's first name -b2c user update user@example.com --first-name Jane - -# Update multiple fields -b2c user update user@example.com \ - --first-name Jane \ - --last-name Smith \ - --display-name "Jane Smith" - -# Output as JSON -b2c user update user@example.com --first-name Jane --json -``` - -### Notes - -- At least one field must be provided to update -- Only specified fields will be updated; other fields remain unchanged -- If user is not found, an error is returned - ---- - -## b2c user reset - -Reset a user to INITIAL state, clearing password expiration and allowing password reset. - -### Usage - -```bash -b2c user reset -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `LOGIN` | User email address | Yes | - -### Examples - -```bash -# Reset user to INITIAL state -b2c user reset user@example.com -``` - -### Notes - -- Resets the user's state to INITIAL -- Clears password expiration timestamp -- User will need to set a new password on next login - ---- - -## b2c user delete - -Delete (disable) a user in Account Manager. - -### Usage - -```bash -b2c user delete [FLAGS] -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `LOGIN` | User email address | Yes | - -### Flags - -| Flag | Description | -|------|-------------| -| `--purge` | Permanently delete the user (hard delete). User must be in DELETED state first. | - -### Examples - -```bash -# Soft delete (disable) a user -b2c user delete user@example.com - -# Permanently delete a user (must be in DELETED state first) -b2c user delete user@example.com --purge -``` - -### Notes - -- By default, this performs a soft delete (disables the user) -- Soft delete sets the user state to DELETED -- Use `--purge` for permanent deletion (hard delete) -- Purging requires the user to already be in DELETED state -- Deletion is permanent and cannot be undone diff --git a/packages/b2c-cli/README.md b/packages/b2c-cli/README.md index bfd2ccef..d453aa5c 100644 --- a/packages/b2c-cli/README.md +++ b/packages/b2c-cli/README.md @@ -192,22 +192,22 @@ Manage users in Account Manager. ```sh # List users with pagination -b2c user list --page 0 --size 20 +b2c am users list --page 0 --size 20 # Get user details by email -b2c user get user@example.com +b2c am users get user@example.com # Create a new user -b2c user create --org org-id --mail user@example.com --first-name John --last-name Doe +b2c am users create --org org-id --mail user@example.com --first-name John --last-name Doe # Update a user -b2c user update user@example.com --first-name Jane +b2c am users update user@example.com --first-name Jane # Reset a user to INITIAL state -b2c user reset user@example.com +b2c am users reset user@example.com # Delete (disable) a user -b2c user delete user@example.com +b2c am users delete user@example.com ``` ### Role Management (Account Manager) @@ -216,19 +216,19 @@ Manage roles and role assignments in Account Manager. ```sh # List roles with pagination -b2c role list --page 0 --size 20 --target-type User +b2c am roles list --page 0 --size 20 --target-type User # Get role details -b2c role get bm-admin +b2c am roles get bm-admin # Grant a role to a user -b2c role grant user@example.com --role bm-admin +b2c am roles grant user@example.com --role bm-admin # Grant a role with tenant scope -b2c role grant user@example.com --role bm-admin --scope "tenant1,tenant2" +b2c am roles grant user@example.com --role bm-admin --scope "tenant1,tenant2" # Revoke a role from a user -b2c role revoke user@example.com --role bm-admin +b2c am roles revoke user@example.com --role bm-admin ``` ### Organization Management (Account Manager) @@ -237,22 +237,22 @@ Manage organizations in Account Manager. ```sh # List organizations with pagination -b2c org list --page 0 --size 25 +b2c am orgs list --page 0 --size 25 # List all organizations -b2c org list --all +b2c am orgs list --all # Get organization details by ID -b2c org get org-123 +b2c am orgs get org-123 # Get organization details by name -b2c org get "My Organization" +b2c am orgs get "My Organization" # Get audit logs for an organization -b2c org audit org-123 +b2c am orgs audit org-123 # Get audit logs with extended columns -b2c org audit org-123 --extended +b2c am orgs audit org-123 --extended ``` ### Authentication diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index f4d69d61..479f9e13 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -141,14 +141,19 @@ "sites": { "description": "List and inspect storefront sites\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/sites.html" }, - "user": { - "description": "Manage Account Manager users" - }, - "role": { - "description": "Manage roles for Account Manager users" - }, - "org": { - "description": "Manage Account Manager organizations" + "am": { + "description": "Manage Account Manager resources", + "subtopics": { + "users": { + "description": "Manage Account Manager users" + }, + "roles": { + "description": "Manage roles for Account Manager users" + }, + "orgs": { + "description": "Manage Account Manager organizations" + } + } }, "slas": { "description": "Manage SLAS API clients and credentials\n\nDocs: https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/slas.html", diff --git a/packages/b2c-cli/src/commands/org/audit.ts b/packages/b2c-cli/src/commands/am/orgs/audit.ts similarity index 86% rename from packages/b2c-cli/src/commands/org/audit.ts rename to packages/b2c-cli/src/commands/am/orgs/audit.ts index a7316bb3..e8536396 100644 --- a/packages/b2c-cli/src/commands/org/audit.ts +++ b/packages/b2c-cli/src/commands/am/orgs/audit.ts @@ -4,10 +4,9 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {OrgCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; -import {getOrg, getOrgByName, getOrgAuditLogs} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {AuditLogRecord, AuditLogCollection} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; function formatTimestamp(timestamp: string): string { const date = new Date(timestamp); @@ -50,7 +49,7 @@ const tableRenderer = new TableRenderer(COLUMNS); /** * Command to get audit logs for an Account Manager organization. */ -export default class OrgAudit extends OrgCommand { +export default class OrgAudit extends AmCommand { static args = { org: Args.string({ description: 'Organization ID or name', @@ -86,12 +85,12 @@ export default class OrgAudit extends OrgCommand { // Get organization first (by ID or name) let organization; try { - organization = await getOrg(this.accountManagerOrgsClient, org); + organization = await this.accountManagerClient.getOrg(org); } catch (error) { // If not found by ID, try by name if (error instanceof Error && error.message.includes('not found')) { try { - organization = await getOrgByName(this.accountManagerOrgsClient, org); + organization = await this.accountManagerClient.getOrgByName(org); } catch { throw new Error(t('commands.org.audit.orgNotFound', 'Organization {{org}} not found', {org})); } @@ -102,7 +101,7 @@ export default class OrgAudit extends OrgCommand { this.log(t('commands.org.audit.fetchingLogs', 'Fetching audit logs...')); - const result = await getOrgAuditLogs(this.accountManagerOrgsClient, organization.id); + const result = await this.accountManagerClient.getOrgAuditLogs(organization.id); if (this.jsonEnabled()) { return result; diff --git a/packages/b2c-cli/src/commands/org/get.ts b/packages/b2c-cli/src/commands/am/orgs/get.ts similarity index 94% rename from packages/b2c-cli/src/commands/org/get.ts rename to packages/b2c-cli/src/commands/am/orgs/get.ts index c57d2532..11a0da37 100644 --- a/packages/b2c-cli/src/commands/org/get.ts +++ b/packages/b2c-cli/src/commands/am/orgs/get.ts @@ -5,15 +5,14 @@ */ import {Args, ux} from '@oclif/core'; import cliui from 'cliui'; -import {OrgCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getOrg, getOrgByName} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerOrganization} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; /** * Command to get details of a single Account Manager organization. */ -export default class OrgGet extends OrgCommand { +export default class OrgGet extends AmCommand { static args = { org: Args.string({ description: 'Organization ID or name', @@ -39,12 +38,12 @@ export default class OrgGet extends OrgCommand { // Try to get by ID first, then by name let organization: AccountManagerOrganization; try { - organization = await getOrg(this.accountManagerOrgsClient, org); + organization = await this.accountManagerClient.getOrg(org); } catch (error) { // If not found by ID, try by name if (error instanceof Error && error.message.includes('not found')) { try { - organization = await getOrgByName(this.accountManagerOrgsClient, org); + organization = await this.accountManagerClient.getOrgByName(org); } catch (nameError) { // Preserve the specific error message if it's already a "not found" error if (nameError instanceof Error && nameError.message.includes('not found')) { diff --git a/packages/b2c-cli/src/commands/org/list.ts b/packages/b2c-cli/src/commands/am/orgs/list.ts similarity index 93% rename from packages/b2c-cli/src/commands/org/list.ts rename to packages/b2c-cli/src/commands/am/orgs/list.ts index 426ac375..76869311 100644 --- a/packages/b2c-cli/src/commands/org/list.ts +++ b/packages/b2c-cli/src/commands/am/orgs/list.ts @@ -4,10 +4,9 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Flags, Errors} from '@oclif/core'; -import {OrgCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; -import {listOrgs} from '@salesforce/b2c-tooling-sdk/operations/orgs'; +import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerOrganization, OrganizationCollection} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; const COLUMNS: Record> = { id: { @@ -75,7 +74,7 @@ const tableRenderer = new TableRenderer(COLUMNS); /** * Command to list Account Manager organizations. */ -export default class OrgList extends OrgCommand { +export default class OrgList extends AmCommand { static description = t('commands.org.list.description', 'List Account Manager organizations'); static enableJsonFlag = true; @@ -129,7 +128,7 @@ export default class OrgList extends OrgCommand { this.log(t('commands.org.list.fetching', 'Fetching organizations...')); - const result = await listOrgs(this.accountManagerOrgsClient, { + const result = await this.accountManagerClient.listOrgs({ size, page, all, diff --git a/packages/b2c-cli/src/commands/role/get.ts b/packages/b2c-cli/src/commands/am/roles/get.ts similarity index 88% rename from packages/b2c-cli/src/commands/role/get.ts rename to packages/b2c-cli/src/commands/am/roles/get.ts index 47ef799f..22111265 100644 --- a/packages/b2c-cli/src/commands/role/get.ts +++ b/packages/b2c-cli/src/commands/am/roles/get.ts @@ -5,15 +5,14 @@ */ import {Args, ux} from '@oclif/core'; import cliui from 'cliui'; -import {RoleCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getRole} from '@salesforce/b2c-tooling-sdk/operations/roles'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerRole} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; /** * Command to get details of a single Account Manager role. */ -export default class RoleGet extends RoleCommand { +export default class RoleGet extends AmCommand { static args = { roleId: Args.string({ description: 'Role ID', @@ -35,7 +34,7 @@ export default class RoleGet extends RoleCommand { this.log(t('commands.role.get.fetching', 'Fetching role {{roleId}}...', {roleId})); - const role = await getRole(this.accountManagerRolesClient, roleId); + const role = await this.accountManagerClient.getRole(roleId); if (this.jsonEnabled()) { return role; diff --git a/packages/b2c-cli/src/commands/role/grant.ts b/packages/b2c-cli/src/commands/am/roles/grant.ts similarity index 80% rename from packages/b2c-cli/src/commands/role/grant.ts rename to packages/b2c-cli/src/commands/am/roles/grant.ts index 0586b76e..ee5d7efd 100644 --- a/packages/b2c-cli/src/commands/role/grant.ts +++ b/packages/b2c-cli/src/commands/am/roles/grant.ts @@ -4,15 +4,14 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getUserByLogin, grantRole} from '@salesforce/b2c-tooling-sdk/operations/users'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; /** * Command to grant a role to an Account Manager user. */ -export default class RoleGrant extends UserCommand { +export default class RoleGrant extends AmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -47,7 +46,11 @@ export default class RoleGrant extends UserCommand { this.log(t('commands.role.grant.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerUsersClient, login); + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.role.grant.notFound', 'User {{login}} not found', {login})); + } if (!user.id) { this.error(t('commands.role.grant.noId', 'User does not have an ID')); @@ -60,11 +63,7 @@ export default class RoleGrant extends UserCommand { }), ); - const updatedUser = await grantRole(this.accountManagerUsersClient, { - userId: user.id, - role, - scope, - }); + const updatedUser = await this.accountManagerClient.grantRole(user.id, role, scope); if (this.jsonEnabled()) { return updatedUser; diff --git a/packages/b2c-cli/src/commands/role/list.ts b/packages/b2c-cli/src/commands/am/roles/list.ts similarity index 93% rename from packages/b2c-cli/src/commands/role/list.ts rename to packages/b2c-cli/src/commands/am/roles/list.ts index aadcea04..3ce6d828 100644 --- a/packages/b2c-cli/src/commands/role/list.ts +++ b/packages/b2c-cli/src/commands/am/roles/list.ts @@ -4,10 +4,9 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Flags, Errors} from '@oclif/core'; -import {RoleCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; -import {listRoles} from '@salesforce/b2c-tooling-sdk/operations/roles'; +import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerRole, RoleCollection} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; const COLUMNS: Record> = { id: { @@ -51,7 +50,7 @@ const tableRenderer = new TableRenderer(COLUMNS); /** * Command to list Account Manager roles. */ -export default class RoleList extends RoleCommand { +export default class RoleList extends AmCommand { static description = t('commands.role.list.description', 'List Account Manager roles'); static enableJsonFlag = true; @@ -117,7 +116,7 @@ export default class RoleList extends RoleCommand { this.log(t('commands.role.list.fetching', 'Fetching roles...')); - const result = await listRoles(this.accountManagerRolesClient, { + const result = await this.accountManagerClient.listRoles({ size: pageSize, page: pageNumber, roleTargetType: targetType as 'ApiClient' | 'User' | undefined, diff --git a/packages/b2c-cli/src/commands/role/revoke.ts b/packages/b2c-cli/src/commands/am/roles/revoke.ts similarity index 80% rename from packages/b2c-cli/src/commands/role/revoke.ts rename to packages/b2c-cli/src/commands/am/roles/revoke.ts index 9c240f73..ce30100c 100644 --- a/packages/b2c-cli/src/commands/role/revoke.ts +++ b/packages/b2c-cli/src/commands/am/roles/revoke.ts @@ -4,15 +4,14 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getUserByLogin, revokeRole} from '@salesforce/b2c-tooling-sdk/operations/users'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; /** * Command to revoke a role from an Account Manager user. */ -export default class RoleRevoke extends UserCommand { +export default class RoleRevoke extends AmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -47,7 +46,11 @@ export default class RoleRevoke extends UserCommand { this.log(t('commands.role.revoke.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerUsersClient, login); + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.role.revoke.notFound', 'User {{login}} not found', {login})); + } if (!user.id) { this.error(t('commands.role.revoke.noId', 'User does not have an ID')); @@ -60,11 +63,7 @@ export default class RoleRevoke extends UserCommand { }), ); - const updatedUser = await revokeRole(this.accountManagerUsersClient, { - userId: user.id, - role, - scope, - }); + const updatedUser = await this.accountManagerClient.revokeRole(user.id, role, scope); if (this.jsonEnabled()) { return updatedUser; diff --git a/packages/b2c-cli/src/commands/user/create.ts b/packages/b2c-cli/src/commands/am/users/create.ts similarity index 78% rename from packages/b2c-cli/src/commands/user/create.ts rename to packages/b2c-cli/src/commands/am/users/create.ts index ef03f8ca..61db5c85 100644 --- a/packages/b2c-cli/src/commands/user/create.ts +++ b/packages/b2c-cli/src/commands/am/users/create.ts @@ -4,15 +4,14 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Flags} from '@oclif/core'; -import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {createUser} from '@salesforce/b2c-tooling-sdk/operations/users'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; /** * Command to create a new Account Manager user. */ -export default class UserCreate extends UserCommand { +export default class UserCreate extends AmCommand { static description = t('commands.user.create.description', 'Create a new Account Manager user'); static enableJsonFlag = true; @@ -48,14 +47,12 @@ export default class UserCreate extends UserCommand { this.log(t('commands.user.create.creating', 'Creating user {{mail}} in organization {{org}}...', {mail, org})); - const user = await createUser(this.accountManagerUsersClient, { - user: { - mail, - firstName, - lastName, - organizations: [org], - primaryOrganization: org, - }, + const user = await this.accountManagerClient.createUser({ + mail, + firstName, + lastName, + organizations: [org], + primaryOrganization: org, }); if (this.jsonEnabled()) { diff --git a/packages/b2c-cli/src/commands/user/delete.ts b/packages/b2c-cli/src/commands/am/users/delete.ts similarity index 78% rename from packages/b2c-cli/src/commands/user/delete.ts rename to packages/b2c-cli/src/commands/am/users/delete.ts index 4c0e5c1f..7847510e 100644 --- a/packages/b2c-cli/src/commands/user/delete.ts +++ b/packages/b2c-cli/src/commands/am/users/delete.ts @@ -4,14 +4,13 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getUserByLogin, deleteUser, purgeUser} from '@salesforce/b2c-tooling-sdk/operations/users'; -import {t} from '../../i18n/index.js'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../../i18n/index.js'; /** * Command to delete an Account Manager user. */ -export default class UserDelete extends UserCommand { +export default class UserDelete extends AmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -41,7 +40,11 @@ export default class UserDelete extends UserCommand { this.log(t('commands.user.delete.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerUsersClient, login); + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.user.delete.notFound', 'User {{login}} not found', {login})); + } if (!user.id) { this.error(t('commands.user.delete.noId', 'User does not have an ID')); @@ -53,14 +56,14 @@ export default class UserDelete extends UserCommand { login, }), ); - await purgeUser(this.accountManagerUsersClient, user.id); + await this.accountManagerClient.purgeUser(user.id); } else { this.log( t('commands.user.delete.deleting', 'Deleting user {{login}}...', { login, }), ); - await deleteUser(this.accountManagerUsersClient, user.id); + await this.accountManagerClient.deleteUser(user.id); } if (this.jsonEnabled()) { diff --git a/packages/b2c-cli/src/commands/user/get.ts b/packages/b2c-cli/src/commands/am/users/get.ts similarity index 64% rename from packages/b2c-cli/src/commands/user/get.ts rename to packages/b2c-cli/src/commands/am/users/get.ts index 73feb754..19db7d53 100644 --- a/packages/b2c-cli/src/commands/user/get.ts +++ b/packages/b2c-cli/src/commands/am/users/get.ts @@ -3,17 +3,22 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import {Args, ux} from '@oclif/core'; +import {Args, Flags, ux} from '@oclif/core'; import cliui from 'cliui'; -import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getUserByLogin} from '@salesforce/b2c-tooling-sdk/operations/users'; -import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {AccountManagerUser, UserExpandOption} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../../i18n/index.js'; + +/** + * Valid expand values for the users API. + * Extracted from the generated API types to ensure consistency. + */ +const VALID_EXPAND_VALUES: readonly UserExpandOption[] = ['organizations', 'roles'] as const; /** * Command to get details of a single Account Manager user. */ -export default class UserGet extends UserCommand { +export default class UserGet extends AmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -28,14 +33,75 @@ export default class UserGet extends UserCommand { static examples = [ '<%= config.bin %> <%= command.id %> user@example.com', '<%= config.bin %> <%= command.id %> user@example.com --json', + '<%= config.bin %> <%= command.id %> user@example.com --expand-all', + '<%= config.bin %> <%= command.id %> user@example.com --expand organizations', + '<%= config.bin %> <%= command.id %> user@example.com --expand organizations,roles', ]; + static flags = { + expand: Flags.string({ + description: t( + 'flags.expand.description', + 'Comma-separated list of fields to expand. Valid values: organizations, roles', + ), + multiple: false, + }), + 'expand-all': Flags.boolean({ + description: t('flags.expandAll.description', 'Expand both organizations and roles'), + default: false, + }), + }; + async run(): Promise { const {login} = this.args; + const {expand: expandRaw, 'expand-all': expandAll} = this.flags; + + // Validate and process expand flags + let expand: undefined | UserExpandOption[]; + + if (expandAll) { + // --expand-all expands both + expand = ['organizations', 'roles']; + } else if (expandRaw) { + // Parse comma-separated values + const expandValues = expandRaw + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + + // Validate each value + const invalidValues: string[] = []; + const validValues: UserExpandOption[] = []; + + for (const value of expandValues) { + if (VALID_EXPAND_VALUES.includes(value as UserExpandOption)) { + validValues.push(value as UserExpandOption); + } else { + invalidValues.push(value); + } + } + + if (invalidValues.length > 0) { + this.error( + t('commands.user.get.invalidExpand', 'Invalid expand value(s): {{invalid}}. Valid values are: {{valid}}', { + invalid: invalidValues.join(', '), + valid: VALID_EXPAND_VALUES.join(', '), + }), + ); + } + + if (validValues.length > 0) { + expand = validValues; + } + } this.log(t('commands.user.get.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerUsersClient, login); + const user = await this.accountManagerClient.findUserByLogin(login, expand); + + if (!user) { + this.error(t('commands.user.get.notFound', 'User {{login}} not found', {login})); + } if (this.jsonEnabled()) { return user; diff --git a/packages/b2c-cli/src/commands/user/list.ts b/packages/b2c-cli/src/commands/am/users/list.ts similarity index 93% rename from packages/b2c-cli/src/commands/user/list.ts rename to packages/b2c-cli/src/commands/am/users/list.ts index 1fad7019..d42722cb 100644 --- a/packages/b2c-cli/src/commands/user/list.ts +++ b/packages/b2c-cli/src/commands/am/users/list.ts @@ -4,10 +4,9 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Flags, Errors} from '@oclif/core'; -import {UserCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; -import {listUsers} from '@salesforce/b2c-tooling-sdk/operations/users'; +import {AmCommand, TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerUser, UserCollection} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; const COLUMNS: Record> = { mail: { @@ -76,7 +75,7 @@ const tableRenderer = new TableRenderer(COLUMNS); /** * Command to list Account Manager users. */ -export default class UserList extends UserCommand { +export default class UserList extends AmCommand { static description = t('commands.user.list.description', 'List Account Manager users'); static enableJsonFlag = true; @@ -137,7 +136,7 @@ export default class UserList extends UserCommand { this.log(t('commands.user.list.fetching', 'Fetching users...')); - const result = await listUsers(this.accountManagerUsersClient, { + const result = await this.accountManagerClient.listUsers({ size: pageSize, page: pageNumber, }); diff --git a/packages/b2c-cli/src/commands/user/reset.ts b/packages/b2c-cli/src/commands/am/users/reset.ts similarity index 75% rename from packages/b2c-cli/src/commands/user/reset.ts rename to packages/b2c-cli/src/commands/am/users/reset.ts index e51d00f4..88f36522 100644 --- a/packages/b2c-cli/src/commands/user/reset.ts +++ b/packages/b2c-cli/src/commands/am/users/reset.ts @@ -4,14 +4,13 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args} from '@oclif/core'; -import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getUserByLogin, resetUser} from '@salesforce/b2c-tooling-sdk/operations/users'; -import {t} from '../../i18n/index.js'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {t} from '../../../i18n/index.js'; /** * Command to reset an Account Manager user password. */ -export default class UserReset extends UserCommand { +export default class UserReset extends AmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -33,7 +32,11 @@ export default class UserReset extends UserCommand { this.log(t('commands.user.reset.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerUsersClient, login); + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.user.reset.notFound', 'User {{login}} not found', {login})); + } if (!user.id) { this.error(t('commands.user.reset.noId', 'User does not have an ID')); @@ -41,7 +44,7 @@ export default class UserReset extends UserCommand { this.log(t('commands.user.reset.resetting', 'Resetting password for user {{login}}...', {login})); - await resetUser(this.accountManagerUsersClient, user.id); + await this.accountManagerClient.resetUser(user.id); if (this.jsonEnabled()) { return; diff --git a/packages/b2c-cli/src/commands/user/update.ts b/packages/b2c-cli/src/commands/am/users/update.ts similarity index 80% rename from packages/b2c-cli/src/commands/user/update.ts rename to packages/b2c-cli/src/commands/am/users/update.ts index 8be83cf0..d6203cb4 100644 --- a/packages/b2c-cli/src/commands/user/update.ts +++ b/packages/b2c-cli/src/commands/am/users/update.ts @@ -4,15 +4,14 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ import {Args, Flags} from '@oclif/core'; -import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getUserByLogin, updateUser} from '@salesforce/b2c-tooling-sdk/operations/users'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import type {AccountManagerUser, UserUpdate as UserUpdateType} from '@salesforce/b2c-tooling-sdk'; -import {t} from '../../i18n/index.js'; +import {t} from '../../../i18n/index.js'; /** * Command to update an Account Manager user. */ -export default class UserUpdate extends UserCommand { +export default class UserUpdate extends AmCommand { static args = { login: Args.string({ description: 'User login (email)', @@ -44,7 +43,11 @@ export default class UserUpdate extends UserCommand { this.log(t('commands.user.update.fetching', 'Fetching user {{login}}...', {login})); - const user = await getUserByLogin(this.accountManagerUsersClient, login); + const user = await this.accountManagerClient.findUserByLogin(login); + + if (!user) { + this.error(t('commands.user.update.notFound', 'User {{login}} not found', {login})); + } if (!user.id) { this.error(t('commands.user.update.noId', 'User does not have an ID')); @@ -64,10 +67,7 @@ export default class UserUpdate extends UserCommand { this.log(t('commands.user.update.updating', 'Updating user {{login}}...', {login})); - const updatedUser = await updateUser(this.accountManagerUsersClient, { - userId: user.id, - changes: changes as UserUpdateType, - }); + const updatedUser = await this.accountManagerClient.updateUser(user.id, changes as UserUpdateType); if (this.jsonEnabled()) { return updatedUser; diff --git a/packages/b2c-cli/test/commands/org/audit.test.ts b/packages/b2c-cli/test/commands/am/orgs/audit.test.ts similarity index 99% rename from packages/b2c-cli/test/commands/org/audit.test.ts rename to packages/b2c-cli/test/commands/am/orgs/audit.test.ts index d6273dad..e66238f9 100644 --- a/packages/b2c-cli/test/commands/org/audit.test.ts +++ b/packages/b2c-cli/test/commands/am/orgs/audit.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import OrgAudit from '../../../src/commands/org/audit.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import OrgAudit from '../../../../src/commands/am/orgs/audit.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/org/get.test.ts b/packages/b2c-cli/test/commands/am/orgs/get.test.ts similarity index 98% rename from packages/b2c-cli/test/commands/org/get.test.ts rename to packages/b2c-cli/test/commands/am/orgs/get.test.ts index 3ab90e52..6b310e32 100644 --- a/packages/b2c-cli/test/commands/org/get.test.ts +++ b/packages/b2c-cli/test/commands/am/orgs/get.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import OrgGet from '../../../src/commands/org/get.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import OrgGet from '../../../../src/commands/am/orgs/get.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/org/list.test.ts b/packages/b2c-cli/test/commands/am/orgs/list.test.ts similarity index 98% rename from packages/b2c-cli/test/commands/org/list.test.ts rename to packages/b2c-cli/test/commands/am/orgs/list.test.ts index 3d8a8d1b..758b2111 100644 --- a/packages/b2c-cli/test/commands/org/list.test.ts +++ b/packages/b2c-cli/test/commands/am/orgs/list.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import OrgList from '../../../src/commands/org/list.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import OrgList from '../../../../src/commands/am/orgs/list.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/role/get.test.ts b/packages/b2c-cli/test/commands/am/roles/get.test.ts similarity index 97% rename from packages/b2c-cli/test/commands/role/get.test.ts rename to packages/b2c-cli/test/commands/am/roles/get.test.ts index 37bd1597..eefc47f8 100644 --- a/packages/b2c-cli/test/commands/role/get.test.ts +++ b/packages/b2c-cli/test/commands/am/roles/get.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import RoleGet from '../../../src/commands/role/get.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import RoleGet from '../../../../src/commands/am/roles/get.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/role/grant.test.ts b/packages/b2c-cli/test/commands/am/roles/grant.test.ts similarity index 98% rename from packages/b2c-cli/test/commands/role/grant.test.ts rename to packages/b2c-cli/test/commands/am/roles/grant.test.ts index d479eb96..2487375a 100644 --- a/packages/b2c-cli/test/commands/role/grant.test.ts +++ b/packages/b2c-cli/test/commands/am/roles/grant.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import RoleGrant from '../../../src/commands/role/grant.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import RoleGrant from '../../../../src/commands/am/roles/grant.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/role/list.test.ts b/packages/b2c-cli/test/commands/am/roles/list.test.ts similarity index 98% rename from packages/b2c-cli/test/commands/role/list.test.ts rename to packages/b2c-cli/test/commands/am/roles/list.test.ts index 43d39bba..7acd871d 100644 --- a/packages/b2c-cli/test/commands/role/list.test.ts +++ b/packages/b2c-cli/test/commands/am/roles/list.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import RoleList from '../../../src/commands/role/list.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import RoleList from '../../../../src/commands/am/roles/list.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/role/revoke.test.ts b/packages/b2c-cli/test/commands/am/roles/revoke.test.ts similarity index 98% rename from packages/b2c-cli/test/commands/role/revoke.test.ts rename to packages/b2c-cli/test/commands/am/roles/revoke.test.ts index 23c3fabb..0db75a12 100644 --- a/packages/b2c-cli/test/commands/role/revoke.test.ts +++ b/packages/b2c-cli/test/commands/am/roles/revoke.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import RoleRevoke from '../../../src/commands/role/revoke.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import RoleRevoke from '../../../../src/commands/am/roles/revoke.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/user/create.test.ts b/packages/b2c-cli/test/commands/am/users/create.test.ts similarity index 97% rename from packages/b2c-cli/test/commands/user/create.test.ts rename to packages/b2c-cli/test/commands/am/users/create.test.ts index 14157c13..714cdc18 100644 --- a/packages/b2c-cli/test/commands/user/create.test.ts +++ b/packages/b2c-cli/test/commands/am/users/create.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import UserCreate from '../../../src/commands/user/create.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import UserCreate from '../../../../src/commands/am/users/create.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/user/delete.test.ts b/packages/b2c-cli/test/commands/am/users/delete.test.ts similarity index 97% rename from packages/b2c-cli/test/commands/user/delete.test.ts rename to packages/b2c-cli/test/commands/am/users/delete.test.ts index 76bb7a55..e4d62395 100644 --- a/packages/b2c-cli/test/commands/user/delete.test.ts +++ b/packages/b2c-cli/test/commands/am/users/delete.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import UserDelete from '../../../src/commands/user/delete.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import UserDelete from '../../../../src/commands/am/users/delete.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/am/users/get.test.ts b/packages/b2c-cli/test/commands/am/users/get.test.ts new file mode 100644 index 00000000..838f8822 --- /dev/null +++ b/packages/b2c-cli/test/commands/am/users/get.test.ts @@ -0,0 +1,466 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import UserGet from '../../../../src/commands/am/users/get.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; + +const TEST_HOST = 'account.test.demandware.com'; +const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; +const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; + +function createMockJWT(payload: Record = {}): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); + const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); + return `${headerB64}.${payloadB64}.signature`; +} + +/** + * Helper function to extract expand values from URL search parameters. + * Reduces callback nesting in tests. + */ +function extractExpandValues(url: URL): string[] { + const expandParam = url.searchParams.get('expand'); + const allExpandParams = url.searchParams.getAll('expand'); + const expandValues: string[] = []; + if (expandParam) { + expandValues.push(...expandParam.split(',')); + } + for (const param of allExpandParams) { + expandValues.push(...param.split(',')); + } + // Remove duplicates and empty strings + return [...new Set(expandValues.filter(Boolean))]; +} + +/** + * Unit tests for user get command CLI logic. + * Tests output formatting. + * SDK tests cover the actual API calls. + */ +describe('user get', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + server.resetHandlers(); + restoreConfig(); + }); + + after(() => { + server.close(); + }); + + describe('command structure', () => { + it('should require login as argument', () => { + expect(UserGet.args).to.have.property('login'); + expect(UserGet.args.login.required).to.be.true; + }); + + it('should have correct description', () => { + expect(UserGet.description).to.be.a('string'); + expect(UserGet.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(UserGet.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return user data in JSON mode', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + userState: 'ACTIVE', + primaryOrganization: 'org-123', + passwordExpirationTimestamp: null, + verifiers: [], + linkedToSfIdentity: false, + lastLoginDate: '2025-01-01', + createdAt: 1_234_567_890, + lastModified: 1_234_567_890, + roles: ['bm-admin'], + organizations: ['org-123'], + roleTenantFilterMap: { + 'bm-admin': 'tenant1,tenant2', + }, + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + expect(result.mail).to.equal('user@example.com'); + }); + + it('should return user data in non-JSON mode', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(mockUser); + }); + + it('should handle API errors', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'nonexistent@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages - return empty result + return HttpResponse.json({content: []}); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('not found'); + } + }); + }); + + describe('expand functionality', () => { + it('should support --expand-all flag', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {'expand-all': true}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + }; + + const expandedUser = { + ...mockUser, + organizations: [{id: 'org-123', name: 'Test Org'}], + roles: [{id: 'bm-admin', description: 'Admin Role'}], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + // findUserByLogin searches through pages and filters by mail + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, ({request}) => { + // Verify expand parameter is passed + const url = new URL(request.url); + const uniqueValues = extractExpandValues(url); + expect(uniqueValues).to.include('organizations'); + expect(uniqueValues).to.include('roles'); + return HttpResponse.json(expandedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(expandedUser); + }); + + it('should support --expand with valid single value', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {expand: 'organizations'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + }; + + const expandedUser = { + ...mockUser, + organizations: [{id: 'org-123', name: 'Test Org'}], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, ({request}) => { + const url = new URL(request.url); + const uniqueValues = extractExpandValues(url); + expect(uniqueValues).to.include('organizations'); + expect(uniqueValues).to.not.include('roles'); + return HttpResponse.json(expandedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(expandedUser); + }); + + it('should support --expand with comma-separated values', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {expand: 'organizations,roles'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockUser = { + id: 'user-123', + mail: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + userState: 'ACTIVE', + }; + + const expandedUser = { + ...mockUser, + organizations: [{id: 'org-123', name: 'Test Org'}], + roles: [{id: 'bm-admin', description: 'Admin Role'}], + }; + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + http.get(`${BASE_URL}/users`, () => { + return HttpResponse.json({content: [mockUser]}); + }), + http.get(`${BASE_URL}/users/user-123`, ({request}) => { + const url = new URL(request.url); + const uniqueValues = extractExpandValues(url); + expect(uniqueValues).to.include('organizations'); + expect(uniqueValues).to.include('roles'); + return HttpResponse.json(expandedUser); + }), + ); + + const result = await command.run(); + + expect(result).to.deep.equal(expandedUser); + }); + + it('should reject invalid expand values', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {expand: 'invalid'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Invalid expand value'); + expect((error as Error).message).to.include('invalid'); + expect((error as Error).message).to.include('organizations'); + expect((error as Error).message).to.include('roles'); + } + }); + + it('should reject mixed valid and invalid expand values', async () => { + const command = new UserGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {login: 'user@example.com'}, + configurable: true, + }); + + Object.defineProperty(command, 'flags', { + value: {expand: 'organizations,invalid'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + server.use( + http.post(OAUTH_URL, () => { + return HttpResponse.json({ + access_token: createMockJWT({sub: 'test-client'}), + expires_in: 1800, + scope: 'sfcc.accountmanager.user.manage', + }); + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Invalid expand value'); + expect((error as Error).message).to.include('invalid'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/user/list.test.ts b/packages/b2c-cli/test/commands/am/users/list.test.ts similarity index 98% rename from packages/b2c-cli/test/commands/user/list.test.ts rename to packages/b2c-cli/test/commands/am/users/list.test.ts index 0b159004..39efa945 100644 --- a/packages/b2c-cli/test/commands/user/list.test.ts +++ b/packages/b2c-cli/test/commands/am/users/list.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import UserList from '../../../src/commands/user/list.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import UserList from '../../../../src/commands/am/users/list.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/user/update.test.ts b/packages/b2c-cli/test/commands/am/users/update.test.ts similarity index 98% rename from packages/b2c-cli/test/commands/user/update.test.ts rename to packages/b2c-cli/test/commands/am/users/update.test.ts index 62cebab1..7f5fcc21 100644 --- a/packages/b2c-cli/test/commands/user/update.test.ts +++ b/packages/b2c-cli/test/commands/am/users/update.test.ts @@ -9,8 +9,8 @@ import sinon from 'sinon'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import UserUpdate from '../../../src/commands/user/update.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; +import UserUpdate from '../../../../src/commands/am/users/update.js'; +import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; diff --git a/packages/b2c-cli/test/commands/user/get.test.ts b/packages/b2c-cli/test/commands/user/get.test.ts deleted file mode 100644 index ae3ba832..00000000 --- a/packages/b2c-cli/test/commands/user/get.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ - -import {expect} from 'chai'; -import sinon from 'sinon'; -import {http, HttpResponse} from 'msw'; -import {setupServer} from 'msw/node'; -import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import UserGet from '../../../src/commands/user/get.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../helpers/test-setup.js'; - -const TEST_HOST = 'account.test.demandware.com'; -const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; -const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; - -function createMockJWT(payload: Record = {}): string { - const header = {alg: 'HS256', typ: 'JWT'}; - const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; - const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); - const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); - return `${headerB64}.${payloadB64}.signature`; -} - -/** - * Unit tests for user get command CLI logic. - * Tests output formatting. - * SDK tests cover the actual API calls. - */ -describe('user get', () => { - const server = setupServer(); - - before(() => { - server.listen({onUnhandledRequest: 'error'}); - }); - - beforeEach(() => { - isolateConfig(); - }); - - afterEach(() => { - sinon.restore(); - server.resetHandlers(); - restoreConfig(); - }); - - after(() => { - server.close(); - }); - - describe('command structure', () => { - it('should require login as argument', () => { - expect(UserGet.args).to.have.property('login'); - expect(UserGet.args.login.required).to.be.true; - }); - - it('should have correct description', () => { - expect(UserGet.description).to.be.a('string'); - expect(UserGet.description.length).to.be.greaterThan(0); - }); - - it('should enable JSON flag', () => { - expect(UserGet.enableJsonFlag).to.be.true; - }); - }); - - describe('output formatting', () => { - it('should return user data in JSON mode', async () => { - const command = new UserGet([], {} as any); - - Object.defineProperty(command, 'args', { - value: {login: 'user@example.com'}, - configurable: true, - }); - - stubCommandConfigAndLogger(command); - stubJsonEnabled(command, true); - - const mockUser = { - id: 'user-123', - mail: 'user@example.com', - firstName: 'John', - lastName: 'Doe', - displayName: 'John Doe', - userState: 'ACTIVE', - primaryOrganization: 'org-123', - passwordExpirationTimestamp: null, - verifiers: [], - linkedToSfIdentity: false, - lastLoginDate: '2025-01-01', - createdAt: 1_234_567_890, - lastModified: 1_234_567_890, - roles: ['bm-admin'], - organizations: ['org-123'], - roleTenantFilterMap: { - 'bm-admin': 'tenant1,tenant2', - }, - }; - - server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), - http.get(`${BASE_URL}/users`, () => { - // findUserByLogin searches through pages and filters by mail - return HttpResponse.json({content: [mockUser]}); - }), - ); - - const result = await command.run(); - - expect(result).to.deep.equal(mockUser); - expect(result.mail).to.equal('user@example.com'); - }); - - it('should return user data in non-JSON mode', async () => { - const command = new UserGet([], {} as any); - - Object.defineProperty(command, 'args', { - value: {login: 'user@example.com'}, - configurable: true, - }); - - stubCommandConfigAndLogger(command); - stubJsonEnabled(command, false); - - const mockUser = { - id: 'user-123', - mail: 'user@example.com', - firstName: 'John', - lastName: 'Doe', - userState: 'ACTIVE', - }; - - server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), - http.get(`${BASE_URL}/users`, () => { - // findUserByLogin searches through pages and filters by mail - return HttpResponse.json({content: [mockUser]}); - }), - ); - - const result = await command.run(); - - expect(result).to.deep.equal(mockUser); - }); - - it('should handle API errors', async () => { - const command = new UserGet([], {} as any); - - Object.defineProperty(command, 'args', { - value: {login: 'nonexistent@example.com'}, - configurable: true, - }); - - stubCommandConfigAndLogger(command); - makeCommandThrowOnError(command); - - server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), - http.get(`${BASE_URL}/users`, () => { - // findUserByLogin searches through pages - return empty result - return HttpResponse.json({content: []}); - }), - ); - - try { - await command.run(); - expect.fail('Should have thrown'); - } catch (error: unknown) { - expect((error as Error).message).to.include('not found'); - } - }); - }); -}); diff --git a/packages/b2c-tooling-sdk/src/cli/am-command.ts b/packages/b2c-tooling-sdk/src/cli/am-command.ts new file mode 100644 index 00000000..f944467c --- /dev/null +++ b/packages/b2c-tooling-sdk/src/cli/am-command.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Command} from '@oclif/core'; +import {OAuthCommand} from './oauth-command.js'; +import {createAccountManagerClient} from '../clients/am-api.js'; +import type {AccountManagerClient} from '../clients/am-api.js'; + +/** + * Base command for Account Manager operations. + * + * Extends OAuthCommand with Account Manager client setup for users, roles, and organizations. + * + * @example + * export default class UserList extends AmCommand { + * async run(): Promise { + * const users = await listUsers(this.accountManagerUsersClient, {}); + * // ... + * } + * } + * + * @example + * export default class OrgList extends AmCommand { + * async run(): Promise { + * const orgs = await this.accountManagerOrgsClient.listOrgs(); + * // ... + * } + * } + */ +export abstract class AmCommand extends OAuthCommand { + private _accountManagerClient?: AccountManagerClient; + + /** + * Gets the unified Account Manager client, creating it if necessary. + * This provides direct access to all Account Manager API methods (users, roles, orgs). + * + * @example + * const client = this.accountManagerClient; + * const users = await client.listUsers({}); + * const roles = await client.listRoles({}); + * const orgs = await client.listOrgs(); + * const user = await client.getUser('user-id'); + * const role = await client.getRole('bm-admin'); + * const org = await client.getOrg('org-id'); + */ + protected get accountManagerClient(): AccountManagerClient { + if (!this._accountManagerClient) { + this.requireOAuthCredentials(); + const authStrategy = this.getOAuthStrategy(); + this._accountManagerClient = createAccountManagerClient( + { + hostname: this.accountManagerHost, + }, + authStrategy, + ); + } + return this._accountManagerClient; + } +} diff --git a/packages/b2c-tooling-sdk/src/cli/index.ts b/packages/b2c-tooling-sdk/src/cli/index.ts index 503d51cb..f25484c5 100644 --- a/packages/b2c-tooling-sdk/src/cli/index.ts +++ b/packages/b2c-tooling-sdk/src/cli/index.ts @@ -99,9 +99,7 @@ export {CartridgeCommand} from './cartridge-command.js'; export {JobCommand} from './job-command.js'; export {MrtCommand} from './mrt-command.js'; export {OdsCommand} from './ods-command.js'; -export {UserCommand} from './user-command.js'; -export {RoleCommand} from './role-command.js'; -export {OrgCommand} from './org-command.js'; +export {AmCommand} from './am-command.js'; export {WebDavCommand, WEBDAV_ROOTS, VALID_ROOTS} from './webdav-command.js'; export type {WebDavRootKey} from './webdav-command.js'; diff --git a/packages/b2c-tooling-sdk/src/cli/org-command.ts b/packages/b2c-tooling-sdk/src/cli/org-command.ts deleted file mode 100644 index b1880b73..00000000 --- a/packages/b2c-tooling-sdk/src/cli/org-command.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ -import {Command} from '@oclif/core'; -import {OAuthCommand} from './oauth-command.js'; -import {createAccountManagerOrgsClient} from '../clients/am-orgs-api.js'; -import type {AccountManagerOrgsClient} from '../clients/am-orgs-api.js'; - -/** - * Base command for Account Manager organization operations. - * - * Extends OAuthCommand with Account Manager Organizations client setup. - * - * @example - * export default class OrgList extends OrgCommand { - * async run(): Promise { - * const orgs = await this.accountManagerOrgsClient.listOrgs(); - * // ... - * } - * } - */ -export abstract class OrgCommand extends OAuthCommand { - private _accountManagerOrgsClient?: AccountManagerOrgsClient; - - /** - * Gets the Account Manager Organizations client, creating it if necessary. - */ - protected get accountManagerOrgsClient(): AccountManagerOrgsClient { - if (!this._accountManagerOrgsClient) { - this.requireOAuthCredentials(); - const authStrategy = this.getOAuthStrategy(); - this._accountManagerOrgsClient = createAccountManagerOrgsClient( - { - hostname: this.accountManagerHost, - }, - authStrategy, - ); - } - return this._accountManagerOrgsClient; - } -} diff --git a/packages/b2c-tooling-sdk/src/cli/role-command.ts b/packages/b2c-tooling-sdk/src/cli/role-command.ts deleted file mode 100644 index b6d528b5..00000000 --- a/packages/b2c-tooling-sdk/src/cli/role-command.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ -import {Command} from '@oclif/core'; -import {OAuthCommand} from './oauth-command.js'; -import {createAccountManagerRolesClient} from '../clients/am-roles-api.js'; -import type {AccountManagerRolesClient} from '../clients/am-roles-api.js'; - -/** - * Base command for Account Manager role operations. - * - * Extends OAuthCommand with Account Manager Roles client setup. - * - * @example - * export default class RoleList extends RoleCommand { - * async run(): Promise { - * const roles = await listRoles(this.accountManagerRolesClient, {}); - * // ... - * } - * } - */ -export abstract class RoleCommand extends OAuthCommand { - private _accountManagerRolesClient?: AccountManagerRolesClient; - - /** - * Gets the Account Manager Roles client, creating it if necessary. - */ - protected get accountManagerRolesClient(): AccountManagerRolesClient { - if (!this._accountManagerRolesClient) { - this.requireOAuthCredentials(); - const authStrategy = this.getOAuthStrategy(); - this._accountManagerRolesClient = createAccountManagerRolesClient( - { - hostname: this.accountManagerHost, - }, - authStrategy, - ); - } - return this._accountManagerRolesClient; - } -} diff --git a/packages/b2c-tooling-sdk/src/cli/user-command.ts b/packages/b2c-tooling-sdk/src/cli/user-command.ts deleted file mode 100644 index 139fdddd..00000000 --- a/packages/b2c-tooling-sdk/src/cli/user-command.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ -import {Command} from '@oclif/core'; -import {OAuthCommand} from './oauth-command.js'; -import {createAccountManagerUsersClient} from '../clients/am-users-api.js'; -import type {AccountManagerUsersClient} from '../clients/am-users-api.js'; - -/** - * Base command for Account Manager user operations. - * - * Extends OAuthCommand with Account Manager Users client setup. - * - * @example - * export default class UserList extends UserCommand { - * async run(): Promise { - * const users = await this.accountManagerUsersClient.listUsers(); - * // ... - * } - * } - */ -export abstract class UserCommand extends OAuthCommand { - private _accountManagerUsersClient?: AccountManagerUsersClient; - - /** - * Gets the Account Manager Users client, creating it if necessary. - */ - protected get accountManagerUsersClient(): AccountManagerUsersClient { - if (!this._accountManagerUsersClient) { - this.requireOAuthCredentials(); - const authStrategy = this.getOAuthStrategy(); - this._accountManagerUsersClient = createAccountManagerUsersClient( - { - hostname: this.accountManagerHost, - }, - authStrategy, - ); - } - return this._accountManagerUsersClient; - } -} diff --git a/packages/b2c-tooling-sdk/src/clients/am-api.ts b/packages/b2c-tooling-sdk/src/clients/am-api.ts new file mode 100644 index 00000000..e8bb0ced --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/am-api.ts @@ -0,0 +1,1188 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Account Manager API client for B2C Commerce. + * + * Provides clients for the Account Manager REST APIs including users, roles, and organizations. + * Uses openapi-fetch with OAuth authentication middleware for users and roles, + * and fetch with OAuth for organizations. + * + * @module clients/am-api + */ +import createClient, {type Client, type Middleware} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type { + paths as UsersPaths, + components as UsersComponents, + operations as UsersOperations, +} from './am-users-api.generated.js'; +import type {paths as RolesPaths, components as RolesComponents} from './am-roles-api.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; +import {getLogger} from '../logging/logger.js'; + +// ============================================================================ +// Users API +// ============================================================================ + +/** + * The typed Account Manager Users client - this is the openapi-fetch Client with full type safety. + * + * @see {@link createAccountManagerUsersClient} for instantiation + */ +export type AccountManagerUsersClient = Client; + +/** + * Helper type to extract response data from an operation. + */ +export type AccountManagerResponse = T extends {content: {'application/json': infer R}} ? R : never; + +/** + * Account Manager error response type from the generated schema. + */ +export type AccountManagerError = UsersComponents['schemas']['ErrorResponse']; + +/** + * User type from the generated schema. + */ +export type AccountManagerUser = UsersComponents['schemas']['UserRead']; +export type UserCreate = UsersComponents['schemas']['UserCreate']; +export type UserUpdate = UsersComponents['schemas']['UserUpdate']; + +/** + * Expand parameter type for user operations. + * Extracted from the generated API types to ensure consistency. + */ +export type UserExpandOption = NonNullable< + NonNullable['expand'] +>[number]; +export type UserCollection = UsersComponents['schemas']['UserCollection']; +export type UserState = 'INITIAL' | 'ENABLED' | 'DELETED'; + +/** + * Options for listing users. + */ +export interface ListUsersOptions { + /** Page size (default: 20, min: 1, max: 4000) */ + size?: number; + /** Page number (default: 0) */ + page?: number; +} + +/** + * Role name mappings between external and internal formats. + */ +export const ROLE_NAMES_MAP: Record = { + 'bm-admin': 'ECOM_ADMIN', + 'bm-user': 'ECOM_USER', +}; + +export const ROLE_NAMES_MAP_REVERSE: Record = { + ECOM_ADMIN: 'bm-admin', + ECOM_USER: 'bm-user', +}; + +/** + * Maps the role name to an internal role ID accepted by the API. + */ +export function mapToInternalRole(role: string): string { + if (ROLE_NAMES_MAP[role]) { + return ROLE_NAMES_MAP[role]; + } + return role.toUpperCase().replace(/-/g, '_'); +} + +/** + * Maps the internal role ID to role name. + */ +export function mapFromInternalRole(roleID: string): string { + if (ROLE_NAMES_MAP_REVERSE[roleID]) { + return ROLE_NAMES_MAP_REVERSE[roleID]; + } + return roleID.toLowerCase().replace(/_/g, '-'); +} + +/** + * Configuration for creating Account Manager clients. + * Used for all Account Manager API clients (users, roles, orgs). + */ +export interface AccountManagerClientConfig { + /** + * Account Manager hostname. + * Defaults to: account.demandware.com + * + * @example "account.demandware.com" + */ + hostname?: string; + + /** + * Middleware registry to use for this client. + * If not specified, uses the global middleware registry. + */ + middlewareRegistry?: MiddlewareRegistry; +} + +/** + * Creates a typed Account Manager Users API client. + * + * Returns the openapi-fetch client directly, with authentication + * handled via middleware. This gives full access to all openapi-fetch + * features with type-safe paths, parameters, and responses. + * + * @param config - Account Manager Users client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Typed openapi-fetch client + * + * @example + * // Create Account Manager Users client with OAuth auth + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerUsersClient({}, oauthStrategy); + * + * // List users + * const { data, error } = await client.GET('/dw/rest/v1/users', { + * params: { query: { pageable: { size: 25, page: 0 } } } + * }); + */ +export function createAccountManagerUsersClient( + config: AccountManagerClientConfig, + auth: AuthStrategy, +): AccountManagerUsersClient { + const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${hostname}`, + }); + + // Core middleware: auth first + client.use(createAuthMiddleware(auth)); + + // Transform pageable query parameters from bracket notation to flattened format + // This is needed because the API expects size=X&page=Y, not pageable[size]=X&pageable[page]=Y + client.use(createPageableTransformMiddleware()); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('am-users-api')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) + client.use(createLoggingMiddleware('AM-USERS')); + + return client; +} + +/** + * Retrieves details of a user by ID. + * + * @param client - Account Manager Users client + * @param userId - User ID (UUID) + * @param expand - Optional array of fields to expand (organizations, roles) + * @returns User details + * @throws Error if user is not found or request fails + */ +export async function getUser( + client: AccountManagerUsersClient, + userId: string, + expand?: UserExpandOption[], +): Promise { + const result = await client.GET('/dw/rest/v1/users/{userId}', { + params: { + path: {userId}, + query: expand && expand.length > 0 ? {expand} : undefined, + }, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + if (result.response?.status === 404) { + throw new Error(`User ${userId} not found`); + } + throw new Error(error.error?.message || `Failed to get user: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Lists users with pagination. + * + * @param client - Account Manager Users client + * @param options - List options (size, page) + * @returns Paginated user collection + * @throws Error if request fails + */ +export async function listUsers( + client: AccountManagerUsersClient, + options: ListUsersOptions = {}, +): Promise { + const {size = 20, page = 0} = options; + + const result = await client.GET('/dw/rest/v1/users', { + params: { + query: { + pageable: { + size, + page, + }, + }, + }, + }); + + if (result.error) { + const error = result.error as { + error?: {message?: string}; + errors?: Array<{message?: string; code?: string}>; + }; + + // Check for pagination out-of-bounds error + const errorMessage = error.errors?.[0]?.message || error.error?.message; + if (errorMessage?.includes('fromIndex') && errorMessage?.includes('toIndex')) { + throw new Error( + `Page ${page} is out of bounds. The requested page exceeds the available data. Try a lower page number.`, + ); + } + + throw new Error(errorMessage || `Failed to list users: ${JSON.stringify(result.error)}`); + } + + return result.data || {content: []}; +} + +/** + * Creates a new user. + * + * @param client - Account Manager Users client + * @param user - User details + * @returns Created user + * @throws Error if request fails + */ +export async function createUser(client: AccountManagerUsersClient, user: UserCreate): Promise { + const result = await client.POST('/dw/rest/v1/users', { + body: user, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to create user: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Updates an existing user. + * + * @param client - Account Manager Users client + * @param userId - User ID + * @param changes - Changes to apply + * @returns Updated user + * @throws Error if request fails + */ +export async function updateUser( + client: AccountManagerUsersClient, + userId: string, + changes: UserUpdate, +): Promise { + const result = await client.PUT('/dw/rest/v1/users/{userId}', { + params: {path: {userId}}, + body: changes, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to update user: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Disables a user (soft delete - sets userState to DELETED). + * Users must be disabled before they can be purged. + * + * @param client - Account Manager Users client + * @param userId - User ID + * @throws Error if request fails + */ +export async function deleteUser(client: AccountManagerUsersClient, userId: string): Promise { + const result = await client.POST('/dw/rest/v1/users/{userId}/disable', { + params: {path: {userId}}, + body: {}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to delete user: ${JSON.stringify(result.error)}`); + } +} + +/** + * Purges a user (hard delete). + * Users must be in DELETED state before they can be purged. + * + * @param client - Account Manager Users client + * @param userId - User ID + * @throws Error if request fails + */ +export async function purgeUser(client: AccountManagerUsersClient, userId: string): Promise { + const result = await client.DELETE('/dw/rest/v1/users/{userId}', { + params: {path: {userId}}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to purge user: ${JSON.stringify(result.error)}`); + } +} + +/** + * Resets a user to INITIAL state and sends activation instructions. + * + * @param client - Account Manager Users client + * @param userId - User ID + * @throws Error if request fails + */ +export async function resetUser(client: AccountManagerUsersClient, userId: string): Promise { + const result = await client.POST('/dw/rest/v1/users/{userId}/reset', { + params: {path: {userId}}, + body: {}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + throw new Error(error.error?.message || `Failed to reset user: ${JSON.stringify(result.error)}`); + } +} + +/** + * Helper to find a user by login (email) from a list of users. + * This is a convenience function since the API doesn't have a direct search by login endpoint. + * + * @param client - Account Manager Users client + * @param login - User login (email) + * @returns User if found, undefined otherwise + * @throws Error if request fails + */ +export async function findUserByLogin( + client: AccountManagerUsersClient, + login: string, + expand?: UserExpandOption[], +): Promise { + // Search through paginated results + let page = 0; + const pageSize = 100; + + while (true) { + const result = await client.GET('/dw/rest/v1/users', { + params: { + query: { + pageable: { + page, + size: pageSize, + }, + }, + }, + }); + + if (result.error) { + throw new Error(`Failed to search for user: ${JSON.stringify(result.error)}`); + } + + const users = result.data?.content || []; + const found = users.find((u) => u.mail === login); + + if (found) { + // If expand is requested, fetch the full user with expanded fields + if (expand && expand.length > 0 && found.id) { + return getUser(client, found.id, expand); + } + return found; + } + + // If we got fewer results than page size, we've reached the end + if (users.length < pageSize) { + return undefined; + } + + page++; + } +} + +// ============================================================================ +// Roles API +// ============================================================================ + +/** + * The typed Account Manager Roles client - this is the openapi-fetch Client with full type safety. + * + * @see {@link createAccountManagerRolesClient} for instantiation + */ +export type AccountManagerRolesClient = Client; + +/** + * Helper type to extract response data from an operation. + */ +export type AccountManagerRolesResponse = T extends {content: {'application/json': infer R}} ? R : never; + +/** + * Account Manager Roles error response type from the generated schema. + */ +export type AccountManagerRolesError = RolesComponents['schemas']['ErrorResponse']; + +/** + * Role type from the generated schema. + */ +export type AccountManagerRole = RolesComponents['schemas']['Role']; +export type RoleCollection = RolesComponents['schemas']['RoleCollection']; + +/** + * Options for listing roles. + */ +export interface ListRolesOptions { + /** Page size (default: 20, min: 1, max: 4000) */ + size?: number; + /** Page number (default: 0) */ + page?: number; + /** Filter by target type (User or ApiClient) */ + roleTargetType?: 'ApiClient' | 'User'; +} + +/** + * Creates a typed Account Manager Roles API client. + * + * Returns the openapi-fetch client directly, with authentication + * handled via middleware. This gives full access to all openapi-fetch + * features with type-safe paths, parameters, and responses. + * + * @param config - Account Manager Roles client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Typed openapi-fetch client + * + * @example + * // Create Account Manager Roles client with OAuth auth + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerRolesClient({}, oauthStrategy); + * + * // List roles + * const { data, error } = await client.GET('/dw/rest/v1/roles', { + * params: { query: { pageable: { size: 25, page: 0 } } } + * }); + */ +export function createAccountManagerRolesClient( + config: AccountManagerClientConfig, + auth: AuthStrategy, +): AccountManagerRolesClient { + const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${hostname}`, + }); + + // Core middleware: auth first + client.use(createAuthMiddleware(auth)); + + // Transform pageable query parameters from bracket notation to flattened format + // This is needed because the API expects size=X&page=Y, not pageable[size]=X&pageable[page]=Y + client.use(createPageableTransformMiddleware()); + + // Plugin middleware from registry + for (const middleware of registry.getMiddleware('am-roles-api')) { + client.use(middleware); + } + + // Logging middleware last (sees complete request with all modifications) + client.use(createLoggingMiddleware('AM-ROLES')); + + return client; +} + +/** + * Retrieves details of a role by ID. + * + * @param client - Account Manager Roles client + * @param roleId - Role ID + * @returns Role details + * @throws Error if role is not found or request fails + */ +export async function getRole(client: AccountManagerRolesClient, roleId: string): Promise { + const result = await client.GET('/dw/rest/v1/roles/{roleId}', { + params: {path: {roleId}}, + }); + + if (result.error) { + const error = result.error as {error?: {message?: string}}; + if (result.response?.status === 404) { + throw new Error(`Role ${roleId} not found`); + } + throw new Error(error.error?.message || `Failed to get role: ${JSON.stringify(result.error)}`); + } + + if (!result.data) { + throw new Error('No data returned from API'); + } + + return result.data; +} + +/** + * Lists roles with pagination. + * + * @param client - Account Manager Roles client + * @param options - List options (size, page, roleTargetType) + * @returns Paginated role collection + * @throws Error if request fails + */ +export async function listRoles( + client: AccountManagerRolesClient, + options: ListRolesOptions = {}, +): Promise { + const {size = 20, page = 0, roleTargetType} = options; + + const result = await client.GET('/dw/rest/v1/roles', { + params: { + query: { + pageable: { + size, + page, + }, + ...(roleTargetType && {roleTargetType}), + }, + }, + }); + + if (result.error) { + const error = result.error as { + error?: {message?: string}; + errors?: Array<{message?: string; code?: string}>; + }; + + // Check for pagination out-of-bounds error + const errorMessage = error.errors?.[0]?.message || error.error?.message; + if (errorMessage?.includes('fromIndex') && errorMessage?.includes('toIndex')) { + throw new Error( + `Page ${page} is out of bounds. The requested page exceeds the available data. Try a lower page number.`, + ); + } + + throw new Error(errorMessage || `Failed to list roles: ${JSON.stringify(result.error)}`); + } + + return result.data || {content: []}; +} + +// ============================================================================ +// Organizations API +// ============================================================================ + +/** + * Account Manager Organization type. + */ +export interface AccountManagerOrganization { + id: string; + name: string; + realms: string[]; + twoFARoles: string[]; + twoFAEnabled: boolean; + allowedVerifierTypes: string[]; + vaasEnabled: boolean; + sfIdentityFederation: boolean; + [key: string]: unknown; +} + +/** + * Account Manager Organization collection response. + */ +export interface OrganizationCollection { + content: AccountManagerOrganization[]; + totalElements?: number; + totalPages?: number; + number?: number; + size?: number; + [key: string]: unknown; +} + +/** + * Account Manager audit log record. + */ +export interface AuditLogRecord { + timestamp: string; + authorDisplayName: string; + authorEmail?: string; + eventType: string; + eventMessage: string; + [key: string]: unknown; +} + +/** + * Audit log collection response. + */ +export interface AuditLogCollection { + content: AuditLogRecord[]; + totalElements?: number; + totalPages?: number; + number?: number; + size?: number; + [key: string]: unknown; +} + +/** + * Options for listing organizations. + */ +export interface ListOrgsOptions { + /** Page size (default: 25, max: 5000) */ + size?: number; + /** Page number (0-based, default: 0) */ + page?: number; + /** Return all orgs (uses max page size of 5000) */ + all?: boolean; +} + +/** + * Account Manager Organizations API client. + */ +export interface AccountManagerOrgsClient { + /** + * Get organization by ID. + */ + getOrg(orgId: string): Promise; + + /** + * Get organization by name (searches for exact or partial match). + */ + getOrgByName(name: string): Promise; + + /** + * List organizations with pagination. + */ + listOrgs(options?: ListOrgsOptions): Promise; + + /** + * Get audit logs for an organization. + */ + getOrgAuditLogs(orgId: string): Promise; +} + +/** + * Transforms the API organization representation to an external format. + * Removes internal properties like 'links' that should not be exposed. + * + * @param org - The original organization object + * @returns The transformed organization object + */ +function toExternalOrg(org: AccountManagerOrganization): AccountManagerOrganization { + // Create a copy to avoid mutating the original + const transformed = {...org}; + // Always delete the links property + delete transformed.links; + return transformed; +} + +/** + * Creates an Account Manager Organizations API client. + * + * @param config - Account Manager Organizations client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Organizations API client + * + * @example + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerOrgsClient({}, oauthStrategy); + * + * // List organizations + * const orgs = await client.listOrgs({ size: 25, page: 0 }); + * + * // Get organization by ID + * const org = await client.getOrg('org-id'); + */ +export function createAccountManagerOrgsClient( + config: AccountManagerClientConfig, + auth: AuthStrategy, +): AccountManagerOrgsClient { + const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; + const baseUrl = `https://${hostname}/dw/rest/v1`; + const logger = getLogger(); + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + // Get middleware from registry for am-orgs-api + const pluginMiddleware = registry.getMiddleware('am-orgs-api'); + + /** + * Applies middleware chain to a request. + * Adapts openapi-fetch middleware to work with fetch requests. + */ + async function applyMiddleware(request: Request): Promise { + let processedRequest = request; + + // Apply auth middleware (core) + if (auth.getAuthorizationHeader) { + const authHeader = await auth.getAuthorizationHeader(); + processedRequest.headers.set('Authorization', authHeader); + } + + // Apply plugin middleware from registry + for (const middleware of pluginMiddleware) { + if (middleware.onRequest) { + // Create minimal openapi-fetch context + const result = await middleware.onRequest({ + request: processedRequest, + schemaPath: '', + params: {}, + id: '', + options: { + baseUrl: baseUrl, + parseAs: 'json', + querySerializer: (params) => new URLSearchParams(params as Record).toString(), + bodySerializer: JSON.stringify, + fetch: fetch, + }, + }); + // Middleware can return Request or Response, but we only want Request here + if (result && result instanceof Request) { + processedRequest = result; + } + } + } + + // Apply logging middleware (last, so it sees all modifications) + logger.debug({method: processedRequest.method, url: processedRequest.url}, '[AM-ORGS] Making request'); + logger.trace( + { + method: processedRequest.method, + url: processedRequest.url, + headers: Object.fromEntries(processedRequest.headers.entries()), + }, + '[AM-ORGS] Request details', + ); + + return processedRequest; + } + + /** + * Applies middleware chain to a response. + */ + async function processResponse(request: Request, response: Response): Promise { + let processedResponse = response; + + // Apply plugin middleware from registry + for (const middleware of pluginMiddleware) { + if (middleware.onResponse) { + const result = await middleware.onResponse({ + request, + response: processedResponse, + schemaPath: '', + params: {}, + id: '', + options: { + baseUrl: baseUrl, + parseAs: 'json', + querySerializer: (params) => new URLSearchParams(params as Record).toString(), + bodySerializer: JSON.stringify, + fetch: fetch, + }, + }); + if (result) { + processedResponse = result; + } + } + } + + // Apply logging middleware (last) + logger.debug( + {method: request.method, url: request.url, status: processedResponse.status}, + '[AM-ORGS] Received response', + ); + + return processedResponse; + } + + /** + * Makes an authenticated request to the Account Manager API with middleware support. + */ + async function makeRequest(path: string, options: RequestInit = {}): Promise { + const url = `${baseUrl}${path}`; + let request = new Request(url, { + ...options, + headers: new Headers(options.headers), + }); + + // Apply middleware chain to request + request = await applyMiddleware(request); + + const response = await fetch(request); + + // Apply middleware chain to response + const processedResponse = await processResponse(request, response); + + // Handle errors + if (processedResponse.status === 401) { + throw new Error('Authentication invalid. Please (re-)authenticate.'); + } + if (processedResponse.status === 403) { + throw new Error('Operation forbidden. Please make sure you have the permission to perform this operation.'); + } + if (processedResponse.status >= 400) { + throw new Error(`Operation failed. Error code ${processedResponse.status}`); + } + + if (!processedResponse.ok) { + throw new Error(`Request failed: ${processedResponse.statusText}`); + } + + return processedResponse.json() as Promise; + } + + return { + async getOrg(orgId: string): Promise { + logger.debug({orgId}, '[AM-ORGS] Getting organization by ID'); + try { + const org = await makeRequest(`/organizations/${orgId}`); + return toExternalOrg(org); + } catch (error) { + if (error instanceof Error && error.message.includes('Error code 404')) { + throw new Error(`Organization ${orgId} not found`); + } + throw error; + } + }, + + async getOrgByName(name: string): Promise { + logger.debug({name}, '[AM-ORGS] Getting organization by name'); + const encodedName = encodeURIComponent(name); + let result: OrganizationCollection; + try { + result = await makeRequest( + `/organizations/search/findByName?startsWith=${encodedName}&ignoreCase=false`, + ); + } catch (error) { + if (error instanceof Error && error.message.includes('Error code 404')) { + throw new Error(`Organization ${name} not found`); + } + throw error; + } + + if (result.content.length === 0) { + throw new Error(`Organization ${name} not found`); + } + + if (result.content.length > 1) { + // Attempt to find exact match + const exactMatch = result.content.find((org) => org.name === name); + if (exactMatch) { + return toExternalOrg(exactMatch); + } + throw new Error(`Organization name "${name}" is ambiguous. Multiple organizations found.`); + } + + return toExternalOrg(result.content[0]); + }, + + async listOrgs(options: ListOrgsOptions = {}): Promise { + const {size = 25, page = 0, all = false} = options; + const pageSize = all ? 5000 : size; + + logger.debug({size: pageSize, page}, '[AM-ORGS] Listing organizations'); + + const result = await makeRequest(`/organizations?page=${page}&size=${pageSize}`); + + // Remove links from all organizations in the collection + return { + ...result, + content: result.content.map((org) => toExternalOrg(org)), + }; + }, + + async getOrgAuditLogs(orgId: string): Promise { + logger.debug({orgId}, '[AM-ORGS] Getting audit logs for organization'); + const logs = await makeRequest(`/organizations/${orgId}/audit-log-records`); + return logs; + }, + }; +} + +// ============================================================================ +// Shared Utilities +// ============================================================================ + +/** + * Middleware to transform pageable query parameters from bracket notation + * (pageable[size]=X&pageable[page]=Y) to flattened format (size=X&page=Y) + * that the Account Manager API expects. + */ +function createPageableTransformMiddleware(): Middleware { + const logger = getLogger(); + return { + async onRequest({request}) { + const url = new URL(request.url); + + // Check if URL has pageable[size] or pageable[page] parameters + const pageableSize = url.searchParams.get('pageable[size]'); + const pageablePage = url.searchParams.get('pageable[page]'); + + if (pageableSize !== null || pageablePage !== null) { + // Remove the bracket notation parameters + url.searchParams.delete('pageable[size]'); + url.searchParams.delete('pageable[page]'); + + // Add flattened parameters + if (pageableSize !== null) { + url.searchParams.set('size', pageableSize); + } + if (pageablePage !== null) { + url.searchParams.set('page', pageablePage); + } + + logger.trace( + { + originalUrl: request.url, + transformedUrl: url.toString(), + size: pageableSize, + page: pageablePage, + }, + '[AM] Transformed pageable query parameters from bracket to flattened notation', + ); + + return new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + }); + } + + return request; + }, + }; +} + +// ============================================================================ +// Unified Account Manager Client +// ============================================================================ +/** + * Unified Account Manager API client that combines users, roles, and organizations. + * + * This client provides direct access to all Account Manager API methods through + * a single interface, while internally using separate typed clients for type safety. + */ +export interface AccountManagerClient { + // Users API methods + /** Get user by ID */ + getUser(userId: string, expand?: UserExpandOption[]): Promise; + /** List users with pagination */ + listUsers(options?: ListUsersOptions): Promise; + /** Create a new user */ + createUser(user: UserCreate): Promise; + /** Update an existing user */ + updateUser(userId: string, changes: UserUpdate): Promise; + /** Disable a user (soft delete) */ + deleteUser(userId: string): Promise; + /** Purge a user (hard delete) */ + purgeUser(userId: string): Promise; + /** Reset a user to INITIAL state */ + resetUser(userId: string): Promise; + /** Find a user by login (email) */ + findUserByLogin(login: string, expand?: UserExpandOption[]): Promise; + /** Grant a role to a user, optionally with scope */ + grantRole(userId: string, role: string, scope?: string): Promise; + /** Revoke a role from a user, optionally removing specific scope */ + revokeRole(userId: string, role: string, scope?: string): Promise; + + // Roles API methods + /** Get role by ID */ + getRole(roleId: string): Promise; + /** List roles with pagination */ + listRoles(options?: ListRolesOptions): Promise; + + // Organizations API methods + /** Get organization by ID */ + getOrg(orgId: string): Promise; + /** Get organization by name */ + getOrgByName(name: string): Promise; + /** List organizations with pagination */ + listOrgs(options?: ListOrgsOptions): Promise; + /** Get audit logs for an organization */ + getOrgAuditLogs(orgId: string): Promise; +} + +/** + * Creates a unified Account Manager API client. + * + * This client provides direct access to all Account Manager API methods (users, roles, orgs) + * through a single interface, while internally using separate typed clients for type safety. + * + * @param config - Account Manager client configuration + * @param auth - Authentication strategy (typically OAuth) + * @returns Unified Account Manager client + * + * @example + * const oauthStrategy = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * }); + * + * const client = createAccountManagerClient({}, oauthStrategy); + * + * // Users API + * const users = await client.listUsers({ size: 25, page: 0 }); + * const user = await client.getUser('user-id'); + * await client.createUser({ mail: 'user@example.com', ... }); + * + * // Roles API + * const roles = await client.listRoles({ size: 20, page: 0 }); + * const role = await client.getRole('bm-admin'); + * + * // Organizations API + * const orgs = await client.listOrgs({ size: 25, page: 0 }); + * const org = await client.getOrg('org-id'); + * const auditLogs = await client.getOrgAuditLogs('org-id'); + */ +export function createAccountManagerClient( + config: AccountManagerClientConfig, + auth: AuthStrategy, +): AccountManagerClient { + // All three clients use the same config + + // Create internal clients (all use the same config, however, specifications are different) + const usersClient = createAccountManagerUsersClient(config, auth); + const rolesClient = createAccountManagerRolesClient(config, auth); + const orgsClient = createAccountManagerOrgsClient(config, auth); + + // Return unified client with all methods + return { + // Users API methods + getUser: (userId: string, expand?: UserExpandOption[]) => getUser(usersClient, userId, expand), + listUsers: (options?: ListUsersOptions) => listUsers(usersClient, options), + createUser: (user: UserCreate) => createUser(usersClient, user), + updateUser: (userId: string, changes: UserUpdate) => updateUser(usersClient, userId, changes), + deleteUser: (userId: string) => deleteUser(usersClient, userId), + purgeUser: (userId: string) => purgeUser(usersClient, userId), + resetUser: (userId: string) => resetUser(usersClient, userId), + findUserByLogin: (login: string, expand?: UserExpandOption[]) => findUserByLogin(usersClient, login, expand), + grantRole: (userId: string, role: string, scope?: string) => { + // Import grantRole from operations - it uses getUser internally which needs the client + return getUser(usersClient, userId).then((user) => { + // Build updated roles + const currentRoles = Array.isArray(user.roles) + ? user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')) + : []; + const updatedRoles = currentRoles.includes(role) ? currentRoles : [...currentRoles, role]; + + // Build updated roleTenantFilter + let roleTenantFilter = user.roleTenantFilter || ''; + if (scope) { + const scopes = scope.split(','); + // Parse existing filter + const filters = roleTenantFilter.split(';').filter(Boolean); + const filterMap = new Map(); + for (const filter of filters) { + const [r, tenants] = filter.split(':'); + if (tenants) { + filterMap.set(r, tenants.split(',')); + } + } + // Add new scopes + const existingScopes = filterMap.get(role) || []; + const allScopes = [...new Set([...existingScopes, ...scopes])]; + filterMap.set(role, allScopes); + // Rebuild filter string + roleTenantFilter = Array.from(filterMap.entries()) + .map(([r, tenants]) => `${r}:${tenants.join(',')}`) + .join(';'); + } + + return updateUser(usersClient, userId, { + roles: updatedRoles, + roleTenantFilter: roleTenantFilter || undefined, + }); + }); + }, + revokeRole: (userId: string, role: string, scope?: string) => { + // Import revokeRole logic - it uses getUser internally which needs the client + return getUser(usersClient, userId).then((user) => { + // Build updated roles + const currentRoles = Array.isArray(user.roles) + ? user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || '')) + : []; + let updatedRoles = currentRoles; + + // Build updated roleTenantFilter + let roleTenantFilter = user.roleTenantFilter || ''; + + if (!scope) { + // Remove entire role + updatedRoles = currentRoles.filter((r) => r !== role); + // Remove role from filter + const filters = roleTenantFilter.split(';').filter(Boolean); + roleTenantFilter = filters.filter((filter) => !filter.startsWith(`${role}:`)).join(';'); + } else { + // Remove specific scope + const scopes = scope.split(','); + const filters = roleTenantFilter.split(';').filter(Boolean); + const filterMap = new Map(); + for (const filter of filters) { + const [r, tenants] = filter.split(':'); + if (tenants) { + filterMap.set(r, tenants.split(',')); + } + } + const existingScopes = filterMap.get(role) || []; + const remainingScopes = existingScopes.filter((s) => !scopes.includes(s)); + if (remainingScopes.length === 0) { + // No scopes left, remove role entirely + updatedRoles = currentRoles.filter((r) => r !== role); + filterMap.delete(role); + } else { + filterMap.set(role, remainingScopes); + } + // Rebuild filter string + roleTenantFilter = Array.from(filterMap.entries()) + .map(([r, tenants]) => `${r}:${tenants.join(',')}`) + .join(';'); + } + + return updateUser(usersClient, userId, { + roles: updatedRoles, + roleTenantFilter: roleTenantFilter || undefined, + }); + }); + }, + + // Roles API methods + getRole: (roleId: string) => getRole(rolesClient, roleId), + listRoles: (options?: ListRolesOptions) => listRoles(rolesClient, options), + + // Organizations API methods + getOrg: (orgId: string) => orgsClient.getOrg(orgId), + getOrgByName: (name: string) => orgsClient.getOrgByName(name), + listOrgs: (options?: ListOrgsOptions) => orgsClient.listOrgs(options), + getOrgAuditLogs: (orgId: string) => orgsClient.getOrgAuditLogs(orgId), + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/am-orgs-api.ts b/packages/b2c-tooling-sdk/src/clients/am-orgs-api.ts deleted file mode 100644 index f7670777..00000000 --- a/packages/b2c-tooling-sdk/src/clients/am-orgs-api.ts +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ -/** - * Account Manager Organizations API client for B2C Commerce. - * - * Provides a client for the Account Manager Organizations REST API using - * fetch with OAuth authentication middleware. Used for managing - * organizations in Account Manager. - * - * @module clients/am-orgs-api - */ -import type {AuthStrategy} from '../auth/types.js'; -import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; -import {getLogger} from '../logging/logger.js'; - -/** - * Account Manager Organization type. - */ -export interface AccountManagerOrganization { - id: string; - name: string; - realms: string[]; - twoFARoles: string[]; - twoFAEnabled: boolean; - allowedVerifierTypes: string[]; - vaasEnabled: boolean; - sfIdentityFederation: boolean; - [key: string]: unknown; -} - -/** - * Account Manager Organization collection response. - */ -export interface OrganizationCollection { - content: AccountManagerOrganization[]; - totalElements?: number; - totalPages?: number; - number?: number; - size?: number; - [key: string]: unknown; -} - -/** - * Account Manager audit log record. - */ -export interface AuditLogRecord { - timestamp: string; - authorDisplayName: string; - authorEmail?: string; - eventType: string; - eventMessage: string; - [key: string]: unknown; -} - -/** - * Audit log collection response. - */ -export interface AuditLogCollection { - content: AuditLogRecord[]; - totalElements?: number; - totalPages?: number; - number?: number; - size?: number; - [key: string]: unknown; -} - -/** - * Options for listing organizations. - */ -export interface ListOrgsOptions { - /** Page size (default: 25, max: 5000) */ - size?: number; - /** Page number (0-based, default: 0) */ - page?: number; - /** Return all orgs (uses max page size of 5000) */ - all?: boolean; -} - -/** - * Transforms the API organization representation to an external format. - * Removes internal properties like 'links' that should not be exposed. - * - * @param org - The original organization object - * @returns The transformed organization object - */ -function toExternalOrg(org: AccountManagerOrganization): AccountManagerOrganization { - // Create a copy to avoid mutating the original - const transformed = {...org}; - // Always delete the links property - delete transformed.links; - return transformed; -} - -/** - * Configuration for creating an Account Manager Organizations client. - */ -export interface AccountManagerOrgsClientConfig { - /** - * Account Manager hostname. - * Defaults to: account.demandware.com - */ - hostname?: string; -} - -/** - * Account Manager Organizations API client. - */ -export interface AccountManagerOrgsClient { - /** - * Get organization by ID. - */ - getOrg(orgId: string): Promise; - - /** - * Get organization by name (searches for exact or partial match). - */ - getOrgByName(name: string): Promise; - - /** - * List organizations with pagination. - */ - listOrgs(options?: ListOrgsOptions): Promise; - - /** - * Get audit logs for an organization. - */ - getOrgAuditLogs(orgId: string): Promise; -} - -/** - * Creates an Account Manager Organizations API client. - * - * @param config - Account Manager client configuration - * @param auth - Authentication strategy (typically OAuth) - * @returns Organizations API client - * - * @example - * const oauthStrategy = new OAuthStrategy({ - * clientId: 'your-client-id', - * clientSecret: 'your-client-secret', - * }); - * - * const client = createAccountManagerOrgsClient({}, oauthStrategy); - * - * // List organizations - * const orgs = await client.listOrgs({ size: 25, page: 0 }); - * - * // Get organization by ID - * const org = await client.getOrg('org-id'); - */ -export function createAccountManagerOrgsClient( - config: AccountManagerOrgsClientConfig, - auth: AuthStrategy, -): AccountManagerOrgsClient { - const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; - const baseUrl = `https://${hostname}/dw/rest/v1`; - const logger = getLogger(); - - /** - * Makes an authenticated request to the Account Manager API. - */ - async function makeRequest(path: string, options: RequestInit = {}): Promise { - const url = `${baseUrl}${path}`; - const headers = new Headers(options.headers); - - // Add authentication header - if (auth.getAuthorizationHeader) { - const authHeader = await auth.getAuthorizationHeader(); - headers.set('Authorization', authHeader); - } - - logger.trace({url, method: options.method || 'GET'}, '[AM-ORGS] Making request'); - - const response = await fetch(url, { - ...options, - headers, - }); - - logger.trace({url, status: response.status, statusText: response.statusText}, '[AM-ORGS] Received response'); - - // Handle errors - if (response.status === 401) { - throw new Error('Authentication invalid. Please (re-)authenticate.'); - } - if (response.status === 403) { - throw new Error('Operation forbidden. Please make sure you have the permission to perform this operation.'); - } - if (response.status >= 400) { - throw new Error(`Operation failed. Error code ${response.status}`); - } - - if (!response.ok) { - throw new Error(`Request failed: ${response.statusText}`); - } - - return response.json() as Promise; - } - - return { - async getOrg(orgId: string): Promise { - logger.debug({orgId}, '[AM-ORGS] Getting organization by ID'); - try { - const org = await makeRequest(`/organizations/${orgId}`); - return toExternalOrg(org); - } catch (error) { - if (error instanceof Error && error.message.includes('Error code 404')) { - throw new Error(`Organization ${orgId} not found`); - } - throw error; - } - }, - - async getOrgByName(name: string): Promise { - logger.debug({name}, '[AM-ORGS] Getting organization by name'); - const encodedName = encodeURIComponent(name); - let result: OrganizationCollection; - try { - result = await makeRequest( - `/organizations/search/findByName?startsWith=${encodedName}&ignoreCase=false`, - ); - } catch (error) { - if (error instanceof Error && error.message.includes('Error code 404')) { - throw new Error(`Organization ${name} not found`); - } - throw error; - } - - if (result.content.length === 0) { - throw new Error(`Organization ${name} not found`); - } - - if (result.content.length > 1) { - // Attempt to find exact match - const exactMatch = result.content.find((org) => org.name === name); - if (exactMatch) { - return toExternalOrg(exactMatch); - } - throw new Error(`Organization name "${name}" is ambiguous. Multiple organizations found.`); - } - - return toExternalOrg(result.content[0]); - }, - - async listOrgs(options: ListOrgsOptions = {}): Promise { - const {size = 25, page = 0, all = false} = options; - const pageSize = all ? 5000 : size; - - logger.debug({size: pageSize, page}, '[AM-ORGS] Listing organizations'); - - const result = await makeRequest(`/organizations?page=${page}&size=${pageSize}`); - - // Remove links from all organizations in the collection - return { - ...result, - content: result.content.map((org) => toExternalOrg(org)), - }; - }, - - async getOrgAuditLogs(orgId: string): Promise { - logger.debug({orgId}, '[AM-ORGS] Getting audit logs for organization'); - const logs = await makeRequest(`/organizations/${orgId}/audit-log-records`); - return logs; - }, - }; -} diff --git a/packages/b2c-tooling-sdk/src/clients/am-roles-api.ts b/packages/b2c-tooling-sdk/src/clients/am-roles-api.ts deleted file mode 100644 index 29b61d0d..00000000 --- a/packages/b2c-tooling-sdk/src/clients/am-roles-api.ts +++ /dev/null @@ -1,263 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ -/** - * Account Manager Roles API client for B2C Commerce. - * - * Provides a fully typed client for the Account Manager Roles REST API using - * openapi-fetch with OAuth authentication middleware. Used for retrieving - * role information and permissions in Account Manager. - * - * @module clients/am-roles-api - */ -import createClient, {type Client, type Middleware} from 'openapi-fetch'; -import type {AuthStrategy} from '../auth/types.js'; -import type {paths, components} from './am-roles-api.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; -import {getLogger} from '../logging/logger.js'; - -/** - * Re-export generated types for external use. - */ -export type {paths, components}; - -/** - * The typed Account Manager Roles client - this is the openapi-fetch Client with full type safety. - * - * @see {@link createAccountManagerRolesClient} for instantiation - */ -export type AccountManagerRolesClient = Client; - -/** - * Helper type to extract response data from an operation. - */ -export type AccountManagerRolesResponse = T extends {content: {'application/json': infer R}} ? R : never; - -/** - * Account Manager Roles error response type from the generated schema. - */ -export type AccountManagerRolesError = components['schemas']['ErrorResponse']; - -/** - * Middleware to transform pageable query parameters from bracket notation - * (pageable[size]=X&pageable[page]=Y) to flattened format (size=X&page=Y) - * that the Account Manager API expects. - */ -function createPageableTransformMiddleware(): Middleware { - const logger = getLogger(); - return { - async onRequest({request}) { - const url = new URL(request.url); - - // Check if URL has pageable[size] or pageable[page] parameters - const pageableSize = url.searchParams.get('pageable[size]'); - const pageablePage = url.searchParams.get('pageable[page]'); - - if (pageableSize !== null || pageablePage !== null) { - // Remove the bracket notation parameters - url.searchParams.delete('pageable[size]'); - url.searchParams.delete('pageable[page]'); - - // Add flattened parameters - if (pageableSize !== null) { - url.searchParams.set('size', pageableSize); - } - if (pageablePage !== null) { - url.searchParams.set('page', pageablePage); - } - - logger.trace( - { - originalUrl: request.url, - transformedUrl: url.toString(), - size: pageableSize, - page: pageablePage, - }, - '[AM-ROLES] Transformed pageable query parameters from bracket to flattened notation', - ); - - return new Request(url.toString(), { - method: request.method, - headers: request.headers, - body: request.body, - }); - } - - return request; - }, - }; -} - -/** - * Configuration for creating an Account Manager Roles client. - */ -export interface AccountManagerRolesClientConfig { - /** - * Account Manager hostname. - * Defaults to: account.demandware.com - * - * @example "account.demandware.com" - */ - hostname?: string; - - /** - * Middleware registry to use for this client. - * If not specified, uses the global middleware registry. - */ - middlewareRegistry?: MiddlewareRegistry; -} - -/** - * Creates a typed Account Manager Roles API client. - * - * Returns the openapi-fetch client directly, with authentication - * handled via middleware. This gives full access to all openapi-fetch - * features with type-safe paths, parameters, and responses. - * - * @param config - Account Manager Roles client configuration - * @param auth - Authentication strategy (typically OAuth) - * @returns Typed openapi-fetch client - * - * @example - * // Create Account Manager Roles client with OAuth auth - * const oauthStrategy = new OAuthStrategy({ - * clientId: 'your-client-id', - * clientSecret: 'your-client-secret', - * }); - * - * const client = createAccountManagerRolesClient({}, oauthStrategy); - * - * // List roles - * const { data, error } = await client.GET('/dw/rest/v1/roles', { - * params: { query: { pageable: { size: 25, page: 0 } } } - * }); - * - * // Get role by ID - * const { data, error } = await client.GET('/dw/rest/v1/roles/{roleId}', { - * params: { path: { roleId: 'bm-admin' } } - * }); - */ -export function createAccountManagerRolesClient( - config: AccountManagerRolesClientConfig, - auth: AuthStrategy, -): AccountManagerRolesClient { - const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${hostname}`, - }); - - // Core middleware: auth first - client.use(createAuthMiddleware(auth)); - - // Transform pageable query parameters from bracket notation to flattened format - // This is needed because the API expects size=X&page=Y, not pageable[size]=X&pageable[page]=Y - client.use(createPageableTransformMiddleware()); - - // Plugin middleware from registry - for (const middleware of registry.getMiddleware('am-roles-api')) { - client.use(middleware); - } - - // Logging middleware last (sees complete request with all modifications) - client.use(createLoggingMiddleware('AM-ROLES')); - - return client; -} - -/** - * Role type from the generated schema. - */ -export type AccountManagerRole = components['schemas']['Role']; -export type RoleCollection = components['schemas']['RoleCollection']; - -/** - * Options for listing roles. - */ -export interface ListRolesOptions { - /** Page size (default: 20, min: 1, max: 4000) */ - size?: number; - /** Page number (default: 0) */ - page?: number; - /** Filter by target type (User or ApiClient) */ - roleTargetType?: 'ApiClient' | 'User'; -} - -/** - * Retrieves details of a role by ID. - * - * @param client - Account Manager Roles client - * @param roleId - Role ID - * @returns Role details - * @throws Error if role is not found or request fails - */ -export async function getRole(client: AccountManagerRolesClient, roleId: string): Promise { - const result = await client.GET('/dw/rest/v1/roles/{roleId}', { - params: {path: {roleId}}, - }); - - if (result.error) { - const error = result.error as {error?: {message?: string}}; - if (result.response?.status === 404) { - throw new Error(`Role ${roleId} not found`); - } - throw new Error(error.error?.message || `Failed to get role: ${JSON.stringify(result.error)}`); - } - - if (!result.data) { - throw new Error('No data returned from API'); - } - - return result.data; -} - -/** - * Lists roles with pagination. - * - * @param client - Account Manager Roles client - * @param options - List options (size, page, roleTargetType) - * @returns Paginated role collection - * @throws Error if request fails - */ -export async function listRoles( - client: AccountManagerRolesClient, - options: ListRolesOptions = {}, -): Promise { - const {size = 20, page = 0, roleTargetType} = options; - - const result = await client.GET('/dw/rest/v1/roles', { - params: { - query: { - pageable: { - size, - page, - }, - ...(roleTargetType && {roleTargetType}), - }, - }, - }); - - if (result.error) { - const error = result.error as { - error?: {message?: string}; - errors?: Array<{message?: string; code?: string}>; - }; - - // Check for pagination out-of-bounds error - const errorMessage = error.errors?.[0]?.message || error.error?.message; - if (errorMessage?.includes('fromIndex') && errorMessage?.includes('toIndex')) { - throw new Error( - `Page ${page} is out of bounds. The requested page exceeds the available data. Try a lower page number.`, - ); - } - - throw new Error(errorMessage || `Failed to list roles: ${JSON.stringify(result.error)}`); - } - - return result.data || {content: []}; -} diff --git a/packages/b2c-tooling-sdk/src/clients/am-users-api.ts b/packages/b2c-tooling-sdk/src/clients/am-users-api.ts deleted file mode 100644 index 8cb624dd..00000000 --- a/packages/b2c-tooling-sdk/src/clients/am-users-api.ts +++ /dev/null @@ -1,470 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ -/** - * Account Manager Users API client for B2C Commerce. - * - * Provides a fully typed client for the Account Manager Users REST API using - * openapi-fetch with OAuth authentication middleware. Used for managing - * user accounts, roles, and permissions in Account Manager. - * - * @module clients/am-users-api - */ -import createClient, {type Client, type Middleware} from 'openapi-fetch'; -import type {AuthStrategy} from '../auth/types.js'; -import type {paths, components} from './am-users-api.generated.js'; -import {createAuthMiddleware, createLoggingMiddleware} from './middleware.js'; -import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; -import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; -import {getLogger} from '../logging/logger.js'; - -/** - * Re-export generated types for external use. - */ -export type {paths, components}; - -/** - * The typed Account Manager Users client - this is the openapi-fetch Client with full type safety. - * - * @see {@link createAccountManagerUsersClient} for instantiation - */ -export type AccountManagerUsersClient = Client; - -/** - * Helper type to extract response data from an operation. - */ -export type AccountManagerResponse = T extends {content: {'application/json': infer R}} ? R : never; - -/** - * Account Manager error response type from the generated schema. - */ -export type AccountManagerError = components['schemas']['ErrorResponse']; - -/** - * Middleware to transform pageable query parameters from bracket notation - * (pageable[size]=X&pageable[page]=Y) to flattened format (size=X&page=Y) - * that the Account Manager API expects. - */ -function createPageableTransformMiddleware(): Middleware { - const logger = getLogger(); - return { - async onRequest({request}) { - const url = new URL(request.url); - - // Check if URL has pageable[size] or pageable[page] parameters - const pageableSize = url.searchParams.get('pageable[size]'); - const pageablePage = url.searchParams.get('pageable[page]'); - - if (pageableSize !== null || pageablePage !== null) { - // Remove the bracket notation parameters - url.searchParams.delete('pageable[size]'); - url.searchParams.delete('pageable[page]'); - - // Add flattened parameters - if (pageableSize !== null) { - url.searchParams.set('size', pageableSize); - } - if (pageablePage !== null) { - url.searchParams.set('page', pageablePage); - } - - logger.trace( - { - originalUrl: request.url, - transformedUrl: url.toString(), - size: pageableSize, - page: pageablePage, - }, - '[AM-USERS] Transformed pageable query parameters from bracket to flattened notation', - ); - - return new Request(url.toString(), { - method: request.method, - headers: request.headers, - body: request.body, - }); - } - - return request; - }, - }; -} - -/** - * Configuration for creating an Account Manager client. - */ -export interface AccountManagerUsersClientConfig { - /** - * Account Manager hostname. - * Defaults to: account.demandware.com - * - * @example "account.demandware.com" - */ - hostname?: string; - - /** - * Middleware registry to use for this client. - * If not specified, uses the global middleware registry. - */ - middlewareRegistry?: MiddlewareRegistry; -} - -/** - * Creates a typed Account Manager Users API client. - * - * Returns the openapi-fetch client directly, with authentication - * handled via middleware. This gives full access to all openapi-fetch - * features with type-safe paths, parameters, and responses. - * - * @param config - Account Manager client configuration - * @param auth - Authentication strategy (typically OAuth) - * @returns Typed openapi-fetch client - * - * @example - * // Create Account Manager client with OAuth auth - * const oauthStrategy = new OAuthStrategy({ - * clientId: 'your-client-id', - * clientSecret: 'your-client-secret', - * }); - * - * const client = createAccountManagerUsersClient({}, oauthStrategy); - * - * // List users - * const { data, error } = await client.GET('/dw/rest/v1/users', { - * params: { query: { pageable: { size: 25, page: 0 } } } - * }); - * - * // Get user by ID - * const { data, error } = await client.GET('/dw/rest/v1/users/{userId}', { - * params: { path: { userId: 'user-uuid' } } - * }); - * - * // Create user - * const { data, error } = await client.POST('/dw/rest/v1/users', { - * body: { - * mail: 'user@example.com', - * firstName: 'John', - * lastName: 'Doe', - * organizations: ['org-id'], - * primaryOrganization: 'org-id', - * } - * }); - */ -export function createAccountManagerUsersClient( - config: AccountManagerUsersClientConfig, - auth: AuthStrategy, -): AccountManagerUsersClient { - const hostname = config.hostname ?? DEFAULT_ACCOUNT_MANAGER_HOST; - const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; - - const client = createClient({ - baseUrl: `https://${hostname}`, - }); - - // Core middleware: auth first - client.use(createAuthMiddleware(auth)); - - // Transform pageable query parameters from bracket notation to flattened format - // This is needed because the API expects size=X&page=Y, not pageable[size]=X&pageable[page]=Y - client.use(createPageableTransformMiddleware()); - - // Plugin middleware from registry - for (const middleware of registry.getMiddleware('am-users-api')) { - client.use(middleware); - } - - // Logging middleware last (sees complete request with all modifications) - client.use(createLoggingMiddleware('AM-USERS')); - - return client; -} - -/** - * Role name mappings between external and internal formats. - */ -export const ROLE_NAMES_MAP: Record = { - 'bm-admin': 'ECOM_ADMIN', - 'bm-user': 'ECOM_USER', -}; - -export const ROLE_NAMES_MAP_REVERSE: Record = { - ECOM_ADMIN: 'bm-admin', - ECOM_USER: 'bm-user', -}; - -/** - * Maps the role name to an internal role ID accepted by the API. - */ -export function mapToInternalRole(role: string): string { - if (ROLE_NAMES_MAP[role]) { - return ROLE_NAMES_MAP[role]; - } - return role.toUpperCase().replace(/-/g, '_'); -} - -/** - * Maps the internal role ID to role name. - */ -export function mapFromInternalRole(roleID: string): string { - if (ROLE_NAMES_MAP_REVERSE[roleID]) { - return ROLE_NAMES_MAP_REVERSE[roleID]; - } - return roleID.toLowerCase().replace(/_/g, '-'); -} - -/** - * User type from the generated schema. - */ -export type AccountManagerUser = components['schemas']['UserRead']; -export type UserCreate = components['schemas']['UserCreate']; -export type UserUpdate = components['schemas']['UserUpdate']; -export type UserCollection = components['schemas']['UserCollection']; -export type UserState = 'INITIAL' | 'ENABLED' | 'DELETED'; - -/** - * Options for listing users. - */ -export interface ListUsersOptions { - /** Page size (default: 20, min: 1, max: 4000) */ - size?: number; - /** Page number (default: 0) */ - page?: number; -} - -/** - * Retrieves details of a user by ID. - * - * @param client - Account Manager client - * @param userId - User ID (UUID) - * @returns User details - * @throws Error if user is not found or request fails - */ -export async function getUser(client: AccountManagerUsersClient, userId: string): Promise { - const result = await client.GET('/dw/rest/v1/users/{userId}', { - params: {path: {userId}}, - }); - - if (result.error) { - const error = result.error as {error?: {message?: string}}; - if (result.response?.status === 404) { - throw new Error(`User ${userId} not found`); - } - throw new Error(error.error?.message || `Failed to get user: ${JSON.stringify(result.error)}`); - } - - if (!result.data) { - throw new Error('No data returned from API'); - } - - return result.data; -} - -/** - * Lists users with pagination. - * - * @param client - Account Manager client - * @param options - List options (size, page) - * @returns Paginated user collection - * @throws Error if request fails - */ -export async function listUsers( - client: AccountManagerUsersClient, - options: ListUsersOptions = {}, -): Promise { - const {size = 20, page = 0} = options; - - const result = await client.GET('/dw/rest/v1/users', { - params: { - query: { - pageable: { - size, - page, - }, - }, - }, - }); - - if (result.error) { - const error = result.error as { - error?: {message?: string}; - errors?: Array<{message?: string; code?: string}>; - }; - - // Check for pagination out-of-bounds error - const errorMessage = error.errors?.[0]?.message || error.error?.message; - if (errorMessage?.includes('fromIndex') && errorMessage?.includes('toIndex')) { - throw new Error( - `Page ${page} is out of bounds. The requested page exceeds the available data. Try a lower page number.`, - ); - } - - throw new Error(errorMessage || `Failed to list users: ${JSON.stringify(result.error)}`); - } - - return result.data || {content: []}; -} - -/** - * Creates a new user. - * - * @param client - Account Manager client - * @param user - User details - * @returns Created user - * @throws Error if request fails - */ -export async function createUser(client: AccountManagerUsersClient, user: UserCreate): Promise { - const result = await client.POST('/dw/rest/v1/users', { - body: user, - }); - - if (result.error) { - const error = result.error as {error?: {message?: string}}; - throw new Error(error.error?.message || `Failed to create user: ${JSON.stringify(result.error)}`); - } - - if (!result.data) { - throw new Error('No data returned from API'); - } - - return result.data; -} - -/** - * Updates an existing user. - * - * @param client - Account Manager client - * @param userId - User ID - * @param changes - Changes to apply - * @returns Updated user - * @throws Error if request fails - */ -export async function updateUser( - client: AccountManagerUsersClient, - userId: string, - changes: UserUpdate, -): Promise { - const result = await client.PUT('/dw/rest/v1/users/{userId}', { - params: {path: {userId}}, - body: changes, - }); - - if (result.error) { - const error = result.error as {error?: {message?: string}}; - throw new Error(error.error?.message || `Failed to update user: ${JSON.stringify(result.error)}`); - } - - if (!result.data) { - throw new Error('No data returned from API'); - } - - return result.data; -} - -/** - * Disables a user (soft delete - sets userState to DELETED). - * Users must be disabled before they can be purged. - * - * @param client - Account Manager client - * @param userId - User ID - * @throws Error if request fails - */ -export async function deleteUser(client: AccountManagerUsersClient, userId: string): Promise { - const result = await client.POST('/dw/rest/v1/users/{userId}/disable', { - params: {path: {userId}}, - body: {}, - }); - - if (result.error) { - const error = result.error as {error?: {message?: string}}; - throw new Error(error.error?.message || `Failed to delete user: ${JSON.stringify(result.error)}`); - } -} - -/** - * Purges a user (hard delete). - * Users must be in DELETED state before they can be purged. - * - * @param client - Account Manager client - * @param userId - User ID - * @throws Error if request fails - */ -export async function purgeUser(client: AccountManagerUsersClient, userId: string): Promise { - const result = await client.DELETE('/dw/rest/v1/users/{userId}', { - params: {path: {userId}}, - }); - - if (result.error) { - const error = result.error as {error?: {message?: string}}; - throw new Error(error.error?.message || `Failed to purge user: ${JSON.stringify(result.error)}`); - } -} - -/** - * Resets a user to INITIAL state and sends activation instructions. - * - * @param client - Account Manager client - * @param userId - User ID - * @throws Error if request fails - */ -export async function resetUser(client: AccountManagerUsersClient, userId: string): Promise { - const result = await client.POST('/dw/rest/v1/users/{userId}/reset', { - params: {path: {userId}}, - body: {}, - }); - - if (result.error) { - const error = result.error as {error?: {message?: string}}; - throw new Error(error.error?.message || `Failed to reset user: ${JSON.stringify(result.error)}`); - } -} - -/** - * Helper to find a user by login (email) from a list of users. - * This is a convenience function since the API doesn't have a direct search by login endpoint. - * - * @param client - Account Manager client - * @param login - User login (email) - * @returns User if found, undefined otherwise - * @throws Error if request fails - */ -export async function findUserByLogin( - client: AccountManagerUsersClient, - login: string, -): Promise { - // Search through paginated results - let page = 0; - const pageSize = 100; - - while (true) { - const result = await client.GET('/dw/rest/v1/users', { - params: { - query: { - pageable: { - page, - size: pageSize, - }, - }, - }, - }); - - if (result.error) { - throw new Error(`Failed to search for user: ${JSON.stringify(result.error)}`); - } - - const users = result.data?.content || []; - const found = users.find((u) => u.mail === login); - - if (found) { - return found; - } - - // If we got fewer results than page size, we've reached the end - if (users.length < pageSize) { - return undefined; - } - - page++; - } -} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 005adfbb..6bc6978f 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -205,6 +205,7 @@ export type { } from './scapi-schemas.js'; export { + createAccountManagerClient, createAccountManagerUsersClient, getUser, listUsers, @@ -218,45 +219,37 @@ export { mapFromInternalRole, ROLE_NAMES_MAP, ROLE_NAMES_MAP_REVERSE, -} from './am-users-api.js'; + createAccountManagerRolesClient, + getRole, + listRoles, + createAccountManagerOrgsClient, +} from './am-api.js'; export type { + AccountManagerClient, + AccountManagerClientConfig, AccountManagerUsersClient, - AccountManagerUsersClientConfig, AccountManagerUser, AccountManagerResponse, AccountManagerError, + UserExpandOption, UserCreate, UserUpdate, UserCollection, UserState, ListUsersOptions, - paths as AccountManagerPaths, - components as AccountManagerComponents, -} from './am-users-api.js'; - -export {createAccountManagerRolesClient, getRole, listRoles} from './am-roles-api.js'; -export type { AccountManagerRolesClient, - AccountManagerRolesClientConfig, AccountManagerRole, AccountManagerRolesResponse, AccountManagerRolesError, RoleCollection, ListRolesOptions, - paths as AccountManagerRolesPaths, - components as AccountManagerRolesComponents, -} from './am-roles-api.js'; - -export {createAccountManagerOrgsClient} from './am-orgs-api.js'; -export type { AccountManagerOrgsClient, - AccountManagerOrgsClientConfig, AccountManagerOrganization, OrganizationCollection, AuditLogRecord, AuditLogCollection, ListOrgsOptions, -} from './am-orgs-api.js'; +} from './am-api.js'; export {createCdnZonesClient, CDN_ZONES_READ_SCOPES, CDN_ZONES_RW_SCOPES} from './cdn-zones.js'; export type { diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 539a5a17..3855d79f 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -107,7 +107,7 @@ export type { CustomApisPaths, CustomApisComponents, AccountManagerUsersClient, - AccountManagerUsersClientConfig, + AccountManagerClientConfig, AccountManagerUser, AccountManagerResponse, AccountManagerError, @@ -115,19 +115,14 @@ export type { UserUpdate, UserCollection, UserState, - AccountManagerPaths, - AccountManagerComponents, + UserExpandOption, AccountManagerRolesClient, - AccountManagerRolesClientConfig, AccountManagerRole, AccountManagerRolesResponse, AccountManagerRolesError, RoleCollection, ListRolesOptions, - AccountManagerRolesPaths, - AccountManagerRolesComponents, AccountManagerOrgsClient, - AccountManagerOrgsClientConfig, AccountManagerOrganization, OrganizationCollection, AuditLogRecord, diff --git a/packages/b2c-tooling-sdk/src/operations/orgs/index.ts b/packages/b2c-tooling-sdk/src/operations/orgs/index.ts index 6d3f1a39..ff859289 100644 --- a/packages/b2c-tooling-sdk/src/operations/orgs/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/orgs/index.ts @@ -60,7 +60,7 @@ import type { OrganizationCollection, AuditLogCollection, ListOrgsOptions, -} from '../../clients/am-orgs-api.js'; +} from '../../clients/am-api.js'; // Re-export types export type { @@ -68,7 +68,7 @@ export type { OrganizationCollection, AuditLogCollection, ListOrgsOptions, -} from '../../clients/am-orgs-api.js'; +} from '../../clients/am-api.js'; /** * Gets an organization by ID. diff --git a/packages/b2c-tooling-sdk/src/operations/roles/index.ts b/packages/b2c-tooling-sdk/src/operations/roles/index.ts index 6fff3864..79e92ab2 100644 --- a/packages/b2c-tooling-sdk/src/operations/roles/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/roles/index.ts @@ -44,5 +44,5 @@ * * @module operations/roles */ -export {getRole, listRoles} from '../../clients/am-roles-api.js'; -export type {AccountManagerRole, RoleCollection, ListRolesOptions} from '../../clients/am-roles-api.js'; +export {getRole, listRoles} from '../../clients/am-api.js'; +export type {AccountManagerRole, RoleCollection, ListRolesOptions} from '../../clients/am-api.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/users/index.ts b/packages/b2c-tooling-sdk/src/operations/users/index.ts index 6f9b3dd2..d70562ed 100644 --- a/packages/b2c-tooling-sdk/src/operations/users/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/users/index.ts @@ -61,12 +61,7 @@ * * @module operations/users */ -import type { - AccountManagerUsersClient, - AccountManagerUser, - UserCreate, - UserUpdate, -} from '../../clients/am-users-api.js'; +import type {AccountManagerUsersClient, AccountManagerUser, UserCreate, UserUpdate} from '../../clients/am-api.js'; import { getUser, listUsers, @@ -76,7 +71,7 @@ import { purgeUser, resetUser, findUserByLogin, -} from '../../clients/am-users-api.js'; +} from '../../clients/am-api.js'; /** * Options for creating a user. @@ -283,4 +278,4 @@ export async function revokeRole( } // Re-export types for convenience -export type {AccountManagerUser, UserCreate, UserUpdate, UserCollection} from '../../clients/am-users-api.js'; +export type {AccountManagerUser, UserCreate, UserUpdate, UserCollection} from '../../clients/am-api.js'; diff --git a/packages/b2c-tooling-sdk/test/cli/org-command.test.ts b/packages/b2c-tooling-sdk/test/cli/am-command.test.ts similarity index 55% rename from packages/b2c-tooling-sdk/test/cli/org-command.test.ts rename to packages/b2c-tooling-sdk/test/cli/am-command.test.ts index 93f038df..3cd5654b 100644 --- a/packages/b2c-tooling-sdk/test/cli/org-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/am-command.test.ts @@ -6,33 +6,33 @@ import {expect} from 'chai'; import sinon from 'sinon'; import {Config} from '@oclif/core'; -import {OrgCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import {stubParse} from '../helpers/stub-parse.js'; // Create a test command class -class TestOrgCommand extends OrgCommand { - static id = 'test:org'; - static description = 'Test org command'; +class TestAmCommand extends AmCommand { + static id = 'test:am'; + static description = 'Test AM command'; async run(): Promise { // Test implementation } // Expose protected methods for testing - public testAccountManagerOrgsClient() { - return this.accountManagerOrgsClient; + public testAccountManagerClient() { + return this.accountManagerClient; } } -describe('cli/org-command', () => { +describe('cli/am-command', () => { let config: Config; - let command: TestOrgCommand; + let command: TestAmCommand; beforeEach(async () => { isolateConfig(); config = await Config.load(); - command = new TestOrgCommand([], config); + command = new TestAmCommand([], config); }); afterEach(() => { @@ -40,21 +40,28 @@ describe('cli/org-command', () => { restoreConfig(); }); - describe('accountManagerOrgsClient', () => { - it('should create account manager orgs client', async () => { + describe('accountManagerClient', () => { + it('should create unified account manager client', async () => { stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); await command.init(); - const client = command.testAccountManagerOrgsClient(); + const client = command.testAccountManagerClient(); expect(client).to.exist; + // Unified client should have all API methods + expect(client.getUser).to.be.a('function'); + expect(client.listUsers).to.be.a('function'); + expect(client.getRole).to.be.a('function'); + expect(client.listRoles).to.be.a('function'); + expect(client.getOrg).to.be.a('function'); + expect(client.listOrgs).to.be.a('function'); }); it('should use OAuth credentials from config', async () => { stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); await command.init(); - const client = command.testAccountManagerOrgsClient(); + const client = command.testAccountManagerClient(); expect(client).to.exist; // Client should be created with OAuth authentication diff --git a/packages/b2c-tooling-sdk/test/cli/role-command.test.ts b/packages/b2c-tooling-sdk/test/cli/role-command.test.ts deleted file mode 100644 index 5f1f3bcf..00000000 --- a/packages/b2c-tooling-sdk/test/cli/role-command.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ -import {expect} from 'chai'; -import sinon from 'sinon'; -import {Config} from '@oclif/core'; -import {RoleCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import {stubParse} from '../helpers/stub-parse.js'; - -// Create a test command class -class TestRoleCommand extends RoleCommand { - static id = 'test:role'; - static description = 'Test role command'; - - async run(): Promise { - // Test implementation - } - - // Expose protected methods for testing - public testAccountManagerRolesClient() { - return this.accountManagerRolesClient; - } -} - -describe('cli/role-command', () => { - let config: Config; - let command: TestRoleCommand; - - beforeEach(async () => { - isolateConfig(); - config = await Config.load(); - command = new TestRoleCommand([], config); - }); - - afterEach(() => { - sinon.restore(); - restoreConfig(); - }); - - describe('accountManagerRolesClient', () => { - it('should create account manager roles client', async () => { - stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); - - await command.init(); - const client = command.testAccountManagerRolesClient(); - - expect(client).to.exist; - }); - - it('should use OAuth credentials from config', async () => { - stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); - - await command.init(); - const client = command.testAccountManagerRolesClient(); - - expect(client).to.exist; - // Client should be created with OAuth authentication - }); - }); -}); diff --git a/packages/b2c-tooling-sdk/test/cli/user-command.test.ts b/packages/b2c-tooling-sdk/test/cli/user-command.test.ts deleted file mode 100644 index 99a44bf3..00000000 --- a/packages/b2c-tooling-sdk/test/cli/user-command.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ - -import {expect} from 'chai'; -import sinon from 'sinon'; -import {Config} from '@oclif/core'; -import {UserCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; -import {stubParse} from '../helpers/stub-parse.js'; - -// Create a test command class -class TestUserCommand extends UserCommand { - static id = 'test:user'; - static description = 'Test user command'; - - async run(): Promise { - // Test implementation - } - - // Expose protected methods for testing - public testAccountManagerUsersClient() { - return this.accountManagerUsersClient; - } -} - -describe('cli/user-command', () => { - let config: Config; - let command: TestUserCommand; - - beforeEach(async () => { - isolateConfig(); - config = await Config.load(); - command = new TestUserCommand([], config); - }); - - afterEach(() => { - sinon.restore(); - restoreConfig(); - }); - - describe('accountManagerUsersClient', () => { - it('should create account manager users client', async () => { - stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); - - await command.init(); - const client = command.testAccountManagerUsersClient(); - - expect(client).to.exist; - }); - - it('should use OAuth credentials from config', async () => { - stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); - - await command.init(); - const client = command.testAccountManagerUsersClient(); - - expect(client).to.exist; - // Client should be created with OAuth authentication - }); - }); -}); diff --git a/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts index 666054de..c7f52389 100644 --- a/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts @@ -7,7 +7,7 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -import {createAccountManagerOrgsClient} from '../../src/clients/am-orgs-api.js'; +import {createAccountManagerOrgsClient} from '../../src/clients/am-api.js'; import {MockAuthStrategy} from '../helpers/mock-auth.js'; const TEST_HOST = 'account.test.demandware.com'; diff --git a/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts index 964aa57e..af130817 100644 --- a/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts @@ -7,7 +7,7 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -import {createAccountManagerRolesClient, getRole, listRoles} from '../../src/clients/am-roles-api.js'; +import {createAccountManagerRolesClient, getRole, listRoles} from '../../src/clients/am-api.js'; import {MockAuthStrategy} from '../helpers/mock-auth.js'; const TEST_HOST = 'account.test.demandware.com'; diff --git a/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts index 2ad13cec..8c23e93b 100644 --- a/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts @@ -18,7 +18,7 @@ import { findUserByLogin, mapToInternalRole, mapFromInternalRole, -} from '../../src/clients/am-users-api.js'; +} from '../../src/clients/am-api.js'; import {MockAuthStrategy} from '../helpers/mock-auth.js'; const TEST_HOST = 'account.test.demandware.com'; diff --git a/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts b/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts index a06f467d..87501738 100644 --- a/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts @@ -8,7 +8,7 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {getOrg, getOrgByName, listOrgs, getOrgAuditLogs} from '../../../src/operations/orgs/index.js'; -import {createAccountManagerOrgsClient} from '../../../src/clients/am-orgs-api.js'; +import {createAccountManagerOrgsClient} from '../../../src/clients/am-api.js'; import {MockAuthStrategy} from '../../helpers/mock-auth.js'; const TEST_HOST = 'account.test.demandware.com'; diff --git a/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts b/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts index 3114b887..54494541 100644 --- a/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts @@ -8,7 +8,7 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {getRole, listRoles} from '../../../src/operations/roles/index.js'; -import {createAccountManagerRolesClient} from '../../../src/clients/am-roles-api.js'; +import {createAccountManagerRolesClient} from '../../../src/clients/am-api.js'; import {MockAuthStrategy} from '../../helpers/mock-auth.js'; const TEST_HOST = 'account.test.demandware.com'; diff --git a/packages/b2c-tooling-sdk/test/operations/users/index.test.ts b/packages/b2c-tooling-sdk/test/operations/users/index.test.ts index 7344f594..9510cab2 100644 --- a/packages/b2c-tooling-sdk/test/operations/users/index.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/users/index.test.ts @@ -8,7 +8,7 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {getUserByLogin, createUser, updateUser, grantRole, revokeRole} from '../../../src/operations/users/index.js'; -import {createAccountManagerUsersClient} from '../../../src/clients/am-users-api.js'; +import {createAccountManagerUsersClient} from '../../../src/clients/am-api.js'; import {MockAuthStrategy} from '../../helpers/mock-auth.js'; const TEST_HOST = 'account.test.demandware.com'; From 8826487f33e6a2400280a30bdcc6581e0dc4a0b8 Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Tue, 27 Jan 2026 21:08:29 +0530 Subject: [PATCH 11/15] @W-20893693: Adding AM topic with users, role and org subtopics --- .../test/commands/am/orgs/audit.test.ts | 89 +++++-------------- packages/b2c-cli/test/helpers/test-setup.ts | 37 ++++++++ .../b2c-tooling-sdk/src/cli/am-command.ts | 31 ++++++- .../b2c-tooling-sdk/src/cli/oauth-command.ts | 21 ++++- .../test/cli/am-command.test.ts | 85 +++++++++++++++++- 5 files changed, 191 insertions(+), 72 deletions(-) diff --git a/packages/b2c-cli/test/commands/am/orgs/audit.test.ts b/packages/b2c-cli/test/commands/am/orgs/audit.test.ts index e66238f9..eb631a0b 100644 --- a/packages/b2c-cli/test/commands/am/orgs/audit.test.ts +++ b/packages/b2c-cli/test/commands/am/orgs/audit.test.ts @@ -10,19 +10,15 @@ import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import OrgAudit from '../../../../src/commands/am/orgs/audit.js'; -import {stubCommandConfigAndLogger, stubJsonEnabled, makeCommandThrowOnError} from '../../../helpers/test-setup.js'; +import { + stubCommandConfigAndLogger, + stubJsonEnabled, + makeCommandThrowOnError, + stubImplicitOAuthStrategy, +} from '../../../helpers/test-setup.js'; const TEST_HOST = 'account.test.demandware.com'; const BASE_URL = `https://${TEST_HOST}/dw/rest/v1`; -const OAUTH_URL = `https://${TEST_HOST}/dwsso/oauth2/access_token`; - -function createMockJWT(payload: Record = {}): string { - const header = {alg: 'HS256', typ: 'JWT'}; - const defaultPayload = {sub: 'test-client', iat: Math.floor(Date.now() / 1000), ...payload}; - const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url'); - const payloadB64 = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url'); - return `${headerB64}.${payloadB64}.signature`; -} /** * Unit tests for org audit command CLI logic. @@ -103,15 +99,10 @@ describe('org audit', () => { stubCommandConfigAndLogger(command); stubJsonEnabled(command, true); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), http.get(`${BASE_URL}/organizations/org-123`, () => { return HttpResponse.json(mockOrg); }), @@ -140,15 +131,10 @@ describe('org audit', () => { (command as any).flags = {}; stubCommandConfigAndLogger(command); stubJsonEnabled(command, false); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), http.get(`${BASE_URL}/organizations/org-123`, () => { return HttpResponse.json(mockOrg); }), @@ -173,15 +159,10 @@ describe('org audit', () => { stubCommandConfigAndLogger(command); stubJsonEnabled(command, true); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), http.get(`${BASE_URL}/organizations/org-123`, () => { return HttpResponse.json(mockOrg); }), @@ -206,14 +187,10 @@ describe('org audit', () => { stubCommandConfigAndLogger(command); stubJsonEnabled(command, true); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); + server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), http.get(`${BASE_URL}/organizations/Test%20Organization`, () => { return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); }), @@ -241,15 +218,10 @@ describe('org audit', () => { stubCommandConfigAndLogger(command); makeCommandThrowOnError(command); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), http.get(`${BASE_URL}/organizations/nonexistent-org`, () => { return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); }), @@ -278,15 +250,10 @@ describe('org audit', () => { (command as any).flags = {columns: 'timestamp,authorDisplayName,eventType'}; stubCommandConfigAndLogger(command); stubJsonEnabled(command, false); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), http.get(`${BASE_URL}/organizations/org-123`, () => { return HttpResponse.json(mockOrg); }), @@ -312,15 +279,10 @@ describe('org audit', () => { (command as any).flags = {extended: true}; stubCommandConfigAndLogger(command); stubJsonEnabled(command, false); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), http.get(`${BASE_URL}/organizations/org-123`, () => { return HttpResponse.json(mockOrg); }), @@ -345,6 +307,8 @@ describe('org audit', () => { stubCommandConfigAndLogger(command); stubJsonEnabled(command, true); + // Mock implicit OAuth strategy to avoid browser-based flow + stubImplicitOAuthStrategy(command); const logsWithTimestamps = [ { @@ -357,13 +321,6 @@ describe('org audit', () => { ]; server.use( - http.post(OAUTH_URL, () => { - return HttpResponse.json({ - access_token: createMockJWT({sub: 'test-client'}), - expires_in: 1800, - scope: 'sfcc.accountmanager.user.manage', - }); - }), http.get(`${BASE_URL}/organizations/org-123`, () => { return HttpResponse.json(mockOrg); }), diff --git a/packages/b2c-cli/test/helpers/test-setup.ts b/packages/b2c-cli/test/helpers/test-setup.ts index ad47c8cd..53e3180c 100644 --- a/packages/b2c-cli/test/helpers/test-setup.ts +++ b/packages/b2c-cli/test/helpers/test-setup.ts @@ -8,8 +8,19 @@ import type {Config} from '@oclif/core'; import {captureOutput} from '@oclif/test'; import sinon from 'sinon'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; import {stubParse} from './stub-parse.js'; +type TokenResponse = { + accessToken: string; + expires: Date; + scopes: string[]; +}; + +function futureDate(minutes: number): Date { + return new Date(Date.now() + minutes * 60 * 1000); +} + /** * Run a command silently, capturing stdout/stderr. * Use this when you don't need to verify console output. @@ -137,3 +148,29 @@ export function makeCommandThrowOnError(command: any): void { throw new Error(msg); }; } + +/** + * Mocks getOAuthStrategy to return ImplicitOAuthStrategy with mocked implicitFlowLogin. + * This follows the pattern from oauth-implicit.test.ts to avoid browser-based OAuth flow. + * Use this for AM command tests that need to test implicit flow behavior without triggering + * the interactive browser-based authentication. + * + * @param command - The command instance to stub + * @param accountManagerHost - Account Manager hostname (default: 'account.test.demandware.com') + */ +export function stubImplicitOAuthStrategy(command: any, accountManagerHost = 'account.test.demandware.com'): void { + const strategy = new ImplicitOAuthStrategy({ + clientId: 'test-client-id', + accountManagerHost, + }); + + // Mock implicitFlowLogin to avoid browser-based OAuth flow (following oauth-implicit.test.ts pattern) + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => ({ + accessToken: 'test-token', + expires: futureDate(30), + scopes: [], + }); + + // Stub getOAuthStrategy to return our mocked strategy + sinon.stub(command as {getOAuthStrategy: () => typeof strategy}, 'getOAuthStrategy').returns(strategy); +} diff --git a/packages/b2c-tooling-sdk/src/cli/am-command.ts b/packages/b2c-tooling-sdk/src/cli/am-command.ts index f944467c..10d5f52b 100644 --- a/packages/b2c-tooling-sdk/src/cli/am-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/am-command.ts @@ -7,16 +7,18 @@ import {Command} from '@oclif/core'; import {OAuthCommand} from './oauth-command.js'; import {createAccountManagerClient} from '../clients/am-api.js'; import type {AccountManagerClient} from '../clients/am-api.js'; +import type {AuthMethod} from './config.js'; /** * Base command for Account Manager operations. * * Extends OAuthCommand with Account Manager client setup for users, roles, and organizations. + * Overrides default auth methods to prioritize implicit flow for Account Manager operations. * * @example * export default class UserList extends AmCommand { * async run(): Promise { - * const users = await listUsers(this.accountManagerUsersClient, {}); + * const users = await this.accountManagerClient.listUsers({}); * // ... * } * } @@ -24,12 +26,37 @@ import type {AccountManagerClient} from '../clients/am-api.js'; * @example * export default class OrgList extends AmCommand { * async run(): Promise { - * const orgs = await this.accountManagerOrgsClient.listOrgs(); + * const orgs = await this.accountManagerClient.listOrgs(); * // ... * } * } */ export abstract class AmCommand extends OAuthCommand { + /** + * Override default auth methods to prioritize implicit flow for Account Manager. + * Gets the default methods from parent class, then ensures 'implicit' is first. + * If 'implicit' is already present, moves it to first position. + * If 'implicit' is not present, prepends it to the beginning. + */ + protected override getDefaultAuthMethods(): AuthMethod[] { + const defaultMethods = super.getDefaultAuthMethods(); + const implicitIndex = defaultMethods.indexOf('implicit'); + + if (implicitIndex === 0) { + // Already first, return as-is + return defaultMethods; + } + + if (implicitIndex > 0) { + // Implicit exists but not first - move it to first + const methods = [...defaultMethods]; + methods.splice(implicitIndex, 1); + return ['implicit', ...methods]; + } + + // Implicit not present - prepend it + return ['implicit', ...defaultMethods]; + } private _accountManagerClient?: AccountManagerClient; /** diff --git a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts index 93c971d7..4b8bb1b4 100644 --- a/packages/b2c-tooling-sdk/src/cli/oauth-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/oauth-command.ts @@ -13,6 +13,12 @@ import {ImplicitOAuthStrategy} from '../auth/oauth-implicit.js'; import {t} from '../i18n/index.js'; import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; +/** + * Default OAuth authentication methods array used by getOAuthStrategy. + * Extracted from getOAuthStrategy() to ensure getDefaultAuthMethods() returns the same array. + */ +const DEFAULT_OAUTH_AUTH_METHODS: AuthMethod[] = ['client-credentials', 'implicit']; + /** * Base command for operations requiring OAuth authentication. * Use this for platform-level operations like ODS, APIs. @@ -85,6 +91,17 @@ export abstract class OAuthCommand extends BaseCommand return this.resolvedConfig.values.accountManagerHost ?? DEFAULT_ACCOUNT_MANAGER_HOST; } + /** + * Gets the default authentication methods in priority order. + * This method is used by getOAuthStrategy() when no auth methods are specified in config. + * Subclasses can override this to change the default priority. + * + * @returns Array of auth methods in priority order (first is highest priority) + */ + protected getDefaultAuthMethods(): AuthMethod[] { + return DEFAULT_OAUTH_AUTH_METHODS; + } + /** * Gets an OAuth auth strategy based on allowed auth methods and available credentials. * @@ -96,8 +113,8 @@ export abstract class OAuthCommand extends BaseCommand protected getOAuthStrategy(): OAuthStrategy | ImplicitOAuthStrategy { const config = this.resolvedConfig.values; const accountManagerHost = this.accountManagerHost; - // Default to client-credentials and implicit if no methods specified - const allowedMethods = config.authMethods || (['client-credentials', 'implicit'] as AuthMethod[]); + // Use getDefaultAuthMethods() to get default array, allowing subclasses to override + const allowedMethods = config.authMethods || this.getDefaultAuthMethods(); for (const method of allowedMethods) { switch (method) { diff --git a/packages/b2c-tooling-sdk/test/cli/am-command.test.ts b/packages/b2c-tooling-sdk/test/cli/am-command.test.ts index 3cd5654b..c940f96c 100644 --- a/packages/b2c-tooling-sdk/test/cli/am-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/am-command.test.ts @@ -7,9 +7,20 @@ import {expect} from 'chai'; import sinon from 'sinon'; import {Config} from '@oclif/core'; import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import {stubParse} from '../helpers/stub-parse.js'; +type TokenResponse = { + accessToken: string; + expires: Date; + scopes: string[]; +}; + +function futureDate(minutes: number): Date { + return new Date(Date.now() + minutes * 60 * 1000); +} + // Create a test command class class TestAmCommand extends AmCommand { static id = 'test:am'; @@ -23,6 +34,10 @@ class TestAmCommand extends AmCommand { public testAccountManagerClient() { return this.accountManagerClient; } + + public getDefaultAuthMethods() { + return super.getDefaultAuthMethods(); + } } describe('cli/am-command', () => { @@ -40,11 +55,56 @@ describe('cli/am-command', () => { restoreConfig(); }); + describe('getDefaultAuthMethods', () => { + it('should get from parent and move implicit to first when present', () => { + const methods = command.getDefaultAuthMethods(); + // Parent returns ['client-credentials', 'implicit'] + // AmCommand should move 'implicit' to first: ['implicit', 'client-credentials'] + expect(methods).to.deep.equal(['implicit', 'client-credentials']); + expect(methods[0]).to.equal('implicit'); + expect(methods).to.include('client-credentials'); + }); + + it('should prepend implicit when not present in parent defaults', () => { + // This test verifies the logic works even if parent didn't include implicit + // In practice, parent always includes it, but we test the prepend logic + const parentMethods = ['client-credentials']; + // Simulate what would happen if parent didn't have implicit + const implicitIndex = parentMethods.indexOf('implicit'); + if (implicitIndex < 0) { + const result = ['implicit', ...parentMethods]; + expect(result).to.deep.equal(['implicit', 'client-credentials']); + expect(result[0]).to.equal('implicit'); + } + }); + }); + describe('accountManagerClient', () => { it('should create unified account manager client', async () => { - stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); + // Use implicit flow (AmCommand's default priority) with mocked implicitFlowLogin + stubParse(command, { + 'client-id': 'test-client', + }); await command.init(); + + // Mock getOAuthStrategy to return ImplicitOAuthStrategy with mocked implicitFlowLogin + const strategy = new ImplicitOAuthStrategy({ + clientId: 'test-client', + accountManagerHost: 'account.test.demandware.com', + }); + + // Mock implicitFlowLogin to avoid browser-based OAuth flow (following oauth-implicit.test.ts pattern) + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => ({ + accessToken: 'test-token', + expires: futureDate(30), + scopes: [], + }); + + // Stub getOAuthStrategy to return our mocked strategy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sinon.stub(command as any, 'getOAuthStrategy').returns(strategy); + const client = command.testAccountManagerClient(); expect(client).to.exist; @@ -58,9 +118,30 @@ describe('cli/am-command', () => { }); it('should use OAuth credentials from config', async () => { - stubParse(command, {'client-id': 'test-client', 'client-secret': 'test-secret'}); + // Use implicit flow (AmCommand's default priority) with mocked implicitFlowLogin + stubParse(command, { + 'client-id': 'test-client', + }); await command.init(); + + // Mock getOAuthStrategy to return ImplicitOAuthStrategy with mocked implicitFlowLogin + const strategy = new ImplicitOAuthStrategy({ + clientId: 'test-client', + accountManagerHost: 'account.test.demandware.com', + }); + + // Mock implicitFlowLogin to avoid browser-based OAuth flow (following oauth-implicit.test.ts pattern) + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => ({ + accessToken: 'test-token', + expires: futureDate(30), + scopes: [], + }); + + // Stub getOAuthStrategy to return our mocked strategy + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sinon.stub(command as any, 'getOAuthStrategy').returns(strategy); + const client = command.testAccountManagerClient(); expect(client).to.exist; From 753656744700ed47a6eeaa9ab7a3bb174fbb2f02 Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Tue, 27 Jan 2026 21:26:47 +0530 Subject: [PATCH 12/15] @W-20893693: Adding AM topic with users, role and org subtopics --- packages/b2c-cli/test/commands/docs/search.test.ts | 14 ++++++++++++-- .../test/commands/ecdn/security/get.test.ts | 9 ++++++++- .../b2c-cli/test/commands/ecdn/zones/list.test.ts | 8 +++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/b2c-cli/test/commands/docs/search.test.ts b/packages/b2c-cli/test/commands/docs/search.test.ts index 8c07d1be..59200c59 100644 --- a/packages/b2c-cli/test/commands/docs/search.test.ts +++ b/packages/b2c-cli/test/commands/docs/search.test.ts @@ -8,9 +8,19 @@ import {ux} from '@oclif/core'; import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; +import type {DocEntry, SearchResult} from '@salesforce/b2c-tooling-sdk/operations/docs'; import DocsSearch from '../../../src/commands/docs/search.js'; import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../helpers/test-setup.js'; +interface ListDocsResponse { + entries: DocEntry[]; +} + +interface SearchDocsResponse { + query?: string; + results: SearchResult[]; +} + describe('docs search', () => { const hooks = createIsolatedConfigHooks(); @@ -43,7 +53,7 @@ describe('docs search', () => { const listStub = sinon.stub().returns([{id: 'a', title: 'A', filePath: 'a.md'}]); command.operations = {...command.operations, listDocs: listStub}; - const result = (await runSilent(() => command.run())) as {entries: unknown[]}; + const result = await runSilent(() => command.run()); expect(result.entries).to.have.length(1); }); @@ -69,7 +79,7 @@ describe('docs search', () => { const searchStub = sinon.stub().returns([{entry: {id: 'a', title: 'A', filePath: 'a.md'}, score: 0.1}]); command.operations = {...command.operations, searchDocs: searchStub}; - const result = (await runSilent(() => command.run())) as {results: unknown[]}; + const result = await runSilent(() => command.run()); expect(result.results).to.have.length(1); }); diff --git a/packages/b2c-cli/test/commands/ecdn/security/get.test.ts b/packages/b2c-cli/test/commands/ecdn/security/get.test.ts index 12f30002..83684433 100644 --- a/packages/b2c-cli/test/commands/ecdn/security/get.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/security/get.test.ts @@ -6,9 +6,16 @@ import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; +import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import EcdnSecurityGet from '../../../../src/commands/ecdn/security/get.js'; import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../../helpers/test-setup.js'; +type SecuritySetting = CdnZonesComponents['schemas']['SecuritySetting']; + +interface GetOutput { + settings: SecuritySetting; +} + /** * Unit tests for eCDN security get command CLI logic. * Tests output formatting and error handling. @@ -98,7 +105,7 @@ describe('ecdn security get', () => { }), }); - const result = (await runSilent(() => command.run())) as {settings: {securityLevel: string; wafEnabled: boolean}}; + const result = await runSilent(() => command.run()); expect(result.settings.securityLevel).to.equal('high'); expect(result.settings.wafEnabled).to.be.true; diff --git a/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts b/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts index befc4047..3a84047a 100644 --- a/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts @@ -6,9 +6,15 @@ import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; +import type {Zone} from '@salesforce/b2c-tooling-sdk/clients'; import EcdnZonesList from '../../../../src/commands/ecdn/zones/list.js'; import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../../helpers/test-setup.js'; +interface ListOutput { + zones: Zone[]; + total: number; +} + /** * Unit tests for eCDN zones list command CLI logic. * Tests output formatting, column selection, and error handling. @@ -129,7 +135,7 @@ describe('ecdn zones list', () => { }), }); - const result = (await runSilent(() => command.run())) as {total: number; zones: Array<{name: string}>}; + const result = await runSilent(() => command.run()); expect(result).to.have.property('total', 1); expect(result.zones).to.have.lengthOf(1); From afff2e10261c7642c6d104316eb618909f15d7fc Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Wed, 28 Jan 2026 00:12:50 +0530 Subject: [PATCH 13/15] @W-20893693: Adding AM topic with users, role and org subtopics --- docs/api-readme.md | 230 ++++++++++++++++++++++++++++----------------- 1 file changed, 144 insertions(+), 86 deletions(-) diff --git a/docs/api-readme.md b/docs/api-readme.md index 4dc70450..237e747d 100644 --- a/docs/api-readme.md +++ b/docs/api-readme.md @@ -242,151 +242,207 @@ const { data, error } = await instance.ocapi.PATCH('/code_versions/{code_version ## Account Manager Operations -The SDK provides operations for managing users, roles, and organizations. +The SDK provides a unified client for managing users, roles, and organizations through the Account Manager API. -### User Management +### Authentication + +Account Manager operations use **OAuth implicit flow** by default, which opens a browser for interactive authentication. This is ideal for development and manual operations where you want to use roles assigned to your user account. + +For CI/CD and automation, you can also use **OAuth client credentials flow** (requires both client ID and secret). + +### Unified Client (Recommended) + +The recommended approach is to use the unified `createAccountManagerClient` which provides access to all Account Manager APIs (users, roles, and organizations): ```typescript -import { - listUsers, - getUserByLogin, - createUser, - updateUser, - deleteUser, - resetUser, - grantRole, - revokeRole, -} from '@salesforce/b2c-tooling-sdk/operations/users'; -import {createAccountManagerUsersClient} from '@salesforce/b2c-tooling-sdk/clients'; +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +// Create Account Manager client with implicit OAuth (opens browser for login) +const auth = new ImplicitOAuthStrategy({ + clientId: 'your-client-id', + // No clientSecret needed for implicit flow +}); + +const client = createAccountManagerClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Users API +const users = await client.listUsers({ size: 25, page: 0 }); +const user = await client.getUser('user-id'); +const userByLogin = await client.findUserByLogin('user@example.com'); +await client.createUser({ + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + organizations: ['org-id'], + primaryOrganization: 'org-id', +}); +await client.updateUser('user-id', { firstName: 'Jane' }); +await client.grantRole('user-id', 'bm-admin', 'tenant1,tenant2'); +await client.revokeRole('user-id', 'bm-admin', 'tenant1'); +await client.resetUser('user-id'); +await client.deleteUser('user-id'); + +// Roles API +const roles = await client.listRoles({ size: 20, page: 0 }); +const role = await client.getRole('bm-admin'); + +// Organizations API +const orgs = await client.listOrgs({ size: 25, page: 0 }); +const org = await client.getOrg('org-id'); +const orgByName = await client.getOrgByName('My Organization'); +const auditLogs = await client.getOrgAuditLogs('org-id'); +``` + +### Client Credentials Flow (Alternative) + +For automation and CI/CD, you can use client credentials flow: + +```typescript +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; -// Create Account Manager client +// Create Account Manager client with client credentials OAuth const auth = new OAuthStrategy({ clientId: 'your-client-id', clientSecret: 'your-client-secret', }); -const client = createAccountManagerUsersClient( +const client = createAccountManagerClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Use the unified client as shown above +``` + +### Individual Clients + +If you only need access to a specific API, you can create individual clients: + +```typescript +import { + createAccountManagerUsersClient, + createAccountManagerRolesClient, + createAccountManagerOrgsClient, +} from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new ImplicitOAuthStrategy({ + clientId: 'your-client-id', +}); + +// Users client +const usersClient = createAccountManagerUsersClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Roles client +const rolesClient = createAccountManagerRolesClient( + { accountManagerHost: 'account.demandware.com' }, + auth, +); + +// Organizations client +const orgsClient = createAccountManagerOrgsClient( { accountManagerHost: 'account.demandware.com' }, auth, ); +``` + +### User Operations + +```typescript +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; + +const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const client = createAccountManagerClient({}, auth); // List users with pagination -const users = await listUsers(client, { size: 25, page: 0 }); +const users = await client.listUsers({ size: 25, page: 0 }); + +// Get user by email/login +const user = await client.findUserByLogin('user@example.com'); -// Get user by email -const user = await getUserByLogin(client, 'user@example.com'); +// Get user with expanded organizations and roles +const userExpanded = await client.getUser('user-id', ['organizations', 'roles']); // Create a new user -const newUser = await createUser(client, { - user: { - mail: 'newuser@example.com', - firstName: 'John', - lastName: 'Doe', - organizations: ['org-id'], - primaryOrganization: 'org-id', - }, +const newUser = await client.createUser({ + mail: 'newuser@example.com', + firstName: 'John', + lastName: 'Doe', + organizations: ['org-id'], + primaryOrganization: 'org-id', }); // Update a user -await updateUser(client, { - userId: user.id!, - changes: { firstName: 'Jane' }, -}); +await client.updateUser('user-id', { firstName: 'Jane' }); // Grant a role to a user -await grantRole(client, { - userId: user.id!, - role: 'bm-admin', - scope: 'tenant1,tenant2', // Optional tenant filter -}); +await client.grantRole('user-id', 'bm-admin', 'tenant1,tenant2'); // Optional tenant filter // Revoke a role from a user -await revokeRole(client, { - userId: user.id!, - role: 'bm-admin', - scope: 'tenant1', // Optional: remove specific scope -}); +await client.revokeRole('user-id', 'bm-admin', 'tenant1'); // Optional: remove specific scope // Reset user to INITIAL state -await resetUser(client, user.id!); +await client.resetUser('user-id'); // Delete (disable) a user -await deleteUser(client, user.id!); +await client.deleteUser('user-id'); ``` -### Role Management +### Role Operations ```typescript -import { - createAccountManagerRolesClient, - getRole, - listRoles, -} from '@salesforce/b2c-tooling-sdk/operations/roles'; -import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; -// Create Account Manager Roles client -const auth = new OAuthStrategy({ - clientId: 'your-client-id', - clientSecret: 'your-client-secret', -}); - -const client = createAccountManagerRolesClient( - { accountManagerHost: 'account.demandware.com' }, - auth, -); +const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const client = createAccountManagerClient({}, auth); // Get role details by ID -const role = await getRole(client, 'bm-admin'); +const role = await client.getRole('bm-admin'); // List all roles with pagination -const roles = await listRoles(client, { size: 25, page: 0 }); +const roles = await client.listRoles({ size: 25, page: 0 }); // List roles filtered by target type -const userRoles = await listRoles(client, { +const userRoles = await client.listRoles({ size: 25, page: 0, roleTargetType: 'User', }); ``` -### Organization Management +### Organization Operations ```typescript -import { - createAccountManagerOrgsClient, - getOrg, - getOrgByName, - listOrgs, - getOrgAuditLogs, -} from '@salesforce/b2c-tooling-sdk/operations/orgs'; -import { OAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; +import { createAccountManagerClient } from '@salesforce/b2c-tooling-sdk/clients'; +import { ImplicitOAuthStrategy } from '@salesforce/b2c-tooling-sdk/auth'; -// Create Account Manager Organizations client -const auth = new OAuthStrategy({ - clientId: 'your-client-id', - clientSecret: 'your-client-secret', -}); - -const client = createAccountManagerOrgsClient( - { accountManagerHost: 'account.demandware.com' }, - auth, -); +const auth = new ImplicitOAuthStrategy({ clientId: 'your-client-id' }); +const client = createAccountManagerClient({}, auth); // Get organization by ID -const org = await getOrg(client, 'org-123'); +const org = await client.getOrg('org-123'); // Get organization by name -const orgByName = await getOrgByName(client, 'My Organization'); +const orgByName = await client.getOrgByName('My Organization'); // List organizations with pagination -const orgs = await listOrgs(client, { size: 25, page: 0 }); +const orgs = await client.listOrgs({ size: 25, page: 0 }); // List all organizations (uses max page size of 5000) -const allOrgs = await listOrgs(client, { all: true }); +const allOrgs = await client.listOrgs({ all: true }); // Get audit logs for an organization -const auditLogs = await getOrgAuditLogs(client, 'org-123'); +const auditLogs = await client.getOrgAuditLogs('org-123'); ``` ### Required Permissions @@ -394,6 +450,8 @@ const auditLogs = await getOrgAuditLogs(client, 'org-123'); Account Manager operations require: - OAuth client with `sfcc.accountmanager.user.manage` scope - Account Manager hostname configuration +- For implicit flow: roles configured on your **user account** +- For client credentials flow: roles configured on the **API client** ## Logging From 0ae30053679bce4f1d3e1ba4df5c99f4d2092272 Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Fri, 30 Jan 2026 10:53:04 +0530 Subject: [PATCH 14/15] @W-20893693: REbasing from main --- packages/b2c-cli/test/commands/docs/search.test.ts | 14 ++------------ .../test/commands/ecdn/security/get.test.ts | 9 +-------- .../b2c-cli/test/commands/ecdn/zones/list.test.ts | 8 +------- 3 files changed, 4 insertions(+), 27 deletions(-) diff --git a/packages/b2c-cli/test/commands/docs/search.test.ts b/packages/b2c-cli/test/commands/docs/search.test.ts index 59200c59..8c07d1be 100644 --- a/packages/b2c-cli/test/commands/docs/search.test.ts +++ b/packages/b2c-cli/test/commands/docs/search.test.ts @@ -8,19 +8,9 @@ import {ux} from '@oclif/core'; import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; -import type {DocEntry, SearchResult} from '@salesforce/b2c-tooling-sdk/operations/docs'; import DocsSearch from '../../../src/commands/docs/search.js'; import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../helpers/test-setup.js'; -interface ListDocsResponse { - entries: DocEntry[]; -} - -interface SearchDocsResponse { - query?: string; - results: SearchResult[]; -} - describe('docs search', () => { const hooks = createIsolatedConfigHooks(); @@ -53,7 +43,7 @@ describe('docs search', () => { const listStub = sinon.stub().returns([{id: 'a', title: 'A', filePath: 'a.md'}]); command.operations = {...command.operations, listDocs: listStub}; - const result = await runSilent(() => command.run()); + const result = (await runSilent(() => command.run())) as {entries: unknown[]}; expect(result.entries).to.have.length(1); }); @@ -79,7 +69,7 @@ describe('docs search', () => { const searchStub = sinon.stub().returns([{entry: {id: 'a', title: 'A', filePath: 'a.md'}, score: 0.1}]); command.operations = {...command.operations, searchDocs: searchStub}; - const result = await runSilent(() => command.run()); + const result = (await runSilent(() => command.run())) as {results: unknown[]}; expect(result.results).to.have.length(1); }); diff --git a/packages/b2c-cli/test/commands/ecdn/security/get.test.ts b/packages/b2c-cli/test/commands/ecdn/security/get.test.ts index 83684433..12f30002 100644 --- a/packages/b2c-cli/test/commands/ecdn/security/get.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/security/get.test.ts @@ -6,16 +6,9 @@ import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; -import type {CdnZonesComponents} from '@salesforce/b2c-tooling-sdk/clients'; import EcdnSecurityGet from '../../../../src/commands/ecdn/security/get.js'; import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../../helpers/test-setup.js'; -type SecuritySetting = CdnZonesComponents['schemas']['SecuritySetting']; - -interface GetOutput { - settings: SecuritySetting; -} - /** * Unit tests for eCDN security get command CLI logic. * Tests output formatting and error handling. @@ -105,7 +98,7 @@ describe('ecdn security get', () => { }), }); - const result = await runSilent(() => command.run()); + const result = (await runSilent(() => command.run())) as {settings: {securityLevel: string; wafEnabled: boolean}}; expect(result.settings.securityLevel).to.equal('high'); expect(result.settings.wafEnabled).to.be.true; diff --git a/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts b/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts index 3a84047a..befc4047 100644 --- a/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts +++ b/packages/b2c-cli/test/commands/ecdn/zones/list.test.ts @@ -6,15 +6,9 @@ import {expect} from 'chai'; import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; -import type {Zone} from '@salesforce/b2c-tooling-sdk/clients'; import EcdnZonesList from '../../../../src/commands/ecdn/zones/list.js'; import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../../helpers/test-setup.js'; -interface ListOutput { - zones: Zone[]; - total: number; -} - /** * Unit tests for eCDN zones list command CLI logic. * Tests output formatting, column selection, and error handling. @@ -135,7 +129,7 @@ describe('ecdn zones list', () => { }), }); - const result = await runSilent(() => command.run()); + const result = (await runSilent(() => command.run())) as {total: number; zones: Array<{name: string}>}; expect(result).to.have.property('total', 1); expect(result.zones).to.have.lengthOf(1); From 22113ae8257c5c6aa524e06f8e05065a84930e53 Mon Sep 17 00:00:00 2001 From: amit-kumar8-sf Date: Mon, 2 Feb 2026 21:44:44 +0530 Subject: [PATCH 15/15] @W-20893693: Add AM topic with users, roles and orgs subtopics --- .changeset/am-topic-users-roles-orgs.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/am-topic-users-roles-orgs.md diff --git a/.changeset/am-topic-users-roles-orgs.md b/.changeset/am-topic-users-roles-orgs.md new file mode 100644 index 00000000..70d23ee1 --- /dev/null +++ b/.changeset/am-topic-users-roles-orgs.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'@salesforce/b2c-tooling-sdk': patch +--- + +Account Manager (AM) topic with `users`, `roles`, and `orgs` subtopics. Use `b2c am users`, `b2c am roles`, and `b2c am orgs` for user, role, and organization management.