@wildo-ai/saas-frontend-lib 1.1.3 → 1.1.4

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 (127) hide show
  1. package/dist/esm/companion-exports.d.ts +1 -1
  2. package/dist/esm/companion-exports.d.ts.map +1 -1
  3. package/dist/esm/companion-exports.js +1 -1
  4. package/dist/esm/companion-exports.js.map +1 -1
  5. package/dist/esm/components/ai-elements/decision/approval.d.ts +13 -1
  6. package/dist/esm/components/ai-elements/decision/approval.d.ts.map +1 -1
  7. package/dist/esm/components/ai-elements/decision/approval.js.map +1 -1
  8. package/dist/esm/components/flows-actors/approval.d.ts.map +1 -1
  9. package/dist/esm/components/flows-actors/approval.js +12 -7
  10. package/dist/esm/components/flows-actors/approval.js.map +1 -1
  11. package/dist/esm/components/flows-actors/approval.labels.d.ts.map +1 -1
  12. package/dist/esm/components/flows-actors/approval.labels.js +3 -0
  13. package/dist/esm/components/flows-actors/approval.labels.js.map +1 -1
  14. package/dist/esm/components/flows-actors/execution-plan.js +1 -1
  15. package/dist/esm/components/flows-actors/execution-plan.js.map +1 -1
  16. package/dist/esm/components/flows-actors/task-memory.js +1 -1
  17. package/dist/esm/components/flows-actors/task-memory.js.map +1 -1
  18. package/dist/esm/components/form/automated/generated-relationship-field.hooks.d.ts +1 -1
  19. package/dist/esm/components/form/controls/number/slider.d.ts +1 -1
  20. package/dist/esm/components/form/fields/form-field-file-provider-constraints.frontend.utils.d.ts +1 -1
  21. package/dist/esm/components/form/fields/form-field-file-provider-constraints.frontend.utils.d.ts.map +1 -1
  22. package/dist/esm/components/form/fields/form-field-file-provider-constraints.frontend.utils.js.map +1 -1
  23. package/dist/esm/components/form/fields/form-field-file.d.ts.map +1 -1
  24. package/dist/esm/components/form/fields/form-field-file.js +66 -17
  25. package/dist/esm/components/form/fields/form-field-file.js.map +1 -1
  26. package/dist/esm/components/form/fields/form-field-file.labels.js +2 -2
  27. package/dist/esm/components/form/fields/form-field-file.labels.js.map +1 -1
  28. package/dist/esm/components/form/form-field-types.d.ts +20 -0
  29. package/dist/esm/components/form/form-field-types.d.ts.map +1 -1
  30. package/dist/esm/components/form/form-field-types.js.map +1 -1
  31. package/dist/esm/components/navigation/NavigationItemBadge.d.ts +7 -1
  32. package/dist/esm/components/navigation/NavigationItemBadge.d.ts.map +1 -1
  33. package/dist/esm/components/navigation/NavigationItemBadge.js +29 -1
  34. package/dist/esm/components/navigation/NavigationItemBadge.js.map +1 -1
  35. package/dist/esm/components/navigation/menu-bar.d.ts +1 -1
  36. package/dist/esm/components/overlays/containers/OverlayContainer.d.ts.map +1 -1
  37. package/dist/esm/components/overlays/containers/OverlayContainer.js +36 -9
  38. package/dist/esm/components/overlays/containers/OverlayContainer.js.map +1 -1
  39. package/dist/esm/components/overlays/floating/context-menu.d.ts +1 -1
  40. package/dist/esm/components/overlays/floating/dropdown-menu.d.ts +1 -1
  41. package/dist/esm/components/resources/ResourceLayout_FilterChips.js +1 -1
  42. package/dist/esm/components/resources/ResourceLayout_FilterChips.js.map +1 -1
  43. package/dist/esm/components/resources/ResourceListItemRenderer.js +1 -1
  44. package/dist/esm/components/resources/ResourceListItemRenderer.js.map +1 -1
  45. package/dist/esm/components/resources/skeleton/ResourceHeaderTitle.d.ts +12 -0
  46. package/dist/esm/components/resources/skeleton/ResourceHeaderTitle.d.ts.map +1 -1
  47. package/dist/esm/components/resources/skeleton/ResourceHeaderTitle.js +13 -1
  48. package/dist/esm/components/resources/skeleton/ResourceHeaderTitle.js.map +1 -1
  49. package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.d.ts +106 -49
  50. package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.d.ts.map +1 -1
  51. package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.js +311 -109
  52. package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.js.map +1 -1
  53. package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.labels.d.ts.map +1 -1
  54. package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.labels.js +32 -2
  55. package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.labels.js.map +1 -1
  56. package/dist/esm/components/settings/SettingsHub.d.ts.map +1 -1
  57. package/dist/esm/components/settings/SettingsHub.js +30 -6
  58. package/dist/esm/components/settings/SettingsHub.js.map +1 -1
  59. package/dist/esm/components/views/onboarding-view/OnboardingView.d.ts.map +1 -1
  60. package/dist/esm/components/views/onboarding-view/OnboardingView.js +9 -2
  61. package/dist/esm/components/views/onboarding-view/OnboardingView.js.map +1 -1
  62. package/dist/esm/core/automated-layout/automated-layout.frontend.schemas.d.ts +16 -0
  63. package/dist/esm/core/automated-layout/automated-layout.frontend.schemas.d.ts.map +1 -1
  64. package/dist/esm/core/automated-layout/automated-layout.frontend.schemas.js +17 -0
  65. package/dist/esm/core/automated-layout/automated-layout.frontend.schemas.js.map +1 -1
  66. package/dist/esm/core/automated-layout/default-layout-generator.frontend.utils.d.ts.map +1 -1
  67. package/dist/esm/core/automated-layout/default-layout-generator.frontend.utils.js +3 -2
  68. package/dist/esm/core/automated-layout/default-layout-generator.frontend.utils.js.map +1 -1
  69. package/dist/esm/core/billing/billing-launcher.js +1 -1
  70. package/dist/esm/core/billing/billing-launcher.js.map +1 -1
  71. package/dist/esm/core/components-registry/provider-component-registry.frontend.service.d.ts +64 -4
  72. package/dist/esm/core/components-registry/provider-component-registry.frontend.service.d.ts.map +1 -1
  73. package/dist/esm/core/components-registry/provider-component-registry.frontend.service.js +76 -4
  74. package/dist/esm/core/components-registry/provider-component-registry.frontend.service.js.map +1 -1
  75. package/dist/esm/core/components-registry/useProviderComponent.frontend.hooks.d.ts +8 -0
  76. package/dist/esm/core/components-registry/useProviderComponent.frontend.hooks.d.ts.map +1 -1
  77. package/dist/esm/core/components-registry/useProviderComponent.frontend.hooks.js +1 -0
  78. package/dist/esm/core/components-registry/useProviderComponent.frontend.hooks.js.map +1 -1
  79. package/dist/esm/core/config/chart-view.frontend.schemas.d.ts +8 -8
  80. package/dist/esm/core/config/composite-view.schemas.d.ts +4 -4
  81. package/dist/esm/core/config/engine-settings-destinations.d.ts +0 -69
  82. package/dist/esm/core/config/engine-settings-destinations.d.ts.map +1 -1
  83. package/dist/esm/core/config/engine-settings-destinations.js +35 -74
  84. package/dist/esm/core/config/engine-settings-destinations.js.map +1 -1
  85. package/dist/esm/core/config/settings-hub.frontend.schemas.d.ts +10 -1
  86. package/dist/esm/core/config/settings-hub.frontend.schemas.d.ts.map +1 -1
  87. package/dist/esm/core/config/settings-hub.frontend.schemas.js +9 -0
  88. package/dist/esm/core/config/settings-hub.frontend.schemas.js.map +1 -1
  89. package/dist/esm/core/config/useSettingsHubConfig.d.ts.map +1 -1
  90. package/dist/esm/core/config/useSettingsHubConfig.js +8 -1
  91. package/dist/esm/core/config/useSettingsHubConfig.js.map +1 -1
  92. package/dist/esm/core/events/events.frontend.schemas.d.ts +4 -4
  93. package/dist/esm/core/external-providers/ProviderFilePickerHost.d.ts +12 -1
  94. package/dist/esm/core/external-providers/ProviderFilePickerHost.d.ts.map +1 -1
  95. package/dist/esm/core/external-providers/ProviderFilePickerHost.js +15 -2
  96. package/dist/esm/core/external-providers/ProviderFilePickerHost.js.map +1 -1
  97. package/dist/esm/core/external-providers/provider-file-selection.frontend.utils.d.ts +1 -2
  98. package/dist/esm/core/external-providers/provider-file-selection.frontend.utils.d.ts.map +1 -1
  99. package/dist/esm/core/external-providers/provider-file-selection.frontend.utils.js.map +1 -1
  100. package/dist/esm/core/flows-actors/FlowsActorsContext.d.ts +6 -1
  101. package/dist/esm/core/flows-actors/FlowsActorsContext.d.ts.map +1 -1
  102. package/dist/esm/core/flows-actors/FlowsActorsContext.js +10 -2
  103. package/dist/esm/core/flows-actors/FlowsActorsContext.js.map +1 -1
  104. package/dist/esm/core/i18n/I18nContext.d.ts +23 -0
  105. package/dist/esm/core/i18n/I18nContext.d.ts.map +1 -1
  106. package/dist/esm/core/i18n/I18nContext.js +6 -1
  107. package/dist/esm/core/i18n/I18nContext.js.map +1 -1
  108. package/dist/esm/core/requests/wildo-http-client-manual-calls.frontend.service.d.ts +75 -2
  109. package/dist/esm/core/requests/wildo-http-client-manual-calls.frontend.service.d.ts.map +1 -1
  110. package/dist/esm/core/requests/wildo-http-client-manual-calls.frontend.service.js +52 -0
  111. package/dist/esm/core/requests/wildo-http-client-manual-calls.frontend.service.js.map +1 -1
  112. package/dist/esm/core/requests/wildo-http-client-resources.frontend.service.d.ts +3 -2
  113. package/dist/esm/core/requests/wildo-http-client-resources.frontend.service.d.ts.map +1 -1
  114. package/dist/esm/core/requests/wildo-http-client-resources.frontend.service.js +57 -4
  115. package/dist/esm/core/requests/wildo-http-client-resources.frontend.service.js.map +1 -1
  116. package/dist/esm/core/resources/ResourceReadCacheBridge.d.ts.map +1 -1
  117. package/dist/esm/core/resources/ResourceReadCacheBridge.js +130 -37
  118. package/dist/esm/core/resources/ResourceReadCacheBridge.js.map +1 -1
  119. package/dist/esm/core/resources/resources.frontend.schemas.d.ts +2 -2
  120. package/dist/esm/core/runtime/start-application.frontend.service.d.ts.map +1 -1
  121. package/dist/esm/core/runtime/start-application.frontend.service.js +17 -9
  122. package/dist/esm/core/runtime/start-application.frontend.service.js.map +1 -1
  123. package/dist/esm/core/security/UserOrgsContext.d.ts.map +1 -1
  124. package/dist/esm/core/security/UserOrgsContext.js +61 -5
  125. package/dist/esm/core/security/UserOrgsContext.js.map +1 -1
  126. package/dist/tsconfig.build.tsbuildinfo +1 -1
  127. package/package.json +6 -6
