Skip to content

Commit ca9dcf0

Browse files
authored
Fix AM role mapping, user display, org resolution, and auth error guidance (#143)
* Fix AM role mapping, user display, org resolution, and auth error guidance - Replace hardcoded role ID maps with dynamic API-based role mapping (RoleMapping interface with byId, byEnumName, descriptions maps) - Fix grant/revoke operations to handle mixed role formats: role IDs in roles array, roleEnumNames in roleTenantFilter - Switch findUserByLogin to dedicated /users/search/findByLogin endpoint - Extract shared user display utility (user-display.ts) used by am users get, am roles grant, and am roles revoke - Show role descriptions with enum names: "Description (ENUM_NAME)" - Resolve org IDs to friendly names in user display: "Name (UUID)" - Add org name resolution utility (resolve-org.ts) so commands that accept org IDs also accept friendly org names - Rename --organizations to --orgs in am clients create - Detect "Access is denied" errors and suggest --user-auth for all AM command subtopics * Add changeset for AM role mapping and display fixes * Change changeset bump to patch * Print user details after am users create success * Fix null guard in user display for newly created users The API returns null (not undefined) for empty array fields on new users. Use truthy checks instead of === undefined.
1 parent 414db00 commit ca9dcf0

File tree

35 files changed

+793
-414
lines changed

35 files changed

+793
-414
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@salesforce/b2c-cli': patch
3+
'@salesforce/b2c-tooling-sdk': patch
4+
---
5+
6+
Fix AM role ID mapping between API internal/external formats and improve user display output. Role grant/revoke now correctly handle mixed formats (role IDs in roles array, enum names in roleTenantFilter). User display shows role descriptions, resolves org names, and detects auth errors with actionable --user-auth suggestions. Commands accepting org IDs now also accept friendly org names.

docs/cli/auth.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ b2c auth token
2222
|------|---------------------|-------------|
2323
| `--client-id` | `SFCC_CLIENT_ID` | Client ID for OAuth |
2424
| `--client-secret` | `SFCC_CLIENT_SECRET` | Client Secret for OAuth |
25-
| `--scope` | `SFCC_OAUTH_SCOPES` | OAuth scopes to request (can be repeated) |
25+
| `--auth-scope` | `SFCC_OAUTH_SCOPES` | OAuth scopes to request (can be repeated) |
2626
| `--account-manager-host` | `SFCC_ACCOUNT_MANAGER_HOST` | Account Manager hostname (default: account.demandware.com) |
2727

2828
### Examples
@@ -32,7 +32,7 @@ b2c auth token
3232
b2c auth token --client-id xxx --client-secret yyy
3333

3434
# Get a token with specific scopes
35-
b2c auth token --scope sfcc.orders --scope sfcc.products
35+
b2c auth token --auth-scope sfcc.orders --auth-scope sfcc.products
3636

3737
# Output as JSON (useful for parsing)
3838
b2c auth token --json

packages/b2c-cli/src/commands/am/clients/create.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {Flags} from '@oclif/core';
77
import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli';
88
import {isValidRoleTenantFilter, type AccountManagerApiClient, type APIClientCreate} from '@salesforce/b2c-tooling-sdk';
99
import {t} from '../../../i18n/index.js';
10+
import {resolveOrgId} from '../../../utils/am/resolve-org.js';
1011

1112
function splitCommaSeparated(s: string): string[] {
1213
return s
@@ -24,8 +25,8 @@ export default class ClientCreate extends AmCommand<typeof ClientCreate> {
2425
static enableJsonFlag = true;
2526

2627
static examples = [
27-
'<%= config.bin %> <%= command.id %> --name my-client --organizations org-id-1 --password "SecureP@ss123"',
28-
'<%= config.bin %> <%= command.id %> --name my-client --organizations org-id-1 --password "SecureP@ss123" --roles SALESFORCE_COMMERCE_API',
28+
'<%= config.bin %> <%= command.id %> --name my-client --orgs org-id-1 --password "SecureP@ss123"',
29+
'<%= config.bin %> <%= command.id %> --name my-client --orgs "My Organization" --password "SecureP@ss123" --roles SALESFORCE_COMMERCE_API',
2930
];
3031

3132
static flags = {
@@ -38,9 +39,9 @@ export default class ClientCreate extends AmCommand<typeof ClientCreate> {
3839
char: 'd',
3940
description: 'Description of the API client',
4041
}),
41-
organizations: Flags.string({
42+
orgs: Flags.string({
4243
char: 'o',
43-
description: 'Comma-separated organization IDs (required)',
44+
description: 'Comma-separated organization IDs or names',
4445
required: true,
4546
}),
4647
password: Flags.string({
@@ -84,9 +85,11 @@ export default class ClientCreate extends AmCommand<typeof ClientCreate> {
8485
async run(): Promise<AccountManagerApiClient> {
8586
const flags = this.flags;
8687
const nameTrimmed = flags.name.trim();
87-
const orgIds = splitCommaSeparated(flags.organizations);
88+
const orgInputs = splitCommaSeparated(flags.orgs);
8889

89-
this.validateCreateInput(flags, nameTrimmed, orgIds);
90+
this.validateCreateInput(flags, nameTrimmed, orgInputs);
91+
92+
const orgIds = await Promise.all(orgInputs.map((o) => resolveOrgId(this.accountManagerClient, o)));
9093

9194
const body = this.buildCreateBody(flags, nameTrimmed, orgIds);
9295

@@ -187,7 +190,7 @@ export default class ClientCreate extends AmCommand<typeof ClientCreate> {
187190
);
188191
}
189192
if (orgIds.length === 0) {
190-
this.error(t('commands.client.create.noOrgs', 'At least one organization ID is required'));
193+
this.error(t('commands.client.create.noOrgs', 'At least one organization is required'));
191194
}
192195
}
193196
}

packages/b2c-cli/src/commands/am/roles/grant.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {Args, Flags} from '@oclif/core';
77
import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli';
88
import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk';
99
import {t} from '../../../i18n/index.js';
10+
import {printUserDetails} from '../../../utils/am/user-display.js';
1011

1112
/**
1213
* Command to grant a role to an Account Manager user.
@@ -79,6 +80,12 @@ export default class RoleGrant extends AmCommand<typeof RoleGrant> {
7980

8081
this.log(message);
8182

83+
const [roleMapping, orgMapping] = await Promise.all([
84+
this.accountManagerClient.getRoleMapping(),
85+
this.accountManagerClient.getOrgMapping(),
86+
]);
87+
printUserDetails(updatedUser, roleMapping, orgMapping);
88+
8289
return updatedUser;
8390
}
8491
}

packages/b2c-cli/src/commands/am/roles/revoke.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {Args, Flags} from '@oclif/core';
77
import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli';
88
import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk';
99
import {t} from '../../../i18n/index.js';
10+
import {printUserDetails} from '../../../utils/am/user-display.js';
1011

1112
/**
1213
* Command to revoke a role from an Account Manager user.
@@ -79,6 +80,12 @@ export default class RoleRevoke extends AmCommand<typeof RoleRevoke> {
7980

8081
this.log(message);
8182

83+
const [roleMapping, orgMapping] = await Promise.all([
84+
this.accountManagerClient.getRoleMapping(),
85+
this.accountManagerClient.getOrgMapping(),
86+
]);
87+
printUserDetails(updatedUser, roleMapping, orgMapping);
88+
8289
return updatedUser;
8390
}
8491
}

packages/b2c-cli/src/commands/am/users/create.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {Flags} from '@oclif/core';
77
import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli';
88
import type {AccountManagerUser} from '@salesforce/b2c-tooling-sdk';
99
import {t} from '../../../i18n/index.js';
10+
import {resolveOrgId} from '../../../utils/am/resolve-org.js';
11+
import {printUserDetails} from '../../../utils/am/user-display.js';
1012

1113
/**
1214
* Command to create a new Account Manager user.
@@ -18,13 +20,13 @@ export default class UserCreate extends AmCommand<typeof UserCreate> {
1820

1921
static examples = [
2022
'<%= config.bin %> <%= command.id %> --org org-id --mail user@example.com --first-name John --last-name Doe',
21-
'<%= config.bin %> <%= command.id %> --org org-id --mail user@example.com --first-name John --last-name Doe --json',
23+
'<%= config.bin %> <%= command.id %> --org "My Organization" --mail user@example.com --first-name John --last-name Doe --json',
2224
];
2325

2426
static flags = {
2527
org: Flags.string({
2628
char: 'o',
27-
description: 'Organization ID to create the user in',
29+
description: 'Organization ID or name',
2830
required: true,
2931
}),
3032
mail: Flags.string({
@@ -43,26 +45,39 @@ export default class UserCreate extends AmCommand<typeof UserCreate> {
4345
};
4446

4547
async run(): Promise<AccountManagerUser> {
46-
const {org, mail, 'first-name': firstName, 'last-name': lastName} = this.flags;
48+
const {org: orgInput, mail, 'first-name': firstName, 'last-name': lastName} = this.flags;
4749

48-
this.log(t('commands.user.create.creating', 'Creating user {{mail}} in organization {{org}}...', {mail, org}));
50+
const orgId = await resolveOrgId(this.accountManagerClient, orgInput);
51+
52+
this.log(
53+
t('commands.user.create.creating', 'Creating user {{mail}} in organization {{org}}...', {mail, org: orgId}),
54+
);
4955

5056
const user = await this.accountManagerClient.createUser({
5157
mail,
5258
firstName,
5359
lastName,
54-
organizations: [org],
55-
primaryOrganization: org,
60+
organizations: [orgId],
61+
primaryOrganization: orgId,
5662
});
5763

5864
if (this.jsonEnabled()) {
5965
return user;
6066
}
6167

6268
this.log(
63-
t('commands.user.create.success', 'User {{mail}} created successfully in organization {{org}}.', {mail, org}),
69+
t('commands.user.create.success', 'User {{mail}} created successfully in organization {{org}}.', {
70+
mail,
71+
org: orgId,
72+
}),
6473
);
6574

75+
const [roleMapping, orgMapping] = await Promise.all([
76+
this.accountManagerClient.getRoleMapping(),
77+
this.accountManagerClient.getOrgMapping(),
78+
]);
79+
printUserDetails(user, roleMapping, orgMapping);
80+
6681
return user;
6782
}
6883
}

packages/b2c-cli/src/commands/am/users/get.ts

Lines changed: 7 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
* SPDX-License-Identifier: Apache-2
44
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
55
*/
6-
import {Args, Flags, ux} from '@oclif/core';
7-
import cliui from 'cliui';
6+
import {Args, Flags} from '@oclif/core';
87
import {AmCommand} from '@salesforce/b2c-tooling-sdk/cli';
98
import type {AccountManagerUser, UserExpandOption} from '@salesforce/b2c-tooling-sdk';
109
import {t} from '../../../i18n/index.js';
10+
import {printUserDetails} from '../../../utils/am/user-display.js';
1111

1212
/**
1313
* Valid expand values for the users API.
@@ -107,96 +107,12 @@ export default class UserGet extends AmCommand<typeof UserGet> {
107107
return user;
108108
}
109109

110-
this.printUserDetails(user);
110+
const [roleMapping, orgMapping] = await Promise.all([
111+
this.accountManagerClient.getRoleMapping(),
112+
this.accountManagerClient.getOrgMapping(),
113+
]);
114+
printUserDetails(user, roleMapping, orgMapping);
111115

112116
return user;
113117
}
114-
115-
private printBasicFields(ui: ReturnType<typeof cliui>, user: AccountManagerUser): void {
116-
const isPasswordExpired = user.passwordExpirationTimestamp
117-
? user.passwordExpirationTimestamp < Date.now()
118-
: undefined;
119-
const twoFAEnabled = user.verifiers && user.verifiers.length > 0 ? 'Yes' : 'No';
120-
121-
const fields: [string, string | undefined][] = [
122-
['ID', user.id],
123-
['Email', user.mail],
124-
['First Name', user.firstName],
125-
['Last Name', user.lastName],
126-
['Display Name', user.displayName],
127-
['State', user.userState],
128-
['Primary Organization', user.primaryOrganization],
129-
['Preferred Locale', user.preferredLocale || undefined],
130-
['Business Phone', user.businessPhone || undefined],
131-
['Home Phone', user.homePhone || undefined],
132-
['Mobile Phone', user.mobilePhone || undefined],
133-
['Linked to SF Identity', user.linkedToSfIdentity?.toString()],
134-
['2FA Enabled', twoFAEnabled],
135-
['Password Expired', isPasswordExpired === undefined ? undefined : isPasswordExpired ? 'Yes' : 'No'],
136-
['Last Login', user.lastLoginDate || undefined],
137-
['Created At', user.createdAt ? new Date(user.createdAt).toLocaleString() : undefined],
138-
['Last Modified', user.lastModified ? new Date(user.lastModified).toLocaleString() : undefined],
139-
];
140-
141-
for (const [label, value] of fields) {
142-
if (value !== undefined) {
143-
ui.div({text: `${label}:`, width: 25, padding: [0, 2, 0, 0]}, {text: value, padding: [0, 0, 0, 0]});
144-
}
145-
}
146-
}
147-
148-
private printOrganizations(ui: ReturnType<typeof cliui>, user: AccountManagerUser): void {
149-
if (user.organizations === undefined || user.organizations.length === 0) {
150-
return;
151-
}
152-
153-
ui.div({text: 'Organizations', padding: [2, 0, 0, 0]});
154-
ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]});
155-
156-
const orgIds = user.organizations.map((o) => (typeof o === 'string' ? o : o.id || 'Unknown'));
157-
ui.div(
158-
{text: 'Organization IDs:', width: 25, padding: [0, 2, 0, 0]},
159-
{text: orgIds.join(', '), padding: [0, 0, 0, 0]},
160-
);
161-
}
162-
163-
private printRoles(ui: ReturnType<typeof cliui>, user: AccountManagerUser): void {
164-
if (user.roles === undefined || user.roles.length === 0) {
165-
return;
166-
}
167-
168-
ui.div({text: 'Roles', padding: [2, 0, 0, 0]});
169-
ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]});
170-
171-
const roleNames = user.roles.map((r) => (typeof r === 'string' ? r : r.roleEnumName || r.id || 'Unknown'));
172-
ui.div({text: 'Role IDs:', width: 25, padding: [0, 2, 0, 0]}, {text: roleNames.join(', '), padding: [0, 0, 0, 0]});
173-
}
174-
175-
private printRoleTenantFilters(ui: ReturnType<typeof cliui>, user: AccountManagerUser): void {
176-
if (user.roleTenantFilterMap === undefined || Object.keys(user.roleTenantFilterMap).length === 0) {
177-
return;
178-
}
179-
180-
ui.div({text: 'Role Tenant Filters', padding: [2, 0, 0, 0]});
181-
ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]});
182-
183-
for (const [roleId, filter] of Object.entries(user.roleTenantFilterMap)) {
184-
const filterValue = typeof filter === 'string' ? filter : JSON.stringify(filter);
185-
ui.div({text: `${roleId}:`, width: 30, padding: [0, 2, 0, 0]}, {text: filterValue, padding: [0, 0, 0, 0]});
186-
}
187-
}
188-
189-
private printUserDetails(user: AccountManagerUser): void {
190-
const ui = cliui({width: process.stdout.columns || 80});
191-
192-
ui.div({text: 'User Details', padding: [1, 0, 0, 0]});
193-
ui.div({text: '─'.repeat(50), padding: [0, 0, 0, 0]});
194-
195-
this.printBasicFields(ui, user);
196-
this.printOrganizations(ui, user);
197-
this.printRoles(ui, user);
198-
this.printRoleTenantFilters(ui, user);
199-
200-
ux.stdout(ui.toString());
201-
}
202118
}

packages/b2c-cli/src/commands/auth/token.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default class AuthToken extends OAuthCommand<typeof AuthToken> {
2727

2828
static examples = [
2929
'<%= config.bin %> <%= command.id %>',
30-
'<%= config.bin %> <%= command.id %> --scope sfcc.orders --scope sfcc.products',
30+
'<%= config.bin %> <%= command.id %> --auth-scope sfcc.orders --auth-scope sfcc.products',
3131
'<%= config.bin %> <%= command.id %> --json',
3232
];
3333

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* SPDX-License-Identifier: Apache-2
4+
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
5+
*/
6+
import type {AccountManagerOrganization} from '@salesforce/b2c-tooling-sdk';
7+
8+
interface OrgResolver {
9+
getOrg(orgId: string): Promise<AccountManagerOrganization>;
10+
getOrgByName(name: string): Promise<AccountManagerOrganization>;
11+
}
12+
13+
/**
14+
* Resolves an organization identifier (ID or friendly name) to an org ID.
15+
* Tries by ID first, then falls back to name lookup.
16+
*/
17+
export async function resolveOrgId(client: OrgResolver, orgIdOrName: string): Promise<string> {
18+
try {
19+
const org = await client.getOrg(orgIdOrName);
20+
return org.id!;
21+
} catch {
22+
const org = await client.getOrgByName(orgIdOrName);
23+
return org.id!;
24+
}
25+
}

0 commit comments

Comments
 (0)