@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.
- package/dist/esm/companion-exports.d.ts +1 -1
- package/dist/esm/companion-exports.d.ts.map +1 -1
- package/dist/esm/companion-exports.js +1 -1
- package/dist/esm/companion-exports.js.map +1 -1
- package/dist/esm/components/ai-elements/decision/approval.d.ts +13 -1
- package/dist/esm/components/ai-elements/decision/approval.d.ts.map +1 -1
- package/dist/esm/components/ai-elements/decision/approval.js.map +1 -1
- package/dist/esm/components/flows-actors/approval.d.ts.map +1 -1
- package/dist/esm/components/flows-actors/approval.js +12 -7
- package/dist/esm/components/flows-actors/approval.js.map +1 -1
- package/dist/esm/components/flows-actors/approval.labels.d.ts.map +1 -1
- package/dist/esm/components/flows-actors/approval.labels.js +3 -0
- package/dist/esm/components/flows-actors/approval.labels.js.map +1 -1
- package/dist/esm/components/flows-actors/execution-plan.js +1 -1
- package/dist/esm/components/flows-actors/execution-plan.js.map +1 -1
- package/dist/esm/components/flows-actors/task-memory.js +1 -1
- package/dist/esm/components/flows-actors/task-memory.js.map +1 -1
- package/dist/esm/components/form/automated/generated-relationship-field.hooks.d.ts +1 -1
- package/dist/esm/components/form/controls/number/slider.d.ts +1 -1
- package/dist/esm/components/form/fields/form-field-file-provider-constraints.frontend.utils.d.ts +1 -1
- package/dist/esm/components/form/fields/form-field-file-provider-constraints.frontend.utils.d.ts.map +1 -1
- package/dist/esm/components/form/fields/form-field-file-provider-constraints.frontend.utils.js.map +1 -1
- package/dist/esm/components/form/fields/form-field-file.d.ts.map +1 -1
- package/dist/esm/components/form/fields/form-field-file.js +66 -17
- package/dist/esm/components/form/fields/form-field-file.js.map +1 -1
- package/dist/esm/components/form/fields/form-field-file.labels.js +2 -2
- package/dist/esm/components/form/fields/form-field-file.labels.js.map +1 -1
- package/dist/esm/components/form/form-field-types.d.ts +20 -0
- package/dist/esm/components/form/form-field-types.d.ts.map +1 -1
- package/dist/esm/components/form/form-field-types.js.map +1 -1
- package/dist/esm/components/navigation/NavigationItemBadge.d.ts +7 -1
- package/dist/esm/components/navigation/NavigationItemBadge.d.ts.map +1 -1
- package/dist/esm/components/navigation/NavigationItemBadge.js +29 -1
- package/dist/esm/components/navigation/NavigationItemBadge.js.map +1 -1
- package/dist/esm/components/navigation/menu-bar.d.ts +1 -1
- package/dist/esm/components/overlays/containers/OverlayContainer.d.ts.map +1 -1
- package/dist/esm/components/overlays/containers/OverlayContainer.js +36 -9
- package/dist/esm/components/overlays/containers/OverlayContainer.js.map +1 -1
- package/dist/esm/components/overlays/floating/context-menu.d.ts +1 -1
- package/dist/esm/components/overlays/floating/dropdown-menu.d.ts +1 -1
- package/dist/esm/components/resources/ResourceLayout_FilterChips.js +1 -1
- package/dist/esm/components/resources/ResourceLayout_FilterChips.js.map +1 -1
- package/dist/esm/components/resources/ResourceListItemRenderer.js +1 -1
- package/dist/esm/components/resources/ResourceListItemRenderer.js.map +1 -1
- package/dist/esm/components/resources/skeleton/ResourceHeaderTitle.d.ts +12 -0
- package/dist/esm/components/resources/skeleton/ResourceHeaderTitle.d.ts.map +1 -1
- package/dist/esm/components/resources/skeleton/ResourceHeaderTitle.js +13 -1
- package/dist/esm/components/resources/skeleton/ResourceHeaderTitle.js.map +1 -1
- package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.d.ts +106 -49
- package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.d.ts.map +1 -1
- package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.js +311 -109
- package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.js.map +1 -1
- package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.labels.d.ts.map +1 -1
- package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.labels.js +32 -2
- package/dist/esm/components/settings/AppPage_ConnectedAccountsSettings.labels.js.map +1 -1
- package/dist/esm/components/settings/SettingsHub.d.ts.map +1 -1
- package/dist/esm/components/settings/SettingsHub.js +30 -6
- package/dist/esm/components/settings/SettingsHub.js.map +1 -1
- package/dist/esm/components/views/onboarding-view/OnboardingView.d.ts.map +1 -1
- package/dist/esm/components/views/onboarding-view/OnboardingView.js +9 -2
- package/dist/esm/components/views/onboarding-view/OnboardingView.js.map +1 -1
- package/dist/esm/core/automated-layout/automated-layout.frontend.schemas.d.ts +16 -0
- package/dist/esm/core/automated-layout/automated-layout.frontend.schemas.d.ts.map +1 -1
- package/dist/esm/core/automated-layout/automated-layout.frontend.schemas.js +17 -0
- package/dist/esm/core/automated-layout/automated-layout.frontend.schemas.js.map +1 -1
- package/dist/esm/core/automated-layout/default-layout-generator.frontend.utils.d.ts.map +1 -1
- package/dist/esm/core/automated-layout/default-layout-generator.frontend.utils.js +3 -2
- package/dist/esm/core/automated-layout/default-layout-generator.frontend.utils.js.map +1 -1
- package/dist/esm/core/billing/billing-launcher.js +1 -1
- package/dist/esm/core/billing/billing-launcher.js.map +1 -1
- package/dist/esm/core/components-registry/provider-component-registry.frontend.service.d.ts +64 -4
- package/dist/esm/core/components-registry/provider-component-registry.frontend.service.d.ts.map +1 -1
- package/dist/esm/core/components-registry/provider-component-registry.frontend.service.js +76 -4
- package/dist/esm/core/components-registry/provider-component-registry.frontend.service.js.map +1 -1
- package/dist/esm/core/components-registry/useProviderComponent.frontend.hooks.d.ts +8 -0
- package/dist/esm/core/components-registry/useProviderComponent.frontend.hooks.d.ts.map +1 -1
- package/dist/esm/core/components-registry/useProviderComponent.frontend.hooks.js +1 -0
- package/dist/esm/core/components-registry/useProviderComponent.frontend.hooks.js.map +1 -1
- package/dist/esm/core/config/chart-view.frontend.schemas.d.ts +8 -8
- package/dist/esm/core/config/composite-view.schemas.d.ts +4 -4
- package/dist/esm/core/config/engine-settings-destinations.d.ts +0 -69
- package/dist/esm/core/config/engine-settings-destinations.d.ts.map +1 -1
- package/dist/esm/core/config/engine-settings-destinations.js +35 -74
- package/dist/esm/core/config/engine-settings-destinations.js.map +1 -1
- package/dist/esm/core/config/settings-hub.frontend.schemas.d.ts +10 -1
- package/dist/esm/core/config/settings-hub.frontend.schemas.d.ts.map +1 -1
- package/dist/esm/core/config/settings-hub.frontend.schemas.js +9 -0
- package/dist/esm/core/config/settings-hub.frontend.schemas.js.map +1 -1
- package/dist/esm/core/config/useSettingsHubConfig.d.ts.map +1 -1
- package/dist/esm/core/config/useSettingsHubConfig.js +8 -1
- package/dist/esm/core/config/useSettingsHubConfig.js.map +1 -1
- package/dist/esm/core/events/events.frontend.schemas.d.ts +4 -4
- package/dist/esm/core/external-providers/ProviderFilePickerHost.d.ts +12 -1
- package/dist/esm/core/external-providers/ProviderFilePickerHost.d.ts.map +1 -1
- package/dist/esm/core/external-providers/ProviderFilePickerHost.js +15 -2
- package/dist/esm/core/external-providers/ProviderFilePickerHost.js.map +1 -1
- package/dist/esm/core/external-providers/provider-file-selection.frontend.utils.d.ts +1 -2
- package/dist/esm/core/external-providers/provider-file-selection.frontend.utils.d.ts.map +1 -1
- package/dist/esm/core/external-providers/provider-file-selection.frontend.utils.js.map +1 -1
- package/dist/esm/core/flows-actors/FlowsActorsContext.d.ts +6 -1
- package/dist/esm/core/flows-actors/FlowsActorsContext.d.ts.map +1 -1
- package/dist/esm/core/flows-actors/FlowsActorsContext.js +10 -2
- package/dist/esm/core/flows-actors/FlowsActorsContext.js.map +1 -1
- package/dist/esm/core/i18n/I18nContext.d.ts +23 -0
- package/dist/esm/core/i18n/I18nContext.d.ts.map +1 -1
- package/dist/esm/core/i18n/I18nContext.js +6 -1
- package/dist/esm/core/i18n/I18nContext.js.map +1 -1
- package/dist/esm/core/requests/wildo-http-client-manual-calls.frontend.service.d.ts +75 -2
- package/dist/esm/core/requests/wildo-http-client-manual-calls.frontend.service.d.ts.map +1 -1
- package/dist/esm/core/requests/wildo-http-client-manual-calls.frontend.service.js +52 -0
- package/dist/esm/core/requests/wildo-http-client-manual-calls.frontend.service.js.map +1 -1
- package/dist/esm/core/requests/wildo-http-client-resources.frontend.service.d.ts +3 -2
- package/dist/esm/core/requests/wildo-http-client-resources.frontend.service.d.ts.map +1 -1
- package/dist/esm/core/requests/wildo-http-client-resources.frontend.service.js +57 -4
- package/dist/esm/core/requests/wildo-http-client-resources.frontend.service.js.map +1 -1
- package/dist/esm/core/resources/ResourceReadCacheBridge.d.ts.map +1 -1
- package/dist/esm/core/resources/ResourceReadCacheBridge.js +130 -37
- package/dist/esm/core/resources/ResourceReadCacheBridge.js.map +1 -1
- package/dist/esm/core/resources/resources.frontend.schemas.d.ts +2 -2
- package/dist/esm/core/runtime/start-application.frontend.service.d.ts.map +1 -1
- package/dist/esm/core/runtime/start-application.frontend.service.js +17 -9
- package/dist/esm/core/runtime/start-application.frontend.service.js.map +1 -1
- package/dist/esm/core/security/UserOrgsContext.d.ts.map +1 -1
- package/dist/esm/core/security/UserOrgsContext.js +61 -5
- package/dist/esm/core/security/UserOrgsContext.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- 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 —
|
|
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
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* ##
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
60
|
+
* The providers connectable AT THIS SCOPE.
|
|
62
61
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
*
|
|
184
|
+
* WHOSE account, resolved for the scope in play.
|
|
99
185
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
351
|
+
setBusy(false);
|
|
159
352
|
}
|
|
160
|
-
}, [answers,
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|