@@ -1,69 +1,150 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
- * AppPage_ConnectedAccountsSettings — the Settings Hub "Connected accounts" connect panel.
3
+ * AppPage_ConnectedAccountsSettings — connecting a third-party account, at either owner scope.
4
4
  *
5
5
  * A bespoke `ENGINE_PANEL`, and the reason is the shape of the act rather than the shape of the data.
6
- * The stored credentials ARE a resource, and the generic surface beside this one lists, inspects and
6
+ * The stored credentials ARE a resource and the generic surface beside this one lists, inspects and
7
7
  * deletes them perfectly. What it cannot do is CONNECT: that is not a row somebody creates, it is a
8
8
  * flow that leaves the application, obtains consent at a vendor, and returns with a grant nobody
9
- * typed. A resource screen has no mechanism for an action of that shape, so until this existed the
10
- * whole outbound-authorization lane — controller, exchange, refresher, encrypted store, migrations —
11
- * had no surface that could begin it.
12
- *
13
- * ## Two reads, deliberately not one
14
- *
15
- * `listConnectableProviders()` answers what MAY be connected; the sibling resource list answers what
16
- * IS. Merging them would make "you have connected nothing" and "there is nothing to connect"
17
- * indistinguishable — and the first of those is the state every person starts in, which is precisely
18
- * when they need the second question answered.
19
- *
20
- * ## USER scope only, and that is not a simplification
21
- *
22
- * This panel lives under the person's own settings, so it offers only connections the provider admits
23
- * as USER-owned. A tenant's shared integration is the administrator's screen next door
24
- * (`provider-credentials`), and the two must never be merged: one is an individual's own account that
25
- * nobody else may see or spend, the other is a shared credential an administrator configures for
26
- * everyone. There is no cross-scope defaulting anywhere in this lane — a person with no credential of
27
- * their own is refused rather than quietly spending the organization's.
28
- *
29
- * ## Why it navigates instead of following a redirect
30
- *
31
- * The start leg answers with a URL. A 302 would be followed by this fetch, produce an opaque
32
- * cross-origin response, and render the vendor's consent screen into a response body nobody sees. So
33
- * the panel assigns `window.location.href` and the browser leaves — which is also why the OUTCOME
34
- * arrives back as a query parameter rather than as a promise resolving: the return trip is a fresh
35
- * page load from the vendor, not a continuation of this component.
36
- *
37
- * i18n follows the framework convention: `useI18n({ component })` requests this component's label
38
- * namespace, the panel gates rendering on `isReady` so it never flashes raw key paths, and a thin
39
- * `t(key)` reads each label. Copy is owned by the labelling pipeline (sibling `.labels.ts`) — no
40
- * hardcoded strings, and no humanized provider ref either: a provider identifier is an open
41
- * vocabulary with no authored label, so it is rendered verbatim rather than invented from its own
42
- * characters.
43
- *
44
- * assurance-control: WILDO.IDENTITY.DELEGATED_AUTHORIZATION — the surface on which a person
9
+ * typed. A resource screen has no mechanism for an action of that shape.
10
+ *
11
+ * ## Nothing is typed by hand
12
+ *
13
+ * The provider comes from a SELECT over what the application declares; the credential fields come
14
+ * from that provider's own secrets contract. Both were free text until this panel existed, which is
15
+ * how somebody ends up typing `secretSlug` into a box and storing a grant in a slot nothing reads.
16
+ *
17
+ * ## The flow follows the AUTH METHOD
18
+ *
19
+ * A connect surface cannot be one form. A vendor with an authorization flow gets the consent round
20
+ * trip; a vendor whose credential is typed gets a form over the fields IT declares. One button for
21
+ * both would be wrong for whichever it was not built around.
22
+ *
23
+ * ## A provider this deployment cannot connect is SHOWN, never hidden
24
+ *
25
+ * When the application has not registered with a vendor, the panel names the environment variables an
26
+ * operator must set. Removing the provider would answer "we do not support Asana", which is false and
27
+ * unfixable; naming `ASANA_CLIENT_ID` is true and is the thing that gets it set.
28
+ *
29
+ * ## One component, both scopes
30
+ *
31
+ * `ownerScope` decides whose account is being connected — a person's own, or the tenant's. The panel
32
+ * is otherwise identical because the ACT is identical, and duplicating it would guarantee the two
33
+ * drift. What must never be shared is the DATA: a person's connection and a tenant's shared
34
+ * integration have different owners, different authorization and different readers, and the scope is
35
+ * what keeps them apart at every call this panel makes.
36
+ *
37
+ * assurance-control: WILDO.IDENTITY.DELEGATED_AUTHORIZATION — the surface on which a principal
45
38
  * authorizes this application to act toward a named third party on their behalf.
