@salesforce/templates 66.13.9 → 66.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/lib/generators/analyticsTemplateGenerator.js +1 -1
  2. package/lib/generators/analyticsTemplateGenerator.js.map +1 -1
  3. package/lib/generators/apexClassGenerator.js +1 -1
  4. package/lib/generators/apexClassGenerator.js.map +1 -1
  5. package/lib/generators/apexTriggerGenerator.js +1 -1
  6. package/lib/generators/apexTriggerGenerator.js.map +1 -1
  7. package/lib/generators/baseGenerator.js +3 -3
  8. package/lib/generators/baseGenerator.js.map +1 -1
  9. package/lib/generators/digitalExperienceSiteGenerator.js +2 -2
  10. package/lib/generators/digitalExperienceSiteGenerator.js.map +1 -1
  11. package/lib/generators/flexipageGenerator.js +2 -2
  12. package/lib/generators/flexipageGenerator.js.map +1 -1
  13. package/lib/generators/lightningAppGenerator.js +1 -1
  14. package/lib/generators/lightningAppGenerator.js.map +1 -1
  15. package/lib/generators/lightningComponentGenerator.js +1 -1
  16. package/lib/generators/lightningComponentGenerator.js.map +1 -1
  17. package/lib/generators/lightningEventGenerator.js +1 -1
  18. package/lib/generators/lightningEventGenerator.js.map +1 -1
  19. package/lib/generators/lightningInterfaceGenerator.js +1 -1
  20. package/lib/generators/lightningInterfaceGenerator.js.map +1 -1
  21. package/lib/generators/lightningTestGenerator.js +1 -1
  22. package/lib/generators/lightningTestGenerator.js.map +1 -1
  23. package/lib/generators/projectGenerator.js +1 -1
  24. package/lib/generators/projectGenerator.js.map +1 -1
  25. package/lib/generators/staticResourceGenerator.js +1 -1
  26. package/lib/generators/staticResourceGenerator.js.map +1 -1
  27. package/lib/generators/uiBundleGenerator.js +1 -1
  28. package/lib/generators/uiBundleGenerator.js.map +1 -1
  29. package/lib/generators/uiEmbeddingGenerator.js +1 -1
  30. package/lib/generators/uiEmbeddingGenerator.js.map +1 -1
  31. package/lib/generators/visualforceComponentGenerator.js +1 -1
  32. package/lib/generators/visualforceComponentGenerator.js.map +1 -1
  33. package/lib/generators/visualforcePageGenerator.js +1 -1
  34. package/lib/generators/visualforcePageGenerator.js.map +1 -1
  35. package/lib/i18n/localization.js +2 -1
  36. package/lib/i18n/localization.js.map +1 -1
  37. package/lib/service/gitRepoUtils.js +5 -5
  38. package/lib/service/gitRepoUtils.js.map +1 -1
  39. package/lib/templates/project/angularextapp/CHANGELOG.md +429 -0
  40. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/package-lock.json +3001 -8132
  41. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/package.json +3 -3
  42. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/components/ui/icon/icon.ts +6 -0
  43. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/authentication/auth/auth.service.ts +53 -21
  44. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/language-switcher/__examples__/html-middleware-site-example.ts +41 -0
  45. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/language-switcher/__examples__/language-switcher-example.ts +41 -0
  46. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/language-switcher/build-language-url.ts +158 -0
  47. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/language-switcher/index.ts +28 -0
  48. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/language-switcher/language-switcher.html +22 -0
  49. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/language-switcher/language-switcher.ts +110 -0
  50. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/app/features/language-switcher/languages.ts +46 -0
  51. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/src/types/sf-globals.d.ts +14 -0
  52. package/lib/templates/project/angularextapp/package.json +1 -1
  53. package/lib/templates/project/angularextapp/scripts/org-setup-config-schema.mjs +14 -2
  54. package/lib/templates/project/angularextapp/scripts/org-setup-dev.mjs +27 -0
  55. package/lib/templates/project/angularextapp/scripts/org-setup-url.mjs +108 -0
  56. package/lib/templates/project/angularextapp/scripts/org-setup-xml.mjs +96 -0
  57. package/lib/templates/project/angularextapp/scripts/org-setup.mjs +195 -3
  58. package/lib/templates/project/angularintapp/CHANGELOG.md +429 -0
  59. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/package-lock.json +3008 -8139
  60. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/package.json +4 -4
  61. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/components/ui/icon/icon.ts +6 -0
  62. package/lib/templates/project/angularintapp/package.json +1 -1
  63. package/lib/templates/project/angularintapp/scripts/org-setup-config-schema.mjs +14 -2
  64. package/lib/templates/project/angularintapp/scripts/org-setup-dev.mjs +27 -0
  65. package/lib/templates/project/angularintapp/scripts/org-setup-url.mjs +108 -0
  66. package/lib/templates/project/angularintapp/scripts/org-setup-xml.mjs +96 -0
  67. package/lib/templates/project/angularintapp/scripts/org-setup.mjs +195 -3
  68. package/lib/templates/project/reactexternalapp/CHANGELOG.md +96 -0
  69. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/package-lock.json +359 -358
  70. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/package.json +4 -4
  71. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/DefaultFilterPanel.tsx +7 -1
  72. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/utils/filterUtils.ts +12 -3
  73. package/lib/templates/project/reactexternalapp/package.json +1 -1
  74. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/package-lock.json +361 -360
  75. package/lib/templates/uiBundles/angularbasic/package-lock.json +3001 -8132
  76. package/lib/templates/uiBundles/angularbasic/package.json +3 -3
  77. package/lib/templates/uiBundles/angularbasic/src/app/components/ui/icon/icon.ts +6 -0
  78. package/lib/templates/uiBundles/reactbasic/package-lock.json +355 -354
  79. package/lib/utils/constants.js +2 -1
  80. package/lib/utils/constants.js.map +1 -1
  81. package/lib/utils/createUtil.js +3 -2
  82. package/lib/utils/createUtil.js.map +1 -1
  83. package/lib/utils/types.js +17 -16
  84. package/lib/utils/types.js.map +1 -1
  85. package/lib/utils/uiBundleTemplateUtils.js +4 -4
  86. package/lib/utils/uiBundleTemplateUtils.js.map +1 -1
  87. package/package.json +20 -20
  88. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/README.md +0 -246
  89. package/lib/tsconfig.tsbuildinfo +0 -1
