@oxyhq/core 12.10.6 → 12.11.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/oauthPkce.ts +56 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape of an Expo push token: `ExponentPushToken[…]` (what
|
|
3
|
+
* `getExpoPushTokenAsync()` returns today) or the equivalent `ExpoPushToken[…]`
|
|
4
|
+
* spelling. Deliberately plain ASCII — `@oxyhq/core` ships to Hermes, where
|
|
5
|
+
* Unicode property escapes throw at runtime.
|
|
6
|
+
*/
|
|
7
|
+
const EXPO_PUSH_TOKEN_PATTERN = /^Expo(nent)?PushToken\[[^[\]\s]+\]$/;
|
|
8
|
+
export function OxyServicesNotificationsMixin(Base) {
|
|
9
|
+
return class extends Base {
|
|
10
|
+
/**
|
|
11
|
+
* Register this installation's **Expo** push token for the authenticated
|
|
12
|
+
* identity (`POST /notifications/push-token`, bearer required).
|
|
13
|
+
*
|
|
14
|
+
* Idempotent server-side: re-registering the same token for the same
|
|
15
|
+
* identity refreshes the existing row rather than creating a duplicate, so
|
|
16
|
+
* callers can safely re-register on every cold boot.
|
|
17
|
+
*
|
|
18
|
+
* @throws When `expoPushToken` is not an Expo push token — a raw APNs/FCM
|
|
19
|
+
* device token fails here rather than becoming a silently undeliverable
|
|
20
|
+
* registration.
|
|
21
|
+
*/
|
|
22
|
+
async registerPushToken(input) {
|
|
23
|
+
try {
|
|
24
|
+
if (!EXPO_PUSH_TOKEN_PATTERN.test(input.expoPushToken)) {
|
|
25
|
+
throw new Error('registerPushToken expects an Expo push token ("ExponentPushToken[...]", from getExpoPushTokenAsync). ' +
|
|
26
|
+
'A raw APNs/FCM device token from getDevicePushTokenAsync cannot be delivered to.');
|
|
27
|
+
}
|
|
28
|
+
await this.makeRequest('POST', '/notifications/push-token', {
|
|
29
|
+
token: input.expoPushToken,
|
|
30
|
+
platform: input.platform,
|
|
31
|
+
// Omitted entirely when absent so the body stays exactly what the
|
|
32
|
+
// endpoint has always received (the server reads presence).
|
|
33
|
+
...(input.deviceId ? { deviceId: input.deviceId } : {}),
|
|
34
|
+
...(input.clientId ? { clientId: input.clientId } : {}),
|
|
35
|
+
}, { cache: false });
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw this.handleError(error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Retire an **Expo** push token for the authenticated identity
|
|
43
|
+
* (`DELETE /notifications/push-token`, bearer required).
|
|
44
|
+
*
|
|
45
|
+
* Call it when the user turns notifications off, when the vault identity is
|
|
46
|
+
* replaced, and on sign-out — otherwise the installation keeps receiving
|
|
47
|
+
* approval requests for an identity it no longer holds.
|
|
48
|
+
*
|
|
49
|
+
* @param expoPushToken - The exact token previously registered.
|
|
50
|
+
*/
|
|
51
|
+
async unregisterPushToken(expoPushToken) {
|
|
52
|
+
try {
|
|
53
|
+
await this.makeRequest('DELETE', '/notifications/push-token', { token: expoPushToken }, { cache: false });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw this.handleError(error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
package/dist/esm/mixins/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { OxyServicesUtilityMixin } from './OxyServices.utility.js';
|
|
|
24
24
|
import { OxyServicesFeaturesMixin } from './OxyServices.features.js';
|
|
25
25
|
import { OxyServicesTopicsMixin } from './OxyServices.topics.js';
|
|
26
26
|
import { OxyServicesContactsMixin } from './OxyServices.contacts.js';
|
|
27
|
+
import { OxyServicesNotificationsMixin } from './OxyServices.notifications.js';
|
|
27
28
|
import { OxyServicesAppDataMixin } from './OxyServices.appData.js';
|
|
28
29
|
import { OxyServicesCivicMixin } from './OxyServices.civic.js';
|
|
29
30
|
import { OxyServicesNodesMixin } from './OxyServices.nodes.js';
|
|
@@ -71,6 +72,10 @@ const MIXIN_PIPELINE = [
|
|
|
71
72
|
OxyServicesFeaturesMixin,
|
|
72
73
|
OxyServicesTopicsMixin,
|
|
73
74
|
OxyServicesContactsMixin,
|
|
75
|
+
// Push-token registration: the one SDK-owned register/unregister pair every
|
|
76
|
+
// Oxy app uses, and what lets a "Sign in with Oxy" request be delivered to a
|
|
77
|
+
// known Commons installation instead of falling back to a QR.
|
|
78
|
+
OxyServicesNotificationsMixin,
|
|
74
79
|
OxyServicesAppDataMixin,
|
|
75
80
|
// Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
|
|
76
81
|
OxyServicesCivicMixin,
|
|
@@ -30,6 +30,15 @@ export class SessionClient {
|
|
|
30
30
|
getState() {
|
|
31
31
|
return this.state;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* The account this client's bearer is pinned to, or `null` when it follows the
|
|
35
|
+
* device's active account (the default). Resolvers are expected to be a plain
|
|
36
|
+
* synchronous read of already-resolved state (see
|
|
37
|
+
* {@link SessionClientOptions.getPinnedAccountId}).
|
|
38
|
+
*/
|
|
39
|
+
pinnedAccountId() {
|
|
40
|
+
return this.options.getPinnedAccountId?.() ?? null;
|
|
41
|
+
}
|
|
33
42
|
subscribe(listener) {
|
|
34
43
|
this.listeners.add(listener);
|
|
35
44
|
return () => {
|
|
@@ -118,15 +127,26 @@ export class SessionClient {
|
|
|
118
127
|
}
|
|
119
128
|
const previousState = this.state;
|
|
120
129
|
this.state = next;
|
|
130
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
121
131
|
// Plant the sync-supplied active token (it is for `next.activeAccountId`)
|
|
122
132
|
// now — before the notify below — so the bearer matches the new active
|
|
123
133
|
// account when subscribers observe it. Guarded on difference to avoid a
|
|
124
134
|
// redundant token-change notification on an unchanged token (bootstrap
|
|
125
|
-
// restate).
|
|
126
|
-
|
|
135
|
+
// restate). An identity-bound client only accepts it when the active account
|
|
136
|
+
// IS its pinned account — otherwise the token belongs to somebody else's
|
|
137
|
+
// switch and must never displace the pinned bearer.
|
|
138
|
+
if (activeToken &&
|
|
139
|
+
next.activeAccountId !== null &&
|
|
140
|
+
(pinnedAccountId === null || next.activeAccountId === pinnedAccountId) &&
|
|
141
|
+
activeToken !== this.host.getAccessToken()) {
|
|
127
142
|
this.host.setTokens(activeToken);
|
|
128
143
|
}
|
|
129
|
-
|
|
144
|
+
// The transport's entire job is converging the bearer on `activeAccountId`,
|
|
145
|
+
// which is precisely what an identity-bound client must not do: its token is
|
|
146
|
+
// minted for the pinned account by the cold boot / re-mint lane. Bypass it
|
|
147
|
+
// while pinned (also removing the mint-before-notify gate, which exists only
|
|
148
|
+
// to keep the bearer and the ACTIVE account in step).
|
|
149
|
+
const transport = pinnedAccountId === null ? this.options.transport : null;
|
|
130
150
|
const activeAccountId = next.activeAccountId;
|
|
131
151
|
// Mint before notifying when the bearer does not already belong to the new
|
|
132
152
|
// active account: no bearer at all, an opaque bearer, OR a bearer for a
|
|
@@ -198,12 +218,15 @@ export class SessionClient {
|
|
|
198
218
|
// socket push): `applyState` no-ops without planting, but the token still
|
|
199
219
|
// needs planting. Guard on the sync's active account STILL being the current
|
|
200
220
|
// active account so a stale response cannot adopt a token for an account a
|
|
201
|
-
// newer state already switched away from
|
|
221
|
+
// newer state already switched away from — and, when pinned, on that account
|
|
222
|
+
// being the PINNED one (same rule as the applied path in `applyState`).
|
|
223
|
+
const pinnedAccountId = this.pinnedAccountId();
|
|
202
224
|
if (!applied &&
|
|
203
225
|
sync.activeToken &&
|
|
204
226
|
this.state &&
|
|
205
227
|
sync.state.activeAccountId !== null &&
|
|
206
228
|
sync.state.activeAccountId === this.state.activeAccountId &&
|
|
229
|
+
(pinnedAccountId === null || sync.state.activeAccountId === pinnedAccountId) &&
|
|
207
230
|
sync.activeToken.accessToken !== this.host.getAccessToken()) {
|
|
208
231
|
this.host.setTokens(sync.activeToken.accessToken);
|
|
209
232
|
}
|
|
@@ -237,12 +260,19 @@ export class SessionClient {
|
|
|
237
260
|
* sign-in. This adds, then switches to the target so the UI lands on the
|
|
238
261
|
* account the user just authenticated.
|
|
239
262
|
*
|
|
263
|
+
* An identity-bound (pinned) client only ADDS: its own session is minted for
|
|
264
|
+
* the pinned account explicitly, so switching the device would gratuitously
|
|
265
|
+
* re-elect the active account under every OTHER app on this device — a
|
|
266
|
+
* mutation a pinned client must never make.
|
|
267
|
+
*
|
|
240
268
|
* @param accountId - The signed-in account id (e.g. `session.user.id`). When
|
|
241
269
|
* omitted, falls back to the host's current-account ref. If neither
|
|
242
270
|
* resolves, the add still applies and no switch is performed.
|
|
243
271
|
*/
|
|
244
272
|
async registerAndActivate(accountId) {
|
|
245
273
|
await this.addCurrentAccount();
|
|
274
|
+
if (this.pinnedAccountId() !== null)
|
|
275
|
+
return;
|
|
246
276
|
const target = accountId ?? this.host.getCurrentAccountId();
|
|
247
277
|
if (target && this.state?.activeAccountId !== target) {
|
|
248
278
|
await this.switchAccount(target);
|
|
@@ -346,6 +376,12 @@ export class SessionClient {
|
|
|
346
376
|
const applied = this.applyState(payload, 'push');
|
|
347
377
|
if (!applied)
|
|
348
378
|
return;
|
|
379
|
+
// An identity-bound client tracks the pushed state but NEVER chases the
|
|
380
|
+
// device's active account: re-fetching here would only pull an
|
|
381
|
+
// `activeToken` for somebody else's switch, which the plant guards would
|
|
382
|
+
// then discard. Skip the round-trip entirely.
|
|
383
|
+
if (this.pinnedAccountId() !== null)
|
|
384
|
+
return;
|
|
349
385
|
// A push changed the active account on another device/tab — re-fetch state
|
|
350
386
|
// to plant the access token for the newly-active account. When this tab is
|
|
351
387
|
// still signed out, applyState mints via ensureActiveToken first; bootstrap
|
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
* - the "Sign in with Oxy" device flow (same-device shared-keychain via
|
|
22
22
|
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
23
23
|
* via `startCommonsSignIn` → poll → `claimSessionByToken`);
|
|
24
|
+
* - AUTOMATIC delivery selection for that flow (issue #691): the user presses
|
|
25
|
+
* ONE primary action and the controller — not the user — picks how the
|
|
26
|
+
* request reaches their Commons identity, by gathering the facts
|
|
27
|
+
* (`platform`, `commonsAvailability`, and the `targets` a bearer-authorized
|
|
28
|
+
* `deliverCommonsSignIn` reached) and handing them to the pure
|
|
29
|
+
* `selectCommonsDelivery`. Exactly one route is primary; alternatives are
|
|
30
|
+
* never chained behind it, they are state (`signIn.route`,
|
|
31
|
+
* `signIn.routeFailed`) the UI reveals on its own terms;
|
|
32
|
+
* - honest, non-sensitive PROGRESS for that flow (`signIn.progress`), derived
|
|
33
|
+
* only from real signals — the chosen route, `pushSentAt`, `openedAt`,
|
|
34
|
+
* `authorized`, and the commit — never from an optimistic timeline;
|
|
24
35
|
* - `commonsAvailability` — whether Commons is installed on this device
|
|
25
36
|
* (native only, via the injected `canOpenApp` probe), so the QR view can
|
|
26
37
|
* offer a "Get Commons" fallback instead of a same-device dead end.
|
|
@@ -47,6 +58,37 @@ import { logger } from '../logger/index.js';
|
|
|
47
58
|
import { extractErrorStatus } from '../utils/errorUtils.js';
|
|
48
59
|
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
|
|
49
60
|
import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
|
|
61
|
+
import { pushTargetsFromDelivery, selectCommonsDelivery, } from '../utils/commonsDelivery.js';
|
|
62
|
+
/**
|
|
63
|
+
* Derive the surface-facing progress from the flow's real facts. Pure, total,
|
|
64
|
+
* and the single place the ladder is defined.
|
|
65
|
+
*
|
|
66
|
+
* Ordering within `'waiting'` is most-specific-first, so a late-arriving weaker
|
|
67
|
+
* signal can never pull the display backwards.
|
|
68
|
+
*/
|
|
69
|
+
function deriveSignInProgress(facts) {
|
|
70
|
+
switch (facts.phase) {
|
|
71
|
+
case 'idle':
|
|
72
|
+
case 'error':
|
|
73
|
+
return 'idle';
|
|
74
|
+
case 'starting':
|
|
75
|
+
return 'preparing';
|
|
76
|
+
case 'authorized':
|
|
77
|
+
return 'confirming-identity';
|
|
78
|
+
case 'completed':
|
|
79
|
+
return 'identity-confirmed';
|
|
80
|
+
case 'waiting':
|
|
81
|
+
if (facts.openedAt !== null)
|
|
82
|
+
return 'opened-in-commons';
|
|
83
|
+
// `route === 'await-push'` is itself a server-confirmed dispatch (the
|
|
84
|
+
// route is only chosen when `deliverCommonsSignIn` reported ≥1 target),
|
|
85
|
+
// so it is a real signal — not an optimistic assumption that a push will
|
|
86
|
+
// arrive. `pushSentAt` is the same fact re-confirmed by the status poll.
|
|
87
|
+
if (facts.pushSentAt !== null || facts.route === 'await-push')
|
|
88
|
+
return 'delivered-to-commons';
|
|
89
|
+
return facts.route === null ? 'preparing' : 'awaiting-approval';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
50
92
|
/**
|
|
51
93
|
* Slow FALLBACK poll cadence for the QR flow. The `/auth-session` socket delivers
|
|
52
94
|
* the approval instantly via `auth_update`; this poll only covers the case where
|
|
@@ -62,12 +104,30 @@ const AUTH_SESSION_NAMESPACE = '/auth-session';
|
|
|
62
104
|
* itself is the flow's `qrPayload`.
|
|
63
105
|
*/
|
|
64
106
|
const COMMONS_APP_SCHEME = 'oxycommons://';
|
|
65
|
-
const
|
|
107
|
+
const IDLE_SIGN_IN_FACTS = {
|
|
66
108
|
phase: 'idle',
|
|
67
109
|
authorizeCode: null,
|
|
68
110
|
qrPayload: null,
|
|
69
111
|
expiresAt: null,
|
|
70
112
|
error: null,
|
|
113
|
+
route: null,
|
|
114
|
+
routeFailed: false,
|
|
115
|
+
pushSentAt: null,
|
|
116
|
+
openedAt: null,
|
|
117
|
+
};
|
|
118
|
+
const IDLE_SIGN_IN = {
|
|
119
|
+
...IDLE_SIGN_IN_FACTS,
|
|
120
|
+
progress: deriveSignInProgress(IDLE_SIGN_IN_FACTS),
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Terminal SUCCESS state: the session was claimed and committed. Holds no live
|
|
124
|
+
* resources and no request handles — only the terminal progress the surface
|
|
125
|
+
* shows ("Identity confirmed") before it closes.
|
|
126
|
+
*/
|
|
127
|
+
const COMPLETED_SIGN_IN = {
|
|
128
|
+
...IDLE_SIGN_IN_FACTS,
|
|
129
|
+
phase: 'completed',
|
|
130
|
+
progress: deriveSignInProgress({ ...IDLE_SIGN_IN_FACTS, phase: 'completed' }),
|
|
71
131
|
};
|
|
72
132
|
function errorMessage(error) {
|
|
73
133
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -124,6 +184,7 @@ export class AccountDialogController {
|
|
|
124
184
|
this.socketFactory = options.socketFactory;
|
|
125
185
|
this.openPopup = options.openPopup;
|
|
126
186
|
this.hubBaseUrl = options.hubBaseUrl ?? `https://auth.${CENTRAL_IDP_APEX}`;
|
|
187
|
+
this.platform = options.platform ?? 'unknown';
|
|
127
188
|
this.snapshot = this.computeSnapshot();
|
|
128
189
|
}
|
|
129
190
|
// =========================================================================
|
|
@@ -253,6 +314,13 @@ export class AccountDialogController {
|
|
|
253
314
|
if (this.view === view)
|
|
254
315
|
return;
|
|
255
316
|
this.view = view;
|
|
317
|
+
// A `'completed'` flow owns no timers, socket, popup, or token — it is only
|
|
318
|
+
// the terminal "Identity confirmed" the finished surface showed. Moving to
|
|
319
|
+
// another view is a NEW intention, so drop it; otherwise a later `add()`
|
|
320
|
+
// would open on the previous sign-in's terminal state.
|
|
321
|
+
if (this.signIn.phase === 'completed') {
|
|
322
|
+
this.signIn = IDLE_SIGN_IN;
|
|
323
|
+
}
|
|
256
324
|
this.emit();
|
|
257
325
|
}
|
|
258
326
|
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
@@ -436,7 +504,7 @@ export class AccountDialogController {
|
|
|
436
504
|
*/
|
|
437
505
|
async signInWithOxy() {
|
|
438
506
|
this.setView('qr');
|
|
439
|
-
this.setSignIn({ ...
|
|
507
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
|
|
440
508
|
try {
|
|
441
509
|
const session = await this.oxyServices.signInWithSharedIdentity();
|
|
442
510
|
if (session) {
|
|
@@ -460,7 +528,7 @@ export class AccountDialogController {
|
|
|
460
528
|
async showQr() {
|
|
461
529
|
this.cancelSignIn();
|
|
462
530
|
this.setView('qr');
|
|
463
|
-
await this.startDeviceFlowSession();
|
|
531
|
+
await this.startDeviceFlowSession({ deliver: true });
|
|
464
532
|
}
|
|
465
533
|
/**
|
|
466
534
|
* Web-only: "Sign in with a passkey" on a non-Oxy origin cannot run the
|
|
@@ -486,10 +554,16 @@ export class AccountDialogController {
|
|
|
486
554
|
this.setView('qr');
|
|
487
555
|
const popup = this.openPopup?.() ?? null;
|
|
488
556
|
if (!popup) {
|
|
489
|
-
await this.startDeviceFlowSession();
|
|
557
|
+
await this.startDeviceFlowSession({ deliver: true });
|
|
490
558
|
return;
|
|
491
559
|
}
|
|
492
|
-
|
|
560
|
+
// The hub popup IS the primary surface here, chosen explicitly by the user —
|
|
561
|
+
// so this flow does NOT run automatic Commons delivery (ringing the user's
|
|
562
|
+
// phone because they asked for a passkey would be exactly the "menu of
|
|
563
|
+
// methods" the one-primary-action rule forbids). The underlying request is
|
|
564
|
+
// the same `AuthSession`, and its Commons route stays the QR the view
|
|
565
|
+
// renders beneath the popup.
|
|
566
|
+
const handle = await this.startDeviceFlowSession({ deliver: false });
|
|
493
567
|
if (!handle) {
|
|
494
568
|
popup.close();
|
|
495
569
|
return;
|
|
@@ -511,42 +585,148 @@ export class AccountDialogController {
|
|
|
511
585
|
* same `startCommonsSignIn` → poll/socket wiring either way. Returns the
|
|
512
586
|
* handle on success (already reflected in `signIn`), or `null` on failure
|
|
513
587
|
* (already set as `signIn.error`).
|
|
588
|
+
*
|
|
589
|
+
* @param opts.deliver - Whether to run automatic Commons delivery selection
|
|
590
|
+
* ({@link resolveDeliveryRoute}). `true` for the normal one-primary-action
|
|
591
|
+
* entry; `false` when the caller already owns the primary surface (the
|
|
592
|
+
* passkey hub popup), where the request's Commons route is simply the QR.
|
|
514
593
|
*/
|
|
515
|
-
async startDeviceFlowSession() {
|
|
594
|
+
async startDeviceFlowSession(opts) {
|
|
516
595
|
if (!this.clientId) {
|
|
517
|
-
this.setSignIn({ ...
|
|
596
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: 'This app is not configured for sign-in (missing clientId).' });
|
|
518
597
|
return null;
|
|
519
598
|
}
|
|
520
|
-
this.setSignIn({ ...
|
|
599
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'starting' });
|
|
521
600
|
try {
|
|
522
601
|
const handle = await this.oxyServices.startCommonsSignIn({ clientId: this.clientId });
|
|
523
602
|
this.signInToken = handle.sessionToken;
|
|
524
603
|
this.setSignIn({
|
|
604
|
+
...IDLE_SIGN_IN_FACTS,
|
|
525
605
|
phase: 'waiting',
|
|
526
606
|
authorizeCode: handle.authorizeCode,
|
|
527
607
|
qrPayload: handle.qrPayload,
|
|
528
608
|
expiresAt: handle.expiresAt,
|
|
529
|
-
|
|
609
|
+
// No route yet: the surface shows "Preparing request" until the primary
|
|
610
|
+
// route is resolved below. It is never guessed in the meantime.
|
|
611
|
+
route: opts.deliver ? null : 'qr',
|
|
530
612
|
});
|
|
531
613
|
// Primary path: an instant `auth_update` wake over the `/auth-session`
|
|
532
614
|
// socket. The poll below is only the fallback for when the socket can't
|
|
533
615
|
// connect, so it now runs at the slow fallback cadence.
|
|
534
616
|
this.openAuthSessionSocket(handle.sessionToken);
|
|
535
617
|
this.scheduleNextPoll(handle.sessionToken);
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
void this.deepLinkIntoCommonsIfAvailable(handle.qrPayload);
|
|
618
|
+
if (opts.deliver) {
|
|
619
|
+
// Non-blocking on purpose: the QR/authorizeCode are already renderable
|
|
620
|
+
// and the popup caller can navigate immediately, while the route (a
|
|
621
|
+
// local probe plus at most one delivery round-trip) resolves behind it.
|
|
622
|
+
void this.resolveDeliveryRoute(handle);
|
|
623
|
+
}
|
|
543
624
|
return handle;
|
|
544
625
|
}
|
|
545
626
|
catch (error) {
|
|
546
|
-
this.setSignIn({ ...
|
|
627
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: errorMessage(error) });
|
|
547
628
|
return null;
|
|
548
629
|
}
|
|
549
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Choose and carry out the ONE primary delivery route for the active request
|
|
633
|
+
* (issue #691, "Automatic delivery selection").
|
|
634
|
+
*
|
|
635
|
+
* The controller gathers the facts — is a verified Commons link openable on
|
|
636
|
+
* THIS device, and how many known Commons installations did Oxy actually push
|
|
637
|
+
* to — and hands them to the pure {@link selectCommonsDelivery}. The selector
|
|
638
|
+
* owns the decision; this method owns only the observations and the single
|
|
639
|
+
* action the chosen route implies. It never cascades: a route that yields
|
|
640
|
+
* nothing resolves to QR *before* a route is chosen, never after.
|
|
641
|
+
*/
|
|
642
|
+
async resolveDeliveryRoute(handle) {
|
|
643
|
+
if (!this.isAwaitingApproval(handle.sessionToken))
|
|
644
|
+
return;
|
|
645
|
+
// Fact 1 — a VERIFIED Commons link openable on this very device. Native
|
|
646
|
+
// only; on web `commonsAvailability` stays `'unknown'` (a browser cannot be
|
|
647
|
+
// asked whether a custom scheme is registered) and this is simply `false`.
|
|
648
|
+
if (this.canOpenApp &&
|
|
649
|
+
(this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking')) {
|
|
650
|
+
await this.resolveCommonsAvailability();
|
|
651
|
+
if (!this.isAwaitingApproval(handle.sessionToken))
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const commonsAvailable = this.commonsAvailability === 'available';
|
|
655
|
+
// Fact 2 — how many known Commons installations the server pushed to.
|
|
656
|
+
const pushTargets = await this.deliverToKnownCommons(handle.authorizeCode, commonsAvailable);
|
|
657
|
+
if (!this.isAwaitingApproval(handle.sessionToken))
|
|
658
|
+
return;
|
|
659
|
+
const route = selectCommonsDelivery({ platform: this.platform, commonsAvailable, pushTargets });
|
|
660
|
+
// The only route that has an action to perform on this device — and the only
|
|
661
|
+
// one that can fail here. `'await-push'` was already dispatched server-side;
|
|
662
|
+
// `'qr'` is rendered by the surface from `qrPayload`.
|
|
663
|
+
const routeFailed = route === 'open-commons' ? !this.openCommonsLink(handle.qrPayload) : false;
|
|
664
|
+
this.patchSignIn({ route, routeFailed });
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Whether `sessionToken` is still THE request this surface is waiting on.
|
|
668
|
+
*
|
|
669
|
+
* Guards every step of the asynchronous route resolution: a cancelled,
|
|
670
|
+
* superseded, failed, or already-approved flow must neither open Commons nor
|
|
671
|
+
* mutate the surface — an approval that lands mid-resolution would otherwise
|
|
672
|
+
* be followed by a pointless app switch.
|
|
673
|
+
*/
|
|
674
|
+
isAwaitingApproval(sessionToken) {
|
|
675
|
+
return this.signInToken === sessionToken && this.signIn.phase === 'waiting';
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Ask Oxy to deliver the pending request to the identity's known Commons
|
|
679
|
+
* installations, returning how many it reached (`0` when delivery is not
|
|
680
|
+
* applicable, not permitted, or reached nobody).
|
|
681
|
+
*
|
|
682
|
+
* Two hard rules, both from the issue:
|
|
683
|
+
* - **Never push from an unauthenticated surface.** `deliverCommonsSignIn`
|
|
684
|
+
* is bearer-required precisely because a request that merely carries a
|
|
685
|
+
* typed-in username must never be able to ring somebody's phone. Without a
|
|
686
|
+
* planted bearer the call is not made AT ALL — not made-and-failed.
|
|
687
|
+
* - **Zero targets and a failed delivery are the same normal outcome.** Both
|
|
688
|
+
* return `0`, which resolves the primary route to QR silently. Neither is
|
|
689
|
+
* surfaced as an error: there is nothing the user did wrong and nothing for
|
|
690
|
+
* them to fix.
|
|
691
|
+
*/
|
|
692
|
+
async deliverToKnownCommons(authorizeCode, commonsAvailable) {
|
|
693
|
+
// Route 1 (mobile with a verified local Commons link) reaches the identity
|
|
694
|
+
// on this very device — pushing as well would notify a second surface for a
|
|
695
|
+
// request the user is about to confirm here.
|
|
696
|
+
if (this.platform === 'mobile' && commonsAvailable)
|
|
697
|
+
return 0;
|
|
698
|
+
if (!this.isAuthenticated())
|
|
699
|
+
return 0;
|
|
700
|
+
try {
|
|
701
|
+
const result = await this.oxyServices.deliverCommonsSignIn(authorizeCode);
|
|
702
|
+
// `delivered: false` with `targets > 0` is a transport failure, not a
|
|
703
|
+
// reachable install — the shared helper owns that rule so the surface
|
|
704
|
+
// can never park a user on "check your phone" for a push that failed.
|
|
705
|
+
return pushTargetsFromDelivery(result);
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
logger.debug('[AccountDialogController] Commons delivery unavailable (QR route)', { component: 'AccountDialogController' }, error);
|
|
709
|
+
return 0;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Open the verified Commons link for the `'open-commons'` route. Returns
|
|
714
|
+
* whether the navigation was actually handed off, so a missing opener or a
|
|
715
|
+
* throwing one becomes an observable `routeFailed` instead of a silent dead
|
|
716
|
+
* end the user stares at.
|
|
717
|
+
*/
|
|
718
|
+
openCommonsLink(qrPayload) {
|
|
719
|
+
if (!this.openUrl)
|
|
720
|
+
return false;
|
|
721
|
+
try {
|
|
722
|
+
this.openUrl(qrPayload);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
logger.debug('[AccountDialogController] Commons deep link failed', { component: 'AccountDialogController' }, error);
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
550
730
|
/**
|
|
551
731
|
* Poll {@link PopupWindowHandle.closed} so a user who dismisses the hub
|
|
552
732
|
* popup without completing sign-in gets prompt feedback (there is no DOM
|
|
@@ -561,7 +741,12 @@ export class AccountDialogController {
|
|
|
561
741
|
return;
|
|
562
742
|
this.clearPopupWatchTimer();
|
|
563
743
|
if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
|
|
744
|
+
// Closing the surface cancels the REQUEST too, not just this listener.
|
|
745
|
+
const pendingCode = this.signIn.authorizeCode;
|
|
564
746
|
this.failSignIn('Sign-in was cancelled.');
|
|
747
|
+
if (pendingCode) {
|
|
748
|
+
void this.withdrawRequest(pendingCode);
|
|
749
|
+
}
|
|
565
750
|
}
|
|
566
751
|
}, 1000);
|
|
567
752
|
}
|
|
@@ -614,32 +799,43 @@ export class AccountDialogController {
|
|
|
614
799
|
this.emit();
|
|
615
800
|
}
|
|
616
801
|
/**
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
802
|
+
* Tear down the active sign-in device flow (timers + socket + popup + token),
|
|
803
|
+
* WITHDRAW the request server-side, and reset to idle.
|
|
804
|
+
*
|
|
805
|
+
* Cancellation has to converge in both directions: the surface closing must
|
|
806
|
+
* cancel the request, not just stop listening to it. Without the withdrawal a
|
|
807
|
+
* dismissed QR would stay approvable until it expired, so a later scan of a
|
|
808
|
+
* stale code could authorize a session nobody is waiting for.
|
|
621
809
|
*/
|
|
622
|
-
async deepLinkIntoCommonsIfAvailable(qrPayload) {
|
|
623
|
-
if (!this.openUrl)
|
|
624
|
-
return;
|
|
625
|
-
if (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking') {
|
|
626
|
-
// The eager `start()` probe hasn't resolved yet (or was never run, e.g.
|
|
627
|
-
// `showQr` called without a prior `start()`) — resolve it now rather
|
|
628
|
-
// than skipping the deep link.
|
|
629
|
-
await this.resolveCommonsAvailability();
|
|
630
|
-
}
|
|
631
|
-
if (this.commonsAvailability === 'available') {
|
|
632
|
-
this.openUrl(qrPayload);
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
/** Tear down the active sign-in device flow (timers + socket + popup + token) and reset to idle. */
|
|
636
810
|
cancelSignIn() {
|
|
811
|
+
// Capture before the teardown clears it, and only for a request that can
|
|
812
|
+
// still be approved — a completed/failed flow has nothing to withdraw.
|
|
813
|
+
const pendingCode = this.signIn.phase === 'starting' || this.signIn.phase === 'waiting'
|
|
814
|
+
? this.signIn.authorizeCode
|
|
815
|
+
: null;
|
|
637
816
|
this.clearPollTimer();
|
|
638
817
|
this.closeAuthSessionSocket();
|
|
639
818
|
this.closeActivePopup();
|
|
640
819
|
this.signInToken = null;
|
|
641
820
|
if (this.signIn !== IDLE_SIGN_IN) {
|
|
642
|
-
this.setSignIn(
|
|
821
|
+
this.setSignIn(IDLE_SIGN_IN_FACTS);
|
|
822
|
+
}
|
|
823
|
+
if (pendingCode) {
|
|
824
|
+
void this.withdrawRequest(pendingCode);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Best-effort server-side withdrawal of a request this surface abandoned
|
|
829
|
+
* (`POST /auth/session/deny/:authorizeCode`). Fire-and-forget by design: the
|
|
830
|
+
* local teardown already happened, and a race with an approval that just
|
|
831
|
+
* landed legitimately rejects here — neither outcome is worth surfacing.
|
|
832
|
+
*/
|
|
833
|
+
async withdrawRequest(authorizeCode) {
|
|
834
|
+
try {
|
|
835
|
+
await this.oxyServices.denyCommonsSignIn(authorizeCode);
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
logger.debug('[AccountDialogController] request withdrawal failed', { component: 'AccountDialogController' }, error);
|
|
643
839
|
}
|
|
644
840
|
}
|
|
645
841
|
// =========================================================================
|
|
@@ -673,6 +869,18 @@ export class AccountDialogController {
|
|
|
673
869
|
const status = await this.oxyServices.pollCommonsSignIn(sessionToken);
|
|
674
870
|
if (this.signInToken !== sessionToken)
|
|
675
871
|
return; // cancelled mid-request
|
|
872
|
+
// Delivery PROGRESS first: it is reported alongside every status, and
|
|
873
|
+
// recording it before the terminal branches means a poll that also
|
|
874
|
+
// carries the approval still leaves an honest trail behind it.
|
|
875
|
+
this.recordDeliveryProgress(status.pushSentAt, status.openedAt);
|
|
876
|
+
const purpose = status.purpose === 'oauth_authorization' ? 'oauth_authorization' : 'device_sign_in';
|
|
877
|
+
if (status.authorized && purpose === 'oauth_authorization') {
|
|
878
|
+
// OAuth-bound sessions mint no sessionId on approval — they finalize
|
|
879
|
+
// into an authorization code. The account dialog only starts device
|
|
880
|
+
// sign-in today; stop rather than poll until expiry.
|
|
881
|
+
this.failSignIn('This sign-in flow cannot be completed here. Use the app\'s OAuth sign-in instead.');
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
676
884
|
if (status.authorized && status.sessionId) {
|
|
677
885
|
this.clearPollTimer();
|
|
678
886
|
await this.claimAndComplete(status.sessionId, sessionToken);
|
|
@@ -699,8 +907,23 @@ export class AccountDialogController {
|
|
|
699
907
|
this.pollInFlight = false;
|
|
700
908
|
}
|
|
701
909
|
}
|
|
910
|
+
/**
|
|
911
|
+
* Record server-reported delivery progress on the active flow.
|
|
912
|
+
*
|
|
913
|
+
* Monotone and additive: a timestamp is only ever adopted, never replaced or
|
|
914
|
+
* cleared, so an older API build (or a partial payload) that omits a field can
|
|
915
|
+
* at most fail to advance the surface — it can never walk it backwards.
|
|
916
|
+
* Emits only on a real change, so a steady poll does not churn the snapshot.
|
|
917
|
+
*/
|
|
918
|
+
recordDeliveryProgress(pushSentAt, openedAt) {
|
|
919
|
+
const nextPushSentAt = this.signIn.pushSentAt ?? pushSentAt ?? null;
|
|
920
|
+
const nextOpenedAt = this.signIn.openedAt ?? openedAt ?? null;
|
|
921
|
+
if (nextPushSentAt === this.signIn.pushSentAt && nextOpenedAt === this.signIn.openedAt)
|
|
922
|
+
return;
|
|
923
|
+
this.patchSignIn({ pushSentAt: nextPushSentAt, openedAt: nextOpenedAt });
|
|
924
|
+
}
|
|
702
925
|
async claimAndComplete(sessionId, sessionToken) {
|
|
703
|
-
this.
|
|
926
|
+
this.patchSignIn({ phase: 'authorized' });
|
|
704
927
|
let claimed;
|
|
705
928
|
try {
|
|
706
929
|
claimed = await this.oxyServices.claimSessionByToken(sessionToken);
|
|
@@ -746,7 +969,9 @@ export class AccountDialogController {
|
|
|
746
969
|
this.clearPollTimer();
|
|
747
970
|
this.closeAuthSessionSocket();
|
|
748
971
|
this.closeActivePopup();
|
|
749
|
-
|
|
972
|
+
// Terminal SUCCESS, not idle: the surface gets one honest frame to show
|
|
973
|
+
// "Identity confirmed" before it closes. Cleared on the next view change.
|
|
974
|
+
this.signIn = COMPLETED_SIGN_IN;
|
|
750
975
|
this.view = 'accounts';
|
|
751
976
|
this.emit();
|
|
752
977
|
this.onSignedIn?.(user);
|
|
@@ -779,7 +1004,7 @@ export class AccountDialogController {
|
|
|
779
1004
|
this.closeAuthSessionSocket();
|
|
780
1005
|
this.closeActivePopup();
|
|
781
1006
|
this.signInToken = null;
|
|
782
|
-
this.setSignIn({ ...
|
|
1007
|
+
this.setSignIn({ ...IDLE_SIGN_IN_FACTS, phase: 'error', error: message });
|
|
783
1008
|
}
|
|
784
1009
|
clearPollTimer() {
|
|
785
1010
|
if (this.pollTimer !== null) {
|
|
@@ -861,10 +1086,32 @@ export class AccountDialogController {
|
|
|
861
1086
|
// =========================================================================
|
|
862
1087
|
// Snapshot plumbing
|
|
863
1088
|
// =========================================================================
|
|
864
|
-
|
|
865
|
-
|
|
1089
|
+
/**
|
|
1090
|
+
* Replace the device-flow state from its FACTS, re-deriving `progress`. The
|
|
1091
|
+
* only writer of `this.signIn` besides the two terminal constants — which is
|
|
1092
|
+
* what keeps `progress` impossible to set by hand, and therefore impossible
|
|
1093
|
+
* to advance without a fact behind it.
|
|
1094
|
+
*/
|
|
1095
|
+
setSignIn(facts) {
|
|
1096
|
+
this.signIn = { ...facts, progress: deriveSignInProgress(facts) };
|
|
866
1097
|
this.emit();
|
|
867
1098
|
}
|
|
1099
|
+
/** Update a subset of the device-flow facts, re-deriving `progress`. */
|
|
1100
|
+
patchSignIn(patch) {
|
|
1101
|
+
const { phase, authorizeCode, qrPayload, expiresAt, error, route, routeFailed, pushSentAt, openedAt, } = this.signIn;
|
|
1102
|
+
this.setSignIn({
|
|
1103
|
+
phase,
|
|
1104
|
+
authorizeCode,
|
|
1105
|
+
qrPayload,
|
|
1106
|
+
expiresAt,
|
|
1107
|
+
error,
|
|
1108
|
+
route,
|
|
1109
|
+
routeFailed,
|
|
1110
|
+
pushSentAt,
|
|
1111
|
+
openedAt,
|
|
1112
|
+
...patch,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
868
1115
|
computeSnapshot() {
|
|
869
1116
|
const state = this.sessionClient.getState();
|
|
870
1117
|
return {
|