46
39
  */
47
- import { useCallback, useEffect, useMemo, useState } from 'react';
40
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
41
+ import { z } from 'zod';
48
42
  import { ButtonSize, FrontendComponentType } from '@wildo-ai/presets-components-models';
49
- import { isWildoBackendError } from '@wildo-ai/saas-models';
43
+ import { isWildoBackendError, ProviderAuthMethod, ProviderConnectabilityBlocker } from '@wildo-ai/saas-models';
50
44
  import { ExternalProvider_Connection_DelegationMode, ExternalProvider_Connection_OwnerScope, } from '@wildo-ai/external-connectors-models';
51
45
  import { Card, CardContent, CardHeader, CardTitle } from '../../components/sections/card.js';
52
46
  import { Button, ButtonVariants_Variant } from '../../components/actions/button.js';
53
47
  import { InputRoot } from '../../components/form/controls/input.js';
48
+ import { Select } from '../../components/form/controls/enum/select.js';
54
49
  import { Text } from '../../components/typography/text.js';
55
50
  import { Alert, AlertDescription } from '../../components/overlays/containers/alert.js';
56
51
  import { useI18n } from '../../core/i18n/useI18n.js';
57
52
  import { useUserOrgs } from '../../core/security/useUserOrgs.js';
58
53
  import { getManualCallsHttpClient, } from '../../core/requests/wildo-http-client-manual-calls.frontend.service.js';
59
54
  const COMPONENT = FrontendComponentType.APP_LAYOUTS_CONNECTED_ACCOUNTS_SETTINGS;