@@ -28,15 +28,15 @@
28
28
  "@angular/router": "^21.2.0",
29
29
  "@ng-icons/core": "^32.2.0",
30
30
  "@ng-icons/lucide": "^32.2.0",
31
- "@salesforce/angular-plugin-ui-bundle": "^11.64.0",
32
- "@salesforce/platform-sdk": "^11.64.0",
31
+ "@salesforce/angular-plugin-ui-bundle": "^12.4.4",
32
+ "@salesforce/platform-sdk": "^12.4.4",
33
33
  "@spartan-ng/brain": "^1.3.1",
34
34
  "class-variance-authority": "^0.7.1",
35
35
  "clsx": "^2.1.1",
36
36
  "rxjs": "~7.8.0",
37
37
  "tailwind-merge": "^3.5.0",
38
38
  "tslib": "^2.3.0",
39
- "@salesforce/agentforce-conversation-client": "^11.57.1"
39
+ "@salesforce/agentforce-conversation-client": "^12.0.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@angular-builders/custom-esbuild": "^21.0.0",
@@ -62,7 +62,7 @@
62
62
  "typescript": "~5.9.2",
63
63
  "typescript-eslint": "^8.0.0",
64
64
  "vitest": "^4.1.0",
65
- "@salesforce/graphiti": "^11.64.0"
65
+ "@salesforce/graphiti": "^12.4.4"
66
66
  },
67
67
  "overrides": {
68
68
  "undici": "^7.28.0"
@@ -8,9 +8,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
8
8
  import {
9
9
  lucideArrowDown,
10
10
  lucideArrowUp,
11
+ lucideCheck,
12
+ lucideChevronDown,
11
13
  lucideChevronRight,
12
14
  lucideCircleAlert,
13
15
  lucideFileQuestion,
16
+ lucideGlobe,
14
17
  lucideListFilter,
15
18
  lucideLogIn,
16
19
  lucideLogOut,
@@ -33,10 +36,13 @@ export const APP_ICONS = {
33
36
  search: lucideSearch,
34
37
  filter: lucideListFilter,
35
38
  chevronRight: lucideChevronRight,
39
+ chevronDown: lucideChevronDown,
36
40
  arrowUp: lucideArrowUp,
37
41
  arrowDown: lucideArrowDown,
38
42
  noResults: lucideSearchX,
39
43
  notFound: lucideFileQuestion,
44
+ globe: lucideGlobe,
45
+ check: lucideCheck,
40
46
  } as const;
41
47
 
42
48
  export type AppIconName = keyof typeof APP_ICONS;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.64.0",
3
+ "version": "12.4.4",
4
4
  "description": "Base SFDX project template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "publishConfig": {
@@ -72,13 +72,25 @@ export const ConfigSchema = z
72
72
  // Required because React (Site Container) sites hide SSO config in Admin UI.
73
73
  authProviderNames: z.array(z.string().min(1)).min(1),
74
74
  // Permission set to assign to community users created via SSO.
75
- // Required because getCurrentUser() calls /chatter/users/me which needs
76
- // API access (PermissionsApiEnabled) that the community profile lacks.
75
+ // Required because getCurrentUser() calls UI API GraphQL (uiapi.currentUser)
76
+ // which needs API access (PermissionsApiEnabled) that the community profile lacks.
77
77
  // Typically the app's "*_Guest_User_Api_Access" permset.
78
78
  communityUserPermset: z.string().min(1).optional(),
79
79
  })
80
80
  .strict()
81
81
  .optional(),