55
+ /** The select's schema. A provider ref is an open identifier, so the control validates shape only. */
56
+ const PROVIDER_REF_SCHEMA = z.string().min(1);
57
+ /** The chooser's own shape. Its OPTIONS are the provider's declared delegations, never this. */
58
+ const DELEGATION_SCHEMA = z.string().min(1);
60
59
  /**
61
- * The outcome the callback wrote into the URL on its way back.
60
+ * The providers connectable AT THIS SCOPE.
62
61
  *
63
- * Read once, from `window.location`, because this component did not survive the round trip — the
64
- * browser left for the vendor and came back on a fresh page load. Read as a plain string rather than
65
- * branched into a closed vocabulary: the callback's `connected` value is a provider ref, which is an
66
- * open set.
62
+ * Exported and named because it is the panel's one substantive claim: a provider admitting only
63
+ * ORGANIZATION connections must never be offered on a person's screen, and vice versa — offering it
64
+ * renders an action that answers 403 at the start leg.
65
+ *
66
+ * It deliberately does NOT filter on `connectable`. A provider this deployment cannot connect is
67
+ * still offered, because the panel's answer for it is the missing configuration rather than silence.
68
+ */
69
+ export const selectProvidersForScope = (providers, ownerScope) => providers.filter((provider) => provider.admissibleConnections.some((shape) => shape.ownerScope === ownerScope));
70
+ /**
71
+ * ON WHOSE BEHALF this provider admits a connection at this scope — DERIVED, never assumed.
72
+ *
73
+ * The panel used to send `delegation: SERVICE` for every connect, at both scopes. That is one of the
74
+ * five declared shapes, so it worked by coincidence for the providers that happen to declare it and
75
+ * answered `409 CONNECTION_NOT_ADMISSIBLE` for the rest — the start leg checks the exact
76
+ * (ownerScope, delegation) PAIR against what the provider admits, and a scope does not imply a
77
+ * delegation.
78
+ *
79
+ * Measured across the generated catalogue on 2026-09-10, which is why this is a derivation rather
80
+ * than a better constant: at ORGANIZATION scope, 75 vendors declare `USER` and 39 declare `SERVICE`.
81
+ * A hard-coded `SERVICE` was therefore wrong for the larger half — `asana` among them, which is how
82
+ * this surfaced: the provider was listed, the button rendered, and the vendor was never reached.
83
+ *
84
+ * The pair is the provider's own statement, so the panel reads it rather than the person supplying
85
+ * it. Where a provider declares BOTH delegations for one scope — 27 vendors do, `box`, `dropbox`,
86
+ * `gmail` and `google-calendar` among them — the declaration genuinely does not decide, and only
87
+ * then is there a question to put to anybody. See `.claude/rules/derive-before-you-ask.md`.
88
+ */
89
+ export const admissibleDelegationsForScope = (provider, ownerScope) => {
90
+ // The view types both members as `string` — it is a wire record — so this is where they become
91
+ // the vocabulary. NARROWED rather than cast: a delegation this build does not know is dropped
92
+ // instead of being sent to the start leg, which would answer 409 for a value the panel invented.
93
+ const known = new Set(Object.values(ExternalProvider_Connection_DelegationMode));
94
+ const delegations = [];
95
+ for (const shape of provider.admissibleConnections) {
96
+ if (shape.ownerScope !== ownerScope || !known.has(shape.delegation))
97
+ continue;
98
+ const delegation = shape.delegation;
99
+ if (!delegations.includes(delegation))
100
+ delegations.push(delegation);
101
+ }
102
+ return delegations;
103
+ };
104
+ /**
105
+ * The target the start leg is given — assembled here so the shape is TESTABLE, not inlined.
106
+ *
107
+ * `ExternalProvider_ConnectionTargetSchema` refines two fields the panel cannot leave to be
108
+ * inferred, and it refuses in BOTH directions:
109
+ *
110
+ * - a `USER`-delegated call MUST name `actingForUserId` — "a delegated call acts FOR someone, and
111
+ * the user it acts for is what makes it permissible";
112
+ * - a `SERVICE` call MUST NOT, because naming a person there "reads as delegation the runtime will
113
+ * not perform, which is worse than an error: the call succeeds as the wrong identity".
114
+ *
115
+ * The panel sent neither until 2026-09-10, so every `USER`-delegated connect answered
116
+ * `400 VALIDATION_FAILED` — and that refusal names no field, so the screen could only report a
117
+ * generic failure. It was invisible because the shape that WORKS is `SERVICE`-delegated: `USER` at
118
+ * ORGANIZATION scope is what 75 of the harvested vendors declare, `asana` among them, and it is
119
+ * exactly the half nobody had driven.
120
+ *
121
+ * `actingForUserId` is the SIGNED-IN person and is independent of `ownerId`: a tenant-owned
122
+ * connection acting for one of its members carries the organization in `ownerId` and the person
123
+ * here. At USER scope the two coincide, which is why one field cannot serve both.
124
+ *
125
+ * `ownerId` is omitted for APPLICATION, which the schema likewise refuses — "an application-owned
126
+ * connection has exactly one instance, so an ownerId here names something that does not exist".
127
+ * The Settings Hub renders only USER and ORGANIZATION today, so that arm is unreachable from this
128
+ * panel; it is handled because the function is the shape's one statement, and a caller that grows
129
+ * the third scope should not have to rediscover the rule.
130
+ */
131
+ export const buildConnectionTarget = (input) => ({
132
+ ownerScope: input.ownerScope,
133
+ delegation: input.delegation,
134
+ ...(input.ownerScope === ExternalProvider_Connection_OwnerScope.APPLICATION || input.ownerId === null
135
+ ? {}
136
+ : { ownerId: input.ownerId }),
137
+ ...(input.delegation === ExternalProvider_Connection_DelegationMode.USER && input.signedInUserId !== null
138
+ ? { actingForUserId: input.signedInUserId }
139
+ : {}),
140
+ });
141
+ /**
142
+ * What the callback left in the URL on its way back. Read once — see the panel's mount.
143
+ *
144
+ * Two shapes arrive here, and they are different acts. `connectionError` is a FINISHED outcome the
145
+ * callback already decided — a refusal at the vendor, or an attempt it could not recognise. `state`
146
+ * and `code` are an approval that is NOT yet redeemed: the callback cannot redeem it, because a
147
+ * vendor redirect carries no bearer token, so it hands the approval to this screen to finish.
67
148
  */
68
149
  const readReturnOutcome = () => {
69
150
  if (typeof window === 'undefined')
@@ -72,45 +153,99 @@ const readReturnOutcome = () => {
72
153
  return {
73
154
  connected: params.get('connected') ?? undefined,
74
155
  connectionError: params.get('connectionError') ?? undefined,
156
+ state: params.get('state') ?? undefined,
157
+ code: params.get('code') ?? undefined,
75
158
  };
76
159
  };
77
160
  /**
78
- * The providers THIS person may connect for themselves.
79
- *
80
- * Named and exported rather than inlined because it is the panel's one substantive claim, and both
81
- * halves of it are a way for a button to be DEAD on press:
161
+ * Take the approval out of the address bar once it has been redeemed.
82
162
  *
83
- * - a provider admitting only ORGANIZATION-owned connections belongs on the administrator's screen
84
- * next door; offering it here renders a Connect button that answers 403;
85
- * - a provider whose credential is pasted has no authorization flow to start, so its button would
86
- * have nowhere to navigate.
87
- *
88
- * The two filters look interchangeable and are not: dropping the first shows an action the person is
89
- * not entitled to, dropping the second shows an action that does not exist.
163
+ * `replaceState` rather than a navigation: the entry is REPLACED, so the code does not sit in
164
+ * history for a back button to return to, and no re-render is triggered. It is single-use at the
165
+ * backend regardless — this is about not leaving it lying in the URL, in a referrer, or in whatever
166
+ * the browser syncs.
90
167
  */
91
- export const selectPersonallyConnectableProviders = (providers) => providers.filter((provider) => provider.connectable
92
- && provider.admissibleConnections.some((shape) => shape.ownerScope === ExternalProvider_Connection_OwnerScope.USER));
93
- export const AppPage_ConnectedAccountsSettings = () => {
168
+ const clearApprovalFromAddressBar = () => {
169
+ if (typeof window === 'undefined')
170
+ return;
171
+ const url = new URL(window.location.href);
172
+ url.searchParams.delete('state');
173
+ url.searchParams.delete('code');
174
+ window.history.replaceState(window.history.state, '', url.toString());
175
+ };
176
+ export const AppPage_ConnectedAccountsSettings = ({
177
+ // Defaults to the person's own, which is the destination this panel was built for; the
178
+ // organization surface passes the scope explicitly.
179
+ ownerScope = ExternalProvider_Connection_OwnerScope.USER, }) => {
94
180
  const { tComponent, isReady } = useI18n({ component: COMPONENT });
95
- const { user } = useUserOrgs();
181
+ const { user, currentOrganizationId } = useUserOrgs();
96
182
  const t = useCallback((key) => tComponent(COMPONENT, key), [tComponent]);
97
183
  /**
98
- * Whose account is being connected — always the caller's own.
184
+ * WHOSE account, resolved for the scope in play.
99
185
  *
100
- * Sent explicitly because the target schema is the SAME one the call leg uses, and there an owner
101
- * genuinely varies. The backend does not trust this value: it re-checks `ownerId === callerId` and
102
- * refuses anything else, so supplying it is a statement of intent rather than a decision.
186
+ * The backend re-checks it — a USER target must be the caller, an ORGANIZATION target goes through
187
+ * the membership port — so supplying it is a statement of intent rather than a decision.
103
188
  */
104
- const selfUserId = user?._id ?? null;
189
+ const ownerId = ownerScope === ExternalProvider_Connection_OwnerScope.USER
190
+ ? user?._id ?? null
191
+ : currentOrganizationId ?? null;
105
192
  const [providers, setProviders] = useState([]);
106
193
  const [loading, setLoading] = useState(true);
107
- const [busyRef, setBusyRef] = useState(null);
108
194
  const [error, setError] = useState(null);
109
- /** Answers to a provider's `{placeholders}`, per provider ref. Nothing is remembered across a reload. */
195
+ const [selectedRef, setSelectedRef] = useState('');
196
+ const [busy, setBusy] = useState(false);
197
+ /** Set ONLY when the provider declares both delegations for this scope — otherwise derived. */
198
+ const [delegationChoice, setDelegationChoice] = useState('');
199
+ /** Answers keyed by the slug the PROVIDER declared — never by a name somebody typed. */
110
200
  const [answers, setAnswers] = useState({});
111
- // Read once on mount. The outcome describes a journey that has already finished; re-reading it on
112
- // every render would make the banner reappear after the person dismissed the tab and came back.
113
- const [outcome] = useState(readReturnOutcome);
201
+ const [outcome, setOutcome] = useState(readReturnOutcome);
202
+ /**
203
+ * Redeem an approval the vendor just granted — the half the callback could not do.
204
+ *
205
+ * ## The in-flight request is CACHED, and both mounts subscribe to it
206
+ *
207
+ * React 18 mounts an effect, tears it down and mounts it again in StrictMode, and the attempt is
208
+ * consumed atomically by the backend — so a second redemption answers
209
+ * `CONNECTION_ATTEMPT_INVALID` and the `catch` would replace a connection that genuinely succeeded
210
+ * with a failure notice. That is not hypothetical: on the first real consent, 2026-09-10, the
211
+ * backend logged `[oauth2-connect] account connected` with `grantedScopes: ["default","identity"]`
212
+ * and stored the credential, and the browser then posted a second time and showed a 400.
213
+ *
214
+ * A once-guard alone does not fix it, and the way it fails is worse than the defect — the same
215
+ * trap `TotpEnrollmentMethod.strict-mode-setup.test.tsx` records. A guard PLUS a cancel-on-cleanup
216
+ * flag means mount 1 starts the request, its cleanup cancels the only subscriber, and the guard
217
+ * then stops mount 2 from applying anything: the account connects and the panel says nothing at
218
+ * all. Caching the PROMISE instead lets the remount re-subscribe, so there is one request and the
219
+ * live mount is the one that renders its result.
220
+ *
221
+ * The address bar is cleared beside the request rather than after it, for its own reason: not
222
+ * leaving an authorization code sitting in history behind a back button.
223
+ */
224
+ const redemption = useRef(null);
225
+ useEffect(() => {
226
+ const { state, code } = outcome;
227
+ if (state === undefined || code === undefined)
228
+ return;
229
+ if (redemption.current === null) {
230
+ clearApprovalFromAddressBar();
231
+ redemption.current = getManualCallsHttpClient().completeProviderConnection({ state, code });
232
+ }
233
+ let cancelled = false;
234
+ redemption.current
235
+ .then(({ connected }) => { if (!cancelled)
236
+ setOutcome({ connected }); })
237
+ .catch((e) => {
238
+ if (isWildoBackendError(e))
239
+ throw e;
240
+ // The callback's own vocabulary, so the notices below stay one set rather than two.
241
+ if (!cancelled)
242
+ setOutcome({ connectionError: 'failed' });
243
+ });
244
+ return () => { cancelled = true; };
245
+ // `outcome` is seeded once from the URL and the cached promise is what makes this issue one
246
+ // request, so this deliberately does not re-run on the outcome it sets itself.
247
+ // eslint-disable-next-line react-hooks/exhaustive-deps
248
+ }, []);
114
249
  useEffect(() => {
115
250
  let cancelled = false;
116
251
  (async () => {
@@ -132,53 +267,120 @@ export const AppPage_ConnectedAccountsSettings = () => {
132
267
  })();
133
268
  return () => { cancelled = true; };
134
269
  }, [t]);
135
- const personalProviders = useMemo(() => selectPersonallyConnectableProviders(providers), [providers]);
136
- const handleConnect = useCallback(async (provider) => {
137
- setBusyRef(provider.providerRef);
270
+ const available = useMemo(() => selectProvidersForScope(providers, ownerScope), [providers, ownerScope]);
271
+ /**
272
+ * EVERY declared provider still missing part of its configuration — not just the selected one.
273
+ *
274
+ * A gap found by selecting a provider is a gap found one click at a time, and an operator asking
275
+ * "what is not configured" would have to walk all eighteen. This answers it once.
276
+ *
277
+ * Deliberately NOT filtered by `ownerScope`. A missing environment variable is a fact about the
278
+ * APPLICATION: `ASANA_CLIENT_ID` is absent for everybody, and hiding it on the person's screen
279
+ * because Asana also admits a tenant connection would make the same gap visible or invisible
280
+ * depending on which settings page somebody happened to open.
281
+ */
282
+ const configurationGaps = useMemo(() => providers
283
+ .filter((provider) => (provider.missingRegistration ?? []).length > 0)
284
+ .map((provider) => ({
285
+ providerRef: provider.providerRef,
286
+ envVarNames: (provider.missingRegistration ?? []).map((slot) => slot.envVarName),
287
+ })), [providers]);
288
+ const selected = useMemo(() => available.find((provider) => provider.providerRef === selectedRef), [available, selectedRef]);
289
+ // Answers belong to the provider they were typed for. Clearing on change is what stops a value
290
+ // meant for one vendor being submitted to another when somebody changes their mind mid-form.
291
+ const chooseProvider = useCallback((ref) => {
292
+ setSelectedRef(ref);
293
+ setAnswers({});
294
+ setDelegationChoice('');
138
295
  setError(null);
296
+ }, []);
297
+ /**
298
+ * What this provider admits at the scope in play, and the one the connect will carry.
299
+ *
300
+ * `delegationChoice` is only ever set by the chooser below, which renders solely when the provider
301
+ * declares more than one. For the ordinary provider `delegations` has a single member and that
302
+ * member IS the answer — nothing is asked and nothing is assumed.
303
+ */
304
+ const delegations = useMemo(() => (selected === undefined ? [] : admissibleDelegationsForScope(selected, ownerScope)), [ownerScope, selected]);
305
+ const delegation = delegations.length === 1
306
+ ? delegations[0]
307
+ : delegations.find((candidate) => candidate === delegationChoice);
308
+ const [savedNotice, setSavedNotice] = useState(null);
309
+ /**
310
+ * The two acts behind one button, branching on the provider's own auth method.
311
+ *
312
+ * An authorization flow ENDS THE PAGE — the browser leaves for the vendor and returns to a fresh
313
+ * load. A typed credential ends in a stored row and the person stays here. Sharing a handler is
314
+ * right because the person's intent is the same ("connect this"); sharing the OUTCOME would not be,
315
+ * which is why only one of the branches navigates.
316
+ */
317
+ const handleConnect = useCallback(async () => {
318
+ // `delegation` undefined means the provider declares several and none is chosen yet. The button
319
+ // is disabled in that state; the guard is here because a disabled control is a courtesy and this
320
+ // is the statement that a target is never assembled with a delegation nobody chose.
321
+ if (selected === undefined || ownerId === null || delegation === undefined)
322
+ return;
323
+ setBusy(true);
324
+ setError(null);
325
+ setSavedNotice(null);
326
+ const target = buildConnectionTarget({ ownerScope, ownerId, delegation, signedInUserId: user?._id ?? null });
139
327
  try {
140
- const { authorizeUrl } = await getManualCallsHttpClient().startProviderConnection(provider.providerRef, {
141
- // The person connects their OWN account and nobody else's — the backend re-checks
142
- // exactly this, so a tampered owner id is refused rather than honoured.
143
- target: {
144
- ownerScope: ExternalProvider_Connection_OwnerScope.USER,
145
- ownerId: selfUserId,
146
- delegation: ExternalProvider_Connection_DelegationMode.SERVICE,
147
- },
148
- connectionParameters: answers[provider.providerRef],
149
- });
150
- // The browser leaves. No state is set afterwards on purpose: this component is about to be
151
- // unmounted by a navigation, and a `setState` here would be a React warning on the way out.
152
- window.location.href = authorizeUrl;
328
+ if (selected.authMethods?.includes(ProviderAuthMethod.OAUTH2_AUTHORIZATION_CODE) ?? false) {
329
+ const { authorizeUrl } = await getManualCallsHttpClient().startProviderConnection(selected.providerRef, { target, connectionParameters: answers });
330
+ // The browser leaves. No state is set afterwards: this component is about to be unmounted by
331
+ // a navigation, and a `setState` here would be a React warning on the way out.
332
+ window.location.href = authorizeUrl;
333
+ return;
334
+ }
335
+ // Only the values the provider DECLARED, so a stale answer left over from a field that is no
336
+ // longer offered cannot be posted. The route re-checks this; sending less is still the honest
337
+ // thing for the screen to do.
338
+ const declared = new Set((selected.credentialFields ?? []).map((field) => field.slug));
339
+ const values = Object.fromEntries(Object.entries(answers).filter(([slug, value]) => declared.has(slug) && value.trim() !== ''));
340
+ await getManualCallsHttpClient().storeProviderCredential(selected.providerRef, { target, values });
341
+ // The typed values are cleared once stored. They are secrets, and a form that keeps them
342
+ // on screen after they have been sealed is holding them for no reason.
343
+ setAnswers({});
344
+ setSavedNotice(t('credentialStoredNotice'));
345
+ setBusy(false);
153
346
  }
154
347
  catch (e) {
155
348
  if (isWildoBackendError(e))
156
349
  throw e;
157
350
  setError(t('errorGeneric'));
158
- setBusyRef(null);
351
+ setBusy(false);
159
352
  }
160
- }, [answers, selfUserId, t]);
161
- // Gate on label readiness so the panel never flashes raw key paths.
353
+ }, [answers, delegation, ownerId, ownerScope, selected, t, user?._id]);
162
354
  if (!isReady)
163
355
  return null;
164
- return (_jsxs("div", { className: "flex flex-col gap-section", children: [_jsxs("div", { className: "flex flex-col gap-item-xs", children: [_jsx("h3", { className: "text-base font-semibold tracking-tight", children: t('pageTitle') }), _jsx(Text, { className: "text-sm text-muted-foreground", children: t('pageDescription') })] }), outcome.connected !== undefined && (_jsx(Alert, { children: _jsx(AlertDescription, { children: t('connectedNotice') }) })), outcome.connectionError === 'declined' && (_jsx(Alert, { children: _jsx(AlertDescription, { children: t('declinedNotice') }) })), outcome.connectionError !== undefined && outcome.connectionError !== 'declined' && (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: t('failedNotice') }) })), error && (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: error }) })), _jsx(Text, { className: "text-xs text-muted-foreground", children: t('disconnectNotice') }), loading ? (_jsx(Text, { className: "text-sm text-muted-foreground", children: t('loading') })) : personalProviders.length === 0 ? (_jsx(Text, { className: "text-sm text-muted-foreground", children: t('empty') })) : (_jsx("div", { className: "flex flex-col gap-group", children: personalProviders.map((provider) => (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: provider.providerRef }) }), _jsxs(CardContent, { className: "flex flex-col gap-group", children: [provider.scopes !== undefined && provider.scopes.length > 0 && (_jsxs(Text, { className: "text-xs text-muted-foreground", children: [t('scopesPrefix'), " ", provider.scopes.join(', ')] })), provider.connectionParameters !== undefined && provider.connectionParameters.length > 0 && (_jsxs("div", { className: "flex flex-col gap-item-xs", children: [_jsx(Text, { className: "text-sm font-medium", children: t('parametersHeading') }), _jsx(Text, { className: "text-xs text-muted-foreground", children: t('parametersDescription') }), provider.connectionParameters.map((parameter) => (
165
- // `InputRoot`, not `Input`: the compound `Input` is the SCHEMA-DRIVEN form
166
- // control and requires a `name` and a Zod `schema`. These fields belong to no
167
- // resource — they are answers to a vendor's URL, held for the length of one
168
- // click — so inventing a schema for them would be inventing a model.
169
- _jsx(InputRoot, { "aria-label": parameter, placeholder: parameter, value: answers[provider.providerRef]?.[parameter] ?? '', onChange: (event) => setAnswers((previous) => ({
170
- ...previous,
171
- [provider.providerRef]: {
172
- ...previous[provider.providerRef],
173
- [parameter]: event.target.value,
174
- },
175
- })) }, parameter)))] })), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { size: ButtonSize.SM, variant: ButtonVariants_Variant.DEFAULT,
176
- // Every placeholder must be answered: a half-answered set builds a URL that is
177
- // still not a URL.
178
- disabled: busyRef === provider.providerRef
179
- // No identity yet — the auth context has not settled. Pressing now would send
180
- // an empty owner and be refused for a reason nobody could act on.
181
- || selfUserId === null
182
- || (provider.connectionParameters ?? []).some((parameter) => (answers[provider.providerRef]?.[parameter] ?? '').trim() === ''), onClick: () => handleConnect(provider), children: busyRef === provider.providerRef ? t('connectingButton') : t('connectButton') }) })] })] }, provider.providerRef))) }))] }));
356
+ const providerOptions = available.map((provider) => ({
357
+ value: provider.providerRef,
358
+ // The ref VERBATIM. There is no authored label for an open vocabulary of vendors, and deriving
359
+ // words from an identifier would invent display text no translator can reach.
360
+ label: provider.providerRef,
361
+ }));
362
+ const oauth2 = selected?.authMethods?.includes(ProviderAuthMethod.OAUTH2_AUTHORIZATION_CODE) ?? false;
363
+ const missing = selected?.missingRegistration ?? [];
364
+ const parameters = selected?.connectionParameters ?? [];
365
+ const credentialFields = selected?.credentialFields ?? [];
366
+ /** What the authorization will REQUEST — rendered before the button, never only after consent. */
367
+ const scopes = selected?.scopes ?? [];
368
+ const unanswered = (oauth2 ? parameters : credentialFields.filter((f) => f.required).map((f) => f.slug))
369
+ .some((key) => (answers[key] ?? '').trim() === '');
370
+ return (_jsxs("div", { className: "flex flex-col gap-section", children: [_jsxs("div", { className: "flex flex-col gap-item-xs", children: [_jsx("h3", { className: "text-base font-semibold tracking-tight", children: t('pageTitle') }), _jsx(Text, { className: "text-sm text-muted-foreground", children: t('pageDescription') })] }), outcome.connected !== undefined && (_jsx(Alert, { children: _jsx(AlertDescription, { children: t('connectedNotice') }) })), outcome.connectionError === 'declined' && (_jsx(Alert, { children: _jsx(AlertDescription, { children: t('declinedNotice') }) })), outcome.connectionError !== undefined && outcome.connectionError !== 'declined' && (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: t('failedNotice') }) })), savedNotice && _jsx(Alert, { children: _jsx(AlertDescription, { children: savedNotice }) }), error && _jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: error }) }), configurationGaps.length > 0 && (_jsx(Alert, { variant: "destructive", children: _jsx(AlertDescription, { children: _jsxs("div", { className: "flex flex-col gap-item-xs", children: [_jsx(Text, { className: "text-sm font-medium", children: t('configurationGapsHeading') }), _jsx(Text, { className: "text-xs", children: t('configurationGapsDescription') }), _jsx("ul", { className: "list-disc pl-4", children: configurationGaps.map((gap) => (_jsxs("li", { className: "text-xs", children: [gap.providerRef, ': ', gap.envVarNames.join(', ')] }, gap.providerRef))) })] }) }) })), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: t('chooseProviderHeading') }) }), _jsxs(CardContent, { className: "flex flex-col gap-group", children: [loading ? (_jsx(Text, { className: "text-sm text-muted-foreground", children: t('loading') })) : providerOptions.length === 0 ? (_jsx(Text, { className: "text-sm text-muted-foreground", children: t('empty') })) : (_jsxs(_Fragment, { children: [_jsx(Select, { name: "providerRef", schema: PROVIDER_REF_SCHEMA, options: providerOptions, value: selectedRef, placeholder: t('choosePlaceholder'), onChange: chooseProvider }), selected !== undefined && (_jsxs("div", { className: "flex flex-col gap-group", children: [missing.length > 0 && (_jsx(Alert, { variant: "destructive", children: _jsxs(AlertDescription, { children: [t('notRegisteredNotice'), ' ', missing.map((slot) => slot.envVarName).join(', ')] }) })), !oauth2 && missing.length === 0 && (_jsx(Text, { className: "text-xs text-muted-foreground", children: t('pastedCredentialNotice') })), delegations.length > 1 && (_jsxs("div", { className: "flex flex-col gap-item-xs", children: [_jsx(Text, { className: "text-sm font-medium", children: t('delegationHeading') }), _jsx(Text, { className: "text-xs text-muted-foreground", children: t('delegationDescription') }), _jsx(Select, { name: "delegation", schema: DELEGATION_SCHEMA, options: delegations.map((mode) => ({
371
+ value: mode,
372
+ label: t(mode === ExternalProvider_Connection_DelegationMode.SERVICE
373
+ ? 'delegationServiceOption'
374
+ : 'delegationUserOption'),
375
+ })), value: delegationChoice, placeholder: t('delegationPlaceholder'), onChange: setDelegationChoice })] })), oauth2 && scopes.length > 0 && (_jsxs(Text, { className: "text-xs text-muted-foreground", children: [t('scopesPrefix'), ' ', scopes.join(', ')] })), (oauth2 ? parameters.length > 0 : credentialFields.length > 0) && (_jsxs("div", { className: "flex flex-col gap-item-xs", children: [_jsx(Text, { className: "text-sm font-medium", children: t('parametersHeading') }), _jsx(Text, { className: "text-xs text-muted-foreground", children: t('parametersDescription') })] })), (oauth2
376
+ ? parameters.map((name) => ({ key: name, label: name, description: undefined }))
377
+ : credentialFields.map((field) => ({ key: field.slug, label: field.slug, description: field.description }))).map((field) => (_jsxs("div", { className: "flex flex-col gap-item-xs", children: [_jsx(Text, { className: "text-sm font-medium", children: field.label }), field.description !== undefined && (_jsx(Text, { className: "text-xs text-muted-foreground", children: field.description })), _jsx(InputRoot, { "aria-label": field.label,
378
+ // A credential is typed into a password field; a connection parameter — which
379
+ // cloud, which directory — is not a secret and is shown.
380
+ type: oauth2 ? 'text' : 'password', value: answers[field.key] ?? '', onChange: (event) => setAnswers((previous) => ({ ...previous, [field.key]: event.target.value })) })] }, field.key))), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { size: ButtonSize.SM, variant: ButtonVariants_Variant.DEFAULT, disabled: busy
381
+ || ownerId === null
382
+ || selected.notConnectableBecause === ProviderConnectabilityBlocker.APPLICATION_NOT_REGISTERED
383
+ || unanswered
384
+ || delegation === undefined, onClick: handleConnect, children: busy ? t('connectingButton') : t('connectButton') }) })] }))] })), _jsx(Text, { className: "text-xs text-muted-foreground", children: t('disconnectNotice') })] })] })] }));
183
385
  };
184
386
  //# sourceMappingURL=AppPage_ConnectedAccountsSettings.js.map