82
+ // URL members are redirected to after logging out, written to the site's
83
+ // <logoutUrl> network metadata (a post-deploy step). Steers logout back to THIS
84
+ // site so members aren't dropped on the org default-site login (which, in an
85
+ // org hosting multiple sites, can be a different community). Prefer a
86
+ // site-relative path (e.g. "/myapp/"): it is domain-independent, so the same
87
+ // shipped value is valid on every org. The platform REQUIRES an absolute logout
88
+ // URL, so org-setup resolves a relative path against the site's Experience Cloud
89
+ // origin (discovered from the org at deploy time) before writing it; an
90
+ // already-absolute value is used as-is. Kept a permissive non-empty string (not
91
+ // z.string().url()) so relative paths are valid; character-safety for raw XML
92
+ // interpolation is enforced at write time.
93
+ logoutUrl: z.string().min(1).optional(),
82
94
  })
83
95
  .strict();
84
96
 
@@ -60,6 +60,31 @@ Options:
60
60
  return { targetOrg, uiBundleName };
61
61
  }
62
62
 
63
+ /**
64
+ * Print a heads-up about auth surfaces that only work on the deployed Experience
65
+ * site. In local preview, `sdk.fetch` is proxied to the org outside the site's
66
+ * guest/community context, so social login and password/MFA login cannot be
67
+ * exercised on localhost. This mirrors the "Local preview limitations" sections
68
+ * in the feature-react-authentication / feature-react-mfa READMEs — surfaced here
69
+ * so it is seen even when the READMEs are not.
70
+ */
71
+ function printLocalPreviewWarning() {
72
+ console.log(
73
+ [
74
+ '',
75
+ '⚠️ Local preview limitation — some auth surfaces only work on the DEPLOYED site',
76
+ ' Local preview proxies sdk.fetch to the org outside the Experience site guest',
77
+ ' context (Site.getBaseUrl() is blank, no site session), so on localhost:',
78
+ ' • Social Login buttons do not render (/auth/social-login-config returns no providers)',
79
+ ' • Password / MFA login fails with "Your login attempt has failed…" (Site.login() cannot',
80
+ ' establish the site session)',
81
+ ' This is expected, not a misconfiguration. Test social login and MFA on the published',
82
+ ' site login page: https://<domain>.site.com/<site-path>/login',
83
+ '',
84
+ ].join('\n'),
85
+ );
86
+ }
87
+
63
88
  async function main() {
64
89
  const parsed = parseArgs();
65
90
 
@@ -76,6 +101,8 @@ async function main() {
76
101
  });
77
102
  run('GraphQL codegen', 'npm', ['run', 'graphql:codegen'], { cwd: uiBundleDir });
78
103
 
104
+ printLocalPreviewWarning();
105
+
79
106
  console.log('\n--- Launching dev server (Ctrl+C to stop) ---\n');
80
107
  const devResult = run('Dev server', 'npm', ['run', 'dev'], { cwd: uiBundleDir, optional: true });
81
108
  process.exit(devResult.status ?? 0);
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Pure URL helpers for org-setup.mjs logout-URL productization.
3
+ *
4
+ * The platform requires an ABSOLUTE logout URL on Network metadata — a relative
5
+ * value is rejected at deploy time ("The logout page URL must be an absolute
6
+ * URL."). Apps ship a domain-INDEPENDENT, site-relative path in
7
+ * org-setup.config.json (so the same value is valid on every org the app deploys
8
+ * to); these helpers resolve it to an absolute URL at deploy time, against the
9
+ * site's Experience Cloud origin discovered from the target org's communities.
10
+ *
11
+ * Pure (no fs / no process / no network): org-setup.mjs fetches the community list
12
+ * (Connect API) and owns the fail modes; these helpers only classify + resolve
13
+ * strings, so they carry automated coverage in the consuming test package
14
+ * (org-setup-tests/test/org-setup-url.spec.ts). Companion to org-setup-xml.mjs.
15
+ */
16
+
17
+ /** True when `value` is an absolute http(s) URL — already deployable as-is. */
18
+ export function isAbsoluteLogoutUrl(value) {
19
+ return /^https?:\/\//i.test(String(value).trim());
20
+ }
21
+
22
+ /**
23
+ * Leading path segment of a URL or path:
24
+ * "/propertyrentalapp/" -> "propertyrentalapp"
25
+ * "https://h.site.com/propertyrentalapp" -> "propertyrentalapp"
26
+ * "/" -> ""
27
+ */
28
+ export function firstPathSegment(pathOrUrl) {
29
+ let pathname;
30
+ try {
31
+ pathname = new URL(pathOrUrl).pathname; // absolute URL
32
+ } catch {
33
+ pathname = String(pathOrUrl).split(/[?#]/)[0]; // relative path
34
+ }
35
+ return pathname.replace(/^\/+/, '').split('/')[0];
36
+ }
37
+
38
+ /**
39
+ * Choose the Experience Cloud community whose site URL should anchor a relative
40
+ * logout path, and return its siteUrl (or null when none matches).
41
+ *
42
+ * Matches on the community's siteUrl PATH first — robust even when a community's
43
+ * `urlPathPrefix` differs from its public path (e.g. a React "site container" site
44
+ * whose companion ChatterNetwork carries a "…vforcesite" prefix), and correct on
45
+ * custom domains where each site can have its own origin. Falls back to a
46
+ * community whose `name` equals the derived site name. Does NOT fall back to an
47
+ * arbitrary community: resolving against the wrong origin would silently produce a
48
+ * valid-but-wrong absolute URL, so an unmatched relative path is left for the
49
+ * caller to surface loudly.
50
+ *
51
+ * @param {Array<{name?: string, siteUrl?: string}>} communities
52
+ * @param {string} configLogoutUrl the site-relative path being resolved
53
+ * @param {string} [siteName]
54
+ * @returns {string|null} the matched community siteUrl
55
+ */
56
+ export function pickCommunityBaseUrl(communities, configLogoutUrl, siteName) {
57
+ const list = Array.isArray(communities) ? communities.filter((c) => c && c.siteUrl) : [];
58
+ const seg = firstPathSegment(configLogoutUrl);
59
+
60
+ if (seg) {
61
+ const byPath = list.find((c) => firstPathSegment(c.siteUrl) === seg);
62
+ if (byPath) return byPath.siteUrl;
63
+ }
64
+ if (siteName) {
65
+ const byName = list.find((c) => c.name === siteName);
66
+ if (byName) return byName.siteUrl;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Resolve a shipped logout-URL config value to an absolute URL.
73
+ * - already absolute -> returned unchanged (trimmed).
74
+ * - site-relative path -> resolved against `baseUrl` (a community siteUrl).
75
+ * Because the config path is root-relative ("/app/"), only baseUrl's ORIGIN is
76
+ * used, so any siteUrl on the same Experience Cloud domain yields the same
77
+ * result.
78
+ *
79
+ * Throws when a relative value has no baseUrl to resolve against, or when
80
+ * resolution fails to produce an absolute http(s) URL.
81
+ *
82
+ * @param {string} configLogoutUrl
83
+ * @param {string|null} baseUrl
84
+ * @returns {string} an absolute URL
85
+ */
86
+ export function resolveLogoutUrl(configLogoutUrl, baseUrl) {
87
+ const value = String(configLogoutUrl).trim();
88
+ if (isAbsoluteLogoutUrl(value)) return value;
89
+
90
+ if (!baseUrl) {
91
+ throw new Error(
92
+ `logout URL "${configLogoutUrl}" is site-relative but no Experience Cloud ` +
93
+ `community site URL was found to resolve it into an absolute URL`,
94
+ );
95
+ }
96
+ let resolved;
97
+ try {
98
+ resolved = new URL(value, baseUrl).href;
99
+ } catch (e) {
100
+ throw new Error(
101
+ `could not resolve logout URL "${configLogoutUrl}" against "${baseUrl}": ${e.message}`,
102
+ );
103
+ }
104
+ if (!isAbsoluteLogoutUrl(resolved)) {
105
+ throw new Error(`resolved logout URL "${resolved}" is not an absolute http(s) URL`);
106
+ }
107
+ return resolved;
108
+ }
@@ -180,3 +180,99 @@ export function enableSelfRegInXml(xml, selfRegProfile) {
180
180
  );
181
181
  return { xml: updated, changed: true };
182
182
  }
183
+
184
+ /**
185
+ * Indentation of the first indented child element, so an inserted node matches
186
+ * the file's own formatting — byte-faithful on 2-space and 4-space files alike.
187
+ * Falls back to 4 spaces when the document has no indented child to sample.
188
+ */
189
+ function firstChildIndent(xml) {
190
+ const m = xml.match(/\n([ \t]+)</);
191
+ return m ? m[1] : ' ';
192
+ }
193
+
194
+ /**
195
+ * Assert a logout URL is safe to interpolate into the network metadata as-is.
196
+ *
197
+ * Mirrors assertSafeProfileName: the value is developer-authored config, so a
198
+ * character that would need XML entity-escaping (& < > " ') signals a mistake to
199
+ * surface loudly rather than silently rewrite. A logout landing URL is a site
200
+ * home path/URL and legitimately needs none of these. Throws NetworkXmlError
201
+ * (mapped by callers to their fail mode) otherwise.
202
+ */
203
+ export function assertSafeLogoutUrl(url) {
204
+ const s = String(url);
205
+ if (XML_SPECIAL_CHARS.test(s)) {
206
+ throw new NetworkXmlError(
207
+ `logoutUrl "${url}" contains an XML-special character (& < > " '); ` +
208
+ `use a URL without these characters in org-setup.config.json`,
209
+ );
210
+ }
211
+ return s;
212
+ }
213
+
214
+ /**
215
+ * Set <logoutUrl> so members land on THIS site after logging out, instead of the
216
+ * org's default-site logout — which, in an org hosting multiple Experience Cloud
217
+ * sites, can drop a member on a different community's login page.
218
+ *
219
+ * `url` MUST already be an absolute URL: the platform rejects a relative value at
220
+ * deploy time ("The logout page URL must be an absolute URL."). A shipped
221
+ * site-relative config path is resolved to an absolute URL before this is called
222
+ * (see org-setup-url.mjs, driven by ensureLogoutUrl in org-setup.mjs).
223
+ *
224
+ * Asserts this is a Network document (throws NetworkXmlError otherwise). Returns
225
+ * `{ xml, changed }`:
226
+ * - `changed: false` (input returned unchanged) when <logoutUrl> already equals
227
+ * `url` — a configured re-run is a true byte-for-byte no-op.
228
+ * - present-but-different: the value is replaced in place (position preserved).
229
+ * - absent: a <logoutUrl> node is inserted in the CANONICAL position the Metadata
230
+ * API emits. Network's top-level elements serialize in alphabetical order, so
231
+ * the node is placed immediately before the first existing sibling that sorts
232
+ * after "logoutUrl" (or as the last child, before </Network>, when none does).
233
+ * Inserting where a later retrieve would place it keeps this org-deployed file
234
+ * free of reorder churn. The node adopts the file's own indentation and every
235
+ * untouched region is preserved byte-for-byte.
236
+ *
237
+ * Assumes an existing <logoutUrl> is a paired, non-empty node (the shipped sparse
238
+ * network omits it entirely; a deployed org carries a paired value) — the case
239
+ * this productizes.
240
+ */
241
+ export function setLogoutUrl(xml, url) {
242
+ const network = parseNetwork(xml);
243
+ // Reject XML-special characters rather than escaping them (see
244
+ // assertSafeLogoutUrl) before interpolating raw.
245
+ const safeUrl = assertSafeLogoutUrl(url);
246
+ const current = 'logoutUrl' in network ? String(network.logoutUrl) : null;
247
+ if (current === safeUrl) {
248
+ return { xml, changed: false };
249
+ }
250
+ const node = `<logoutUrl>${safeUrl}</logoutUrl>`;
251
+ // Present-but-different — replace the value in place (preserves position).
252
+ if (/<logoutUrl>[^<]*<\/logoutUrl>/.test(xml)) {
253
+ return { xml: xml.replace(/<logoutUrl>[^<]*<\/logoutUrl>/, node), changed: true };
254
+ }
255
+ // Absent — insert in alphabetical order among the Network's top-level children to
256
+ // match the canonical Metadata API serialization. parseNetwork's keys are the
257
+ // direct child element names; drop fast-xml-parser's attribute (@_*) and text
258
+ // (#*) pseudo-keys.
259
+ const siblings = Object.keys(network).filter((k) => !k.startsWith('@_') && !k.startsWith('#'));
260
+ // The first sibling that sorts AFTER "logoutUrl" — the node goes just before it.
261
+ const successor = siblings.filter((name) => name > 'logoutUrl').sort()[0];
262
+ if (successor) {
263
+ const beforeSuccessor = new RegExp(`(\\n[ \\t]*)(<${successor}\\b)`);
264
+ if (beforeSuccessor.test(xml)) {
265
+ // Reuse the successor's own leading whitespace so the node adopts its indent.
266
+ return { xml: xml.replace(beforeSuccessor, `$1${node}$1$2`), changed: true };
267
+ }
268
+ }
269
+ // No later-sorting sibling (or its tag wasn't found) — insert as the last child,
270
+ // right before </Network>, at the file's own indent.
271
+ const indent = firstChildIndent(xml);
272
+ const beforeClose = /(\n)([ \t]*<\/Network>)/;
273
+ if (beforeClose.test(xml)) {
274
+ return { xml: xml.replace(beforeClose, `\n${indent}${node}$1$2`), changed: true };
275
+ }
276
+ // Degenerate single-line / rootless form — fall back to just after the open tag.
277
+ return { xml: xml.replace(/(<Network\b[^>]*>)/, `$1\n${indent}${node}`), changed: true };
278
+ }
@@ -58,8 +58,14 @@ import { validateConfig } from './org-setup-config-schema.mjs';
58
58
  import {
59
59
  addProfileToMemberGroups,
60
60
  enableSelfRegInXml,
61
+ setLogoutUrl,
61
62
  NetworkXmlError,
62
63
  } from './org-setup-xml.mjs';
64
+ import {
65
+ isAbsoluteLogoutUrl,
66
+ pickCommunityBaseUrl,
67
+ resolveLogoutUrl,
68
+ } from './org-setup-url.mjs';
63
69
  import {
64
70
  discoverAllUIBundleDirs as discoverAllUIBundleDirsIn,
65
71
  discoverUIBundleDir as discoverUIBundleDirIn,
@@ -540,7 +546,7 @@ function loadSelfRegConfig(config) {
540
546
  * - communityMemberProfile: profile added to NetworkMemberGroup (required for access)
541
547
  * - authProviderNames: DeveloperNames of AuthProvider (OAuth) or SamlSsoConfig (SAML) records to link to site
542
548
  * - communityUserPermset: permset assigned to community users so getCurrentUser()
543
- * works (requires PermissionsApiEnabled for /chatter/users/me via /sf/api/)
549
+ * works (requires PermissionsApiEnabled for UI API GraphQL via /sf/api/)
544
550
  *
545
551
  * Returns null if no "socialLogin" section exists in config (the step is hidden).
546
552
  */
@@ -554,6 +560,15 @@ function loadSocialLoginConfig(config) {
554
560
  };
555
561
  }
556
562
 
563
+ /**
564
+ * Logout URL config, read from the already-validated config. Returns the string
565
+ * (a site-relative path like "/myapp/", or an absolute URL) or null when no
566
+ * "logoutUrl" is set (the step is then a no-op).
567
+ */
568
+ function loadLogoutUrlConfig(config) {
569
+ return config.logoutUrl ?? null;
570
+ }
571
+
557
572
  /**
558
573
  * Enable the "Allow using standard external profiles for self-registration,
559
574
  * user creation, and login" org setting via Metadata API deploy.
@@ -706,6 +721,32 @@ if (allProviders.isEmpty()) {
706
721
  }
707
722
  System.debug('TOTAL_PROVIDERS_FOUND:' + allProviders.size());
708
723
 
724
+ // Fail fast if any requested provider name did not resolve to an AuthProvider
725
+ // (OAuth) or SamlSsoConfig (SAML) record. The isEmpty() check above only catches
726
+ // the case where NONE resolve; comparing the found DeveloperNames against the
727
+ // requested list also catches the strict-subset case (e.g. ["Google","Typo"]
728
+ // links Google but must not silently drop "Typo"). This runs BEFORE any junction
729
+ // record is created, so linking is all-or-nothing. Case-insensitive because SOQL
730
+ // "DeveloperName IN (...)" matches case-insensitively, so a stored DeveloperName
731
+ // may differ in case from the configured value and must not read as "missing".
732
+ Set<String> foundProviderNames = new Set<String>();
733
+ for (AuthProvider ap : oauthProviders) {
734
+ foundProviderNames.add(ap.DeveloperName.toLowerCase());
735
+ }
736
+ for (SamlSsoConfig sp : samlProviders) {
737
+ foundProviderNames.add(sp.DeveloperName.toLowerCase());
738
+ }
739
+ List<String> missingProviderNames = new List<String>();
740
+ for (String requestedName : new List<String>{${providerNamesLiteral}}) {
741
+ if (!foundProviderNames.contains(requestedName.toLowerCase())) {
742
+ missingProviderNames.add(requestedName);
743
+ }
744
+ }
745
+ if (!missingProviderNames.isEmpty()) {
746
+ System.debug('MISSING_PROVIDERS:' + String.join(missingProviderNames, ','));
747
+ return;
748
+ }
749
+
709
750
  // Step 4: Check existing AuthConfigProviders to avoid duplicates
710
751
  Set<Id> existingProviderIds = new Set<Id>();
711
752
  for (AuthConfigProviders acp : [
@@ -768,6 +809,18 @@ if (inserted == 0 && existingProviderIds.size() >= allProviders.size()) {
768
809
  if (apexOut.match(/\|DEBUG\|ERROR_NO_PROVIDERS/)) {
769
810
  throw new StepError(`no AuthProvider or SamlSsoConfig records found for names: ${authProviderNames.join(', ')} — create them in Setup first`);
770
811
  }
812
+ // A SOQL `DeveloperName IN (…)` query silently returns fewer rows for names
813
+ // that don't exist, so the ERROR_NO_PROVIDERS guard above (which only fires
814
+ // when NONE resolve) let a single typo'd name slip through unnoticed whenever
815
+ // at least one other name matched. The Apex now diffs the requested names
816
+ // against the resolved ones (case-insensitively, since SOQL matches that way)
817
+ // and emits MISSING_PROVIDERS naming the offenders — nothing is linked when it
818
+ // fires (all-or-nothing, checked before any insert).
819
+ const missingMatch = apexOut.match(/\|DEBUG\|MISSING_PROVIDERS:([^\n]+)/);
820
+ if (missingMatch) {
821
+ const missing = missingMatch[1].trim();
822
+ throw new StepError(`some configured auth providers were not found: ${missing} — check for typos, or create them in Setup first (nothing was linked)`);
823
+ }
771
824
 
772
825
  // Check for insert failures
773
826
  const insertFails = [...apexOut.matchAll(/\|DEBUG\|INSERT_FAILED:([^:]+):(OAuth|SAML):(\d+):([^\n]+)/g)];
@@ -884,7 +937,7 @@ function addCommunityMemberProfile(profileName, siteName, targetOrg) {
884
937
  * Assign a permission set to all existing community users with a given profile.
885
938
  *
886
939
  * After SSO login, community users need the app's API-enabled permset so that
887
- * getCurrentUser() (which calls /chatter/users/me via the /sf/api/ proxy) works.
940
+ * getCurrentUser() (which calls UI API GraphQL via the /sf/api/ proxy) works.
888
941
  * Without PermissionsApiEnabled the proxy returns API_DISABLED_FOR_ORG and the
889
942
  * React app treats the user as unauthenticated.
890
943
  *
@@ -998,6 +1051,123 @@ function ensureNetworkMemberProfile(selfRegConfig, siteName) {
998
1051
  console.log(` Added profile "${selfRegProfile}" to networkMemberGroups in ${siteName}.network-meta.xml`);
999
1052
  }
1000
1053
 
1054
+ const CONNECT_API_VERSION = '62.0';
1055
+
1056
+ /**
1057
+ * Fetch the org's Experience Cloud communities via the Connect API and return the
1058
+ * `communities` array (possibly empty). Used to discover a site's public origin so
1059
+ * a shipped, site-relative logout path can be resolved to the absolute URL the
1060
+ * platform requires. Throws on transport/parse failure so the caller degrades to a
1061
+ * loud skip. (v62.0 is a safe floor — the /connect/communities resource is stable
1062
+ * across API versions.)
1063
+ */
1064
+ function fetchCommunities(targetOrg) {
1065
+ const res = spawnSync('sf', [
1066
+ 'api', 'request', 'rest',
1067
+ `/services/data/v${CONNECT_API_VERSION}/connect/communities`,
1068
+ '--target-org', targetOrg,
1069
+ ], { cwd: ROOT, encoding: 'utf8', timeout: 60000 });
1070
+ if (res.status !== 0) {
1071
+ throw new Error(`Connect communities query failed (sf exit ${res.status ?? 1})`);
1072
+ }
1073
+ let json;
1074
+ try {
1075
+ json = JSON.parse(res.stdout);
1076
+ } catch {
1077
+ throw new Error('could not parse the Connect communities response as JSON');
1078
+ }
1079
+ return Array.isArray(json.communities) ? json.communities : [];
1080
+ }
1081
+
1082
+ /**
1083
+ * Set the site's <logoutUrl> in the network metadata AFTER the initial deploy, then
1084
+ * re-deploy just the network — mirroring the self-registration step.
1085
+ *
1086
+ * Post-deploy (not folded into the main deploy) because the value must be an
1087
+ * ABSOLUTE URL: the platform rejects a relative logout URL at deploy ("The logout
1088
+ * page URL must be an absolute URL."). Apps ship a domain-independent, site-relative
1089
+ * path in org-setup.config.json; it is resolved here against the site's Experience
1090
+ * Cloud origin, which is only discoverable (via the Connect communities API) once
1091
+ * the site exists — i.e. after the main deploy. An already-absolute config value is
1092
+ * used as-is (no lookup).
1093
+ *
1094
+ * Best-effort, mirroring ensureNetworkMemberProfile: a missing network file, a
1095
+ * failure to resolve the absolute URL, a malformed-XML NetworkXmlError, or a failed
1096
+ * network re-deploy each log a LOUD console.error but do NOT throw — the logout URL
1097
+ * is a convenience that must not abort the whole setup. In particular, if the org's
1098
+ * Network emailSenderAddress has drifted from the shipped value, that field blocks
1099
+ * the network deploy; the message then points the operator at the site's
1100
+ * Administration settings. The helper's idempotency check makes a configured re-run
1101
+ * a byte-for-byte no-op with no deploy.
1102
+ */
1103
+ function ensureLogoutUrl(logoutUrl, siteName, targetOrg) {
1104
+ if (!siteName || !logoutUrl) return;
1105
+
1106
+ const networkXmlPath = resolve(SFDX_SOURCE, 'networks', `${siteName}.network-meta.xml`);
1107
+ if (!existsSync(networkXmlPath)) {
1108
+ console.log(` Network metadata not found: ${networkXmlPath}; skipping logout URL update.`);
1109
+ return;
1110
+ }
1111
+
1112
+ // Resolve the shipped (site-relative or absolute) config value to the absolute
1113
+ // URL the platform requires. A relative value needs the site's Experience Cloud
1114
+ // origin, discovered from the org's communities.
1115
+ let absoluteUrl;
1116
+ try {
1117
+ let baseUrl = null;
1118
+ if (!isAbsoluteLogoutUrl(logoutUrl)) {
1119
+ baseUrl = pickCommunityBaseUrl(fetchCommunities(targetOrg), logoutUrl, siteName);
1120
+ }
1121
+ absoluteUrl = resolveLogoutUrl(logoutUrl, baseUrl);
1122
+ } catch (e) {
1123
+ console.error(
1124
+ ` ERROR: cannot resolve an absolute logout URL for "${siteName}" — ${e.message}. ` +
1125
+ `Skipping; set the logout URL manually in the site's Administration settings.`,
1126
+ );
1127
+ return;
1128
+ }
1129
+
1130
+ const xml = readFileSync(networkXmlPath, 'utf8');
1131
+ let result;
1132
+ try {
1133
+ result = setLogoutUrl(xml, absoluteUrl);
1134
+ } catch (e) {
1135
+ if (e instanceof NetworkXmlError) {
1136
+ console.error(
1137
+ ` ERROR: cannot set logout URL in ${siteName}.network-meta.xml — ${e.message}. ` +
1138
+ `Skipping; set <logoutUrl> manually in the site's Administration settings.`,
1139
+ );
1140
+ return;
1141
+ }
1142
+ throw e;
1143
+ }
1144
+
1145
+ if (!result.changed) {
1146
+ console.log(` Logout URL already set to "${absoluteUrl}" in ${siteName}.network-meta.xml; no update needed.`);
1147
+ return;
1148
+ }
1149
+ writeFileSync(networkXmlPath, result.xml);
1150
+ console.log(` Set <logoutUrl>${absoluteUrl}</logoutUrl> in ${siteName}.network-meta.xml`);
1151
+
1152
+ // Re-deploy ONLY the network file (mirrors enableSelfRegistration). Best-effort:
1153
+ // a non-zero exit (e.g. the org's emailSenderAddress differs from the shipped
1154
+ // value and can't be updated) logs loudly and continues.
1155
+ const deployResult = spawnSync('sf', [
1156
+ 'project', 'deploy', 'start',
1157
+ '--target-org', targetOrg,
1158
+ '--source-dir', networkXmlPath,
1159
+ ], { cwd: ROOT, stdio: 'inherit', shell: true, timeout: 120000 });
1160
+ if (deployResult.status !== 0) {
1161
+ console.error(
1162
+ ` ERROR: failed to deploy <logoutUrl> for "${siteName}" (sf exit ${deployResult.status ?? 1}). ` +
1163
+ `If the org's Network emailSenderAddress differs from the shipped value it blocks this deploy — ` +
1164
+ `set the logout URL manually in the site's Administration settings.`,
1165
+ );
1166
+ return;
1167
+ }
1168
+ console.log(` Deployed <logoutUrl> for "${siteName}".`);
1169
+ }
1170
+
1001
1171
  /**
1002
1172
  * Enable self-registration for an Experience Cloud network.
1003
1173
  *
@@ -1788,6 +1958,7 @@ async function main() {
1788
1958
  const hasSelfRegConfig = selfRegConfig !== null;
1789
1959
  const socialLoginConfig = loadSocialLoginConfig(config);
1790
1960
  const hasSocialLoginConfig = socialLoginConfig !== null;
1961
+ const logoutUrl = loadLogoutUrlConfig(config);
1791
1962
 
1792
1963
  // Validate the selfRegProfile name for SOQL-safety up front, alongside the
1793
1964
  // config validation and BEFORE any org mutation (login/deploy). A quote /
@@ -1955,6 +2126,27 @@ async function main() {
1955
2126
  recordSkipped(deployStep, 'not selected');
1956
2127
  }
1957
2128
 
2129
+ // Set the site's logout URL AFTER deploy so members land back on THIS site after
2130
+ // logging out (not the org default-site login, which in a multi-site org can be a
2131
+ // different community). Post-deploy because the deployed value must be an ABSOLUTE
2132
+ // URL (the platform rejects a relative one) and the shipped site-relative path is
2133
+ // resolved against the site's community origin — which only exists once the site
2134
+ // is deployed. Independent of self-reg; best-effort (a failure logs loudly, does
2135
+ // not abort). An ambiguous multi-network app can't auto-target a single site, so
2136
+ // derivation failure skips it.
2137
+ if (!skipDeploy && logoutUrl) {
2138
+ let logoutSiteName = null;
2139
+ try {
2140
+ logoutSiteName = deriveSiteName();
2141
+ } catch {
2142
+ // ambiguous derivation (multiple network files) — skip logout URL prep
2143
+ }
2144
+ if (logoutSiteName) {
2145
+ console.log('\n--- Ensure logout URL (post-deploy) ---');
2146
+ ensureLogoutUrl(logoutUrl, logoutSiteName, targetOrg);
2147
+ }
2148
+ }
2149
+
1958
2150
  const permsetStep = stepDefs.find((s) => s.key === 'permset');
1959
2151
  if (!skipPermset) {
1960
2152
  await runStep(permsetStep, targetOrg, async () => {
@@ -2117,7 +2309,7 @@ async function main() {
2117
2309
  addCommunityMemberProfile(socialLoginConfig.communityMemberProfile, siteName, targetOrg);
2118
2310
 
2119
2311
  // Sub-step 4 (optional): Assign API-enabled permset to community users.
2120
- // Without this, getCurrentUser() fails because /chatter/users/me requires
2312
+ // Without this, getCurrentUser() fails because UI API GraphQL requires
2121
2313
  // PermissionsApiEnabled which the standard community profile lacks.
2122
2314
  if (socialLoginConfig.communityUserPermset) {
2123
2315
  console.log(` [4/${totalSubSteps}] Assigning API permset to community users...`);