@flow-industries/id 0.22.1 → 0.23.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/README.md +18 -9
- package/dist/sdk/browser-contract.d.ts +30 -0
- package/dist/sdk/browser-contract.js +19 -0
- package/dist/sdk/browser-session-route.d.ts +8 -0
- package/dist/sdk/browser-session-route.js +196 -0
- package/dist/sdk/client/create-flow.js +176 -166
- package/dist/sdk/client/flow-widget.js +1 -1
- package/dist/sdk/client/idb.d.ts +1 -0
- package/dist/sdk/client/idb.js +24 -0
- package/dist/sdk/client/open-profile.js +9 -1
- package/dist/sdk/client/profile-button.d.ts +1 -13
- package/dist/sdk/client/profile-button.js +114 -177
- package/dist/sdk/client/session.d.ts +1 -10
- package/dist/sdk/client/session.js +0 -37
- package/dist/sdk/client/static-flow.js +1 -0
- package/dist/sdk/react/hooks.d.ts +1 -1
- package/dist/sdk/react/profile-button.d.ts +1 -12
- package/dist/sdk/react/profile-button.js +1 -12
- package/dist/sdk/session-route.js +101 -4
- package/dist/sdk/types/dialog.d.ts +0 -3
- package/dist/sdk/types/index.d.ts +1 -1
- package/dist/sdk/types/landing.d.ts +1 -0
- package/dist/sdk/types/sdk.d.ts +7 -21
- package/dist/sdk/types/server.d.ts +6 -0
- package/dist/sdk/wagmi/index.js +0 -3
- package/package.json +4 -3
|
@@ -2,9 +2,8 @@ import { DEFAULT_SESSION_PATH, isLocalHostname, resolveIdHost, } from "../id-hos
|
|
|
2
2
|
import { isExpiring } from "../token-expiry";
|
|
3
3
|
import { createDialogHost } from "./dialog-host";
|
|
4
4
|
import { idb } from "./idb";
|
|
5
|
-
import { METHODS } from "./methods";
|
|
6
5
|
import { createRoomsApi } from "./rooms";
|
|
7
|
-
import { credentialToAddress,
|
|
6
|
+
import { credentialToAddress, runLogout } from "./session";
|
|
8
7
|
import { createStore, initialFlowState } from "./store";
|
|
9
8
|
/**
|
|
10
9
|
* Runs `fn` while holding a cross-tab lock (Web Locks API — origin-scoped by
|
|
@@ -73,7 +72,7 @@ export function createFlow(options = {}) {
|
|
|
73
72
|
if (currentFlow)
|
|
74
73
|
return currentFlow;
|
|
75
74
|
const host = resolveIdHost(options.host);
|
|
76
|
-
const dialogUrl = `${host}/dialog
|
|
75
|
+
const dialogUrl = `${host}/dialog/?v=2`;
|
|
77
76
|
const sessionPath = options.sessionPath ?? DEFAULT_SESSION_PATH;
|
|
78
77
|
const rpId = options.rpId ??
|
|
79
78
|
(isLocalHostname(window.location.hostname) ? "localhost" : undefined);
|
|
@@ -111,7 +110,13 @@ export function createFlow(options = {}) {
|
|
|
111
110
|
* rotation response is authoritative for signing state; the JWT hot path
|
|
112
111
|
* knows neither, and overwriting would erase state restored from IDB.
|
|
113
112
|
*/
|
|
113
|
+
let additionalSubject = null;
|
|
114
114
|
function commitSession(session) {
|
|
115
|
+
const previous = store.getSnapshot().user?.id;
|
|
116
|
+
if (previous && previous !== session.user.id) {
|
|
117
|
+
store.setState({ credential: null, address: null });
|
|
118
|
+
void idb.delete("flow.activeCredential");
|
|
119
|
+
}
|
|
115
120
|
const next = {
|
|
116
121
|
user: session.user,
|
|
117
122
|
jwt: session.jwt,
|
|
@@ -121,6 +126,18 @@ export function createFlow(options = {}) {
|
|
|
121
126
|
next.address = session.address ?? null;
|
|
122
127
|
}
|
|
123
128
|
store.setState(next);
|
|
129
|
+
if (additionalSubject !== session.user.id &&
|
|
130
|
+
options.additionalAudiences?.length) {
|
|
131
|
+
additionalSubject = session.user.id;
|
|
132
|
+
void Promise.all(options.additionalAudiences.map(mintAudience))
|
|
133
|
+
.then((sessions) => {
|
|
134
|
+
if (store.getSnapshot().user?.id !== session.user.id)
|
|
135
|
+
return;
|
|
136
|
+
const minted = sessions.filter((session) => session !== null);
|
|
137
|
+
options.onAdditionalSessions?.(minted);
|
|
138
|
+
})
|
|
139
|
+
.catch(() => { });
|
|
140
|
+
}
|
|
124
141
|
// A guest carries a null credential; never persist that — the IDB store is
|
|
125
142
|
// reserved for a real passkey credential and writing null would erase a
|
|
126
143
|
// previously stored one.
|
|
@@ -128,6 +145,41 @@ export function createFlow(options = {}) {
|
|
|
128
145
|
void idb.set("flow.activeCredential", session.credential);
|
|
129
146
|
}
|
|
130
147
|
}
|
|
148
|
+
async function recoverAccessKey(state, loginCompletion) {
|
|
149
|
+
const completionKey = "flow.pendingAccessKey.completion";
|
|
150
|
+
if (loginCompletion)
|
|
151
|
+
await idb.set(completionKey, {
|
|
152
|
+
...loginCompletion,
|
|
153
|
+
expires: Date.now() + 600_000,
|
|
154
|
+
});
|
|
155
|
+
const completion = loginCompletion ??
|
|
156
|
+
(await idb.get(completionKey));
|
|
157
|
+
if (completion && completion.userId === state.user.id) {
|
|
158
|
+
const key = `flow.pendingAccessKey.${completion.loginId}`;
|
|
159
|
+
const pending = await idb.get(key);
|
|
160
|
+
if (pending && pending.expires > Date.now() && completion.webauthn) {
|
|
161
|
+
const fresh = await fetch(`${sessionPath}?refresh=1`);
|
|
162
|
+
if (!fresh.ok)
|
|
163
|
+
throw new Error("Could not restore signing state");
|
|
164
|
+
// SAFETY: the app session handler returns the SDK protocol response.
|
|
165
|
+
const resolved = (await fresh.json());
|
|
166
|
+
if (resolved.state?.user.id === completion.userId &&
|
|
167
|
+
resolved.state.credential &&
|
|
168
|
+
resolved.state.address) {
|
|
169
|
+
commitSession(resolved.state);
|
|
170
|
+
const { finalizeAccessKey } = await import("./access-key");
|
|
171
|
+
await finalizeAccessKey({
|
|
172
|
+
address: resolved.state.address,
|
|
173
|
+
credential: resolved.state.credential,
|
|
174
|
+
webauthn: completion.webauthn,
|
|
175
|
+
preparation: pending.preparation,
|
|
176
|
+
});
|
|
177
|
+
await idb.delete(key);
|
|
178
|
+
await idb.delete(completionKey);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
131
183
|
/**
|
|
132
184
|
* Resolves a fresh session from the app's own session route. The refresh
|
|
133
185
|
* token lives in an HttpOnly cookie only that route's server can read, so
|
|
@@ -144,10 +196,11 @@ export function createFlow(options = {}) {
|
|
|
144
196
|
if (!res.ok)
|
|
145
197
|
return false;
|
|
146
198
|
// SAFETY: the app's own /flow/session route answers this shape.
|
|
147
|
-
const { state } = (await res.json());
|
|
199
|
+
const { state, loginCompletion } = (await res.json());
|
|
148
200
|
if (!state)
|
|
149
201
|
return false;
|
|
150
202
|
commitSession(state);
|
|
203
|
+
await recoverAccessKey(state, loginCompletion).catch(() => { });
|
|
151
204
|
return true;
|
|
152
205
|
}
|
|
153
206
|
catch {
|
|
@@ -179,49 +232,6 @@ export function createFlow(options = {}) {
|
|
|
179
232
|
return null;
|
|
180
233
|
}
|
|
181
234
|
}
|
|
182
|
-
/**
|
|
183
|
-
* One-time delivery of any additional-audience sessions a mint returned
|
|
184
|
-
* (AUTH-39). Handed straight to the consumer's callback and dropped — the
|
|
185
|
-
* SDK retains nothing; the consumer forwards each to its audience's own
|
|
186
|
-
* origin (the embed bootstrap) to be sealed HttpOnly there.
|
|
187
|
-
*/
|
|
188
|
-
function deliverAdditionalSessions(sessions) {
|
|
189
|
-
if (!sessions?.length || !options.onAdditionalSessions)
|
|
190
|
-
return;
|
|
191
|
-
try {
|
|
192
|
-
options.onAdditionalSessions(sessions);
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
console.warn("Flow ID: onAdditionalSessions handler threw", err);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* One-time handoff of a freshly minted session (dialog login or guest
|
|
200
|
-
* mint) to the app's server, which verifies the JWT against the issuer's
|
|
201
|
-
* JWKS and sets the HttpOnly cookies — the refresh token is never stored
|
|
202
|
-
* where page JavaScript could read it back. Best-effort: without a
|
|
203
|
-
* reachable session route the in-memory session still works, it just
|
|
204
|
-
* can't survive a reload.
|
|
205
|
-
*/
|
|
206
|
-
async function installSession(refreshToken, jwt) {
|
|
207
|
-
if (!refreshToken || !jwt)
|
|
208
|
-
return;
|
|
209
|
-
try {
|
|
210
|
-
const res = await fetch(sessionPath, {
|
|
211
|
-
method: "POST",
|
|
212
|
-
headers: { "Content-Type": "application/json" },
|
|
213
|
-
body: JSON.stringify({ refreshToken, jwt }),
|
|
214
|
-
});
|
|
215
|
-
if (!res.ok) {
|
|
216
|
-
console.warn(`Flow ID: session route ${sessionPath} answered ${res.status}; ` +
|
|
217
|
-
"the session will not survive a reload");
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
console.warn(`Flow ID: no session route at ${sessionPath}; ` +
|
|
222
|
-
"the session will not survive a reload");
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
235
|
// Every session-route call — boot, ensureGuest, and getToken — funnels
|
|
226
236
|
// through this one lock so concurrent tabs don't fan out parallel
|
|
227
237
|
// rotations. The server dedupes and the auth server's grace window absorbs
|
|
@@ -291,22 +301,18 @@ export function createFlow(options = {}) {
|
|
|
291
301
|
if (await doRefresh())
|
|
292
302
|
return true;
|
|
293
303
|
try {
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
:
|
|
297
|
-
|
|
298
|
-
return false;
|
|
299
|
-
store.setState({
|
|
300
|
-
user: result.user,
|
|
301
|
-
jwt: result.jwt,
|
|
302
|
-
// Only a guest has no credential. The server's idempotency path can
|
|
303
|
-
// return a full session here; in that case keep any credential/
|
|
304
|
-
// address already in state rather than stripping signing.
|
|
305
|
-
credential: result.user.isGuest ? null : undefined,
|
|
306
|
-
address: result.user.isGuest ? null : undefined,
|
|
304
|
+
const response = await fetch(sessionPath, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers: { "Content-Type": "application/json" },
|
|
307
|
+
body: JSON.stringify({ action: "guest" }),
|
|
307
308
|
});
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
if (!response.ok)
|
|
310
|
+
return false;
|
|
311
|
+
// SAFETY: the app session handler returns the SDK protocol response.
|
|
312
|
+
const result = (await response.json());
|
|
313
|
+
if (!result.state)
|
|
314
|
+
return false;
|
|
315
|
+
commitSession(result.state);
|
|
310
316
|
return true;
|
|
311
317
|
}
|
|
312
318
|
catch {
|
|
@@ -319,24 +325,26 @@ export function createFlow(options = {}) {
|
|
|
319
325
|
return guestInFlight;
|
|
320
326
|
}
|
|
321
327
|
void (async () => {
|
|
322
|
-
|
|
323
|
-
// A fresh hydrated JWT means the server already refreshed this request —
|
|
324
|
-
// running doRefresh() here would rotate a second time for nothing.
|
|
325
|
-
const hydrated = store.getSnapshot().jwt;
|
|
326
|
-
if (hydrated && !isExpiring(hydrated))
|
|
327
|
-
return;
|
|
328
|
+
void idb.pruneExpired("flow.pendingAccessKey.").catch(() => { });
|
|
328
329
|
if (options.autoRestore !== false) {
|
|
329
|
-
const restored = await
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
330
|
+
const restored = await refreshViaSession();
|
|
331
|
+
if (restored) {
|
|
332
|
+
const response = await fetch(`${sessionPath}?refresh=1`);
|
|
333
|
+
if (response.ok) {
|
|
334
|
+
// SAFETY: the app session handler returns the SDK protocol response.
|
|
335
|
+
const result = (await response.json());
|
|
336
|
+
if (result.state)
|
|
337
|
+
commitSession(result.state);
|
|
338
|
+
else
|
|
339
|
+
await runLogout(store);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else if (options.autoGuest)
|
|
334
343
|
await ensureGuest();
|
|
335
344
|
}
|
|
336
|
-
else if (options.autoGuest)
|
|
345
|
+
else if (options.autoGuest)
|
|
337
346
|
await ensureGuest();
|
|
338
|
-
|
|
339
|
-
})();
|
|
347
|
+
})().catch(() => { });
|
|
340
348
|
function buildSigningContext() {
|
|
341
349
|
return {
|
|
342
350
|
getState: () => store.getSnapshot(),
|
|
@@ -346,107 +354,108 @@ export function createFlow(options = {}) {
|
|
|
346
354
|
strict: accessKeyOptions?.strict ? accessKeyOptions.strict : undefined,
|
|
347
355
|
};
|
|
348
356
|
}
|
|
349
|
-
/**
|
|
350
|
-
* Opens the dialog iframe and runs the interactive sign-in or sign-up flow.
|
|
351
|
-
* Resolves with the resulting Session once the user completes authentication;
|
|
352
|
-
* rejects if the user cancels or the dialog fails.
|
|
353
|
-
*
|
|
354
|
-
* If access keys are enabled, this also derives an ephemeral P256 key pair
|
|
355
|
-
* locally and signs a KeyAuthorization with the passkey during the same
|
|
356
|
-
* WebAuthn ceremony — so future signing calls can use the access key without
|
|
357
|
-
* prompting for a passkey tap each time. The KeyAuthorization is finalized
|
|
358
|
-
* (persisted to IDB) only after the dialog confirms login succeeded.
|
|
359
|
-
*/
|
|
357
|
+
/** Starts top-level authentication; successful navigation never resolves in this document. */
|
|
360
358
|
async function login(loginOpts = {}) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
let
|
|
365
|
-
let extraCapabilities;
|
|
359
|
+
if (window.top !== window) {
|
|
360
|
+
throw new Error("Open sign-in from the containing app.");
|
|
361
|
+
}
|
|
362
|
+
let preparation;
|
|
366
363
|
if (accessKeyOptions) {
|
|
367
364
|
const chain = getChain();
|
|
368
|
-
if (!chain)
|
|
369
|
-
throw new Error("accessKey requires
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
accessKeyPrep = await accessKeyModule.prepareAccessKey(accessKeyOptions, chain.id);
|
|
373
|
-
extraCapabilities = { accessKeyHash: accessKeyPrep.accessKeyHash };
|
|
374
|
-
}
|
|
375
|
-
// When the current session is a guest, forward its access token so a
|
|
376
|
-
// sign-up upgrades that guest in place even where the guest cookie is
|
|
377
|
-
// unreadable (the iOS top-level sign-up popup). Mint via getToken() rather
|
|
378
|
-
// than reading store.jwt directly: the cached JWT can be up to an hour stale
|
|
379
|
-
// on a long-idle tab, and an expired token fails server verification — the
|
|
380
|
-
// guest would then be registered as a fresh account instead of upgraded.
|
|
381
|
-
// An app that never becomes a guest forwards nothing.
|
|
382
|
-
if (store.getSnapshot().user?.isGuest) {
|
|
383
|
-
const guestToken = await getToken();
|
|
384
|
-
if (guestToken) {
|
|
385
|
-
extraCapabilities = { ...(extraCapabilities ?? {}), guestToken };
|
|
386
|
-
}
|
|
365
|
+
if (!chain)
|
|
366
|
+
throw new Error("accessKey requires a configured chain");
|
|
367
|
+
const { prepareAccessKey } = await import("./access-key");
|
|
368
|
+
preparation = await prepareAccessKey(accessKeyOptions, chain.id);
|
|
387
369
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
extraCapabilities: extraCapabilities ? extraCapabilities : undefined,
|
|
370
|
+
const response = await fetch(sessionPath, {
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers: { "Content-Type": "application/json" },
|
|
373
|
+
body: JSON.stringify({
|
|
374
|
+
action: "login",
|
|
375
|
+
accessKeyHash: preparation?.accessKeyHash,
|
|
376
|
+
signUp: loginOpts.signUp,
|
|
377
|
+
returnTo: loginOpts.returnTo ??
|
|
378
|
+
`${window.location.pathname}${window.location.search}${window.location.hash}`,
|
|
379
|
+
}),
|
|
399
380
|
});
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
if (
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
session.address) {
|
|
409
|
-
await accessKeyModule.finalizeAccessKey({
|
|
410
|
-
address: session.address,
|
|
411
|
-
credential: session.credential,
|
|
412
|
-
webauthn,
|
|
413
|
-
preparation: accessKeyPrep,
|
|
381
|
+
if (!response.ok)
|
|
382
|
+
throw new Error("Could not start sign-in. Reload and try again.");
|
|
383
|
+
// SAFETY: the app session handler returns the SDK protocol response.
|
|
384
|
+
const result = (await response.json());
|
|
385
|
+
if (preparation)
|
|
386
|
+
await idb.set(`flow.pendingAccessKey.${result.loginId}`, {
|
|
387
|
+
preparation,
|
|
388
|
+
expires: Date.now() + 600_000,
|
|
414
389
|
});
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
390
|
+
window.location.assign(result.authorizeUrl);
|
|
391
|
+
return new Promise((_resolve, reject) => {
|
|
392
|
+
window.setTimeout(() => reject(new Error("Sign-in navigation did not finish. Try again.")), 30_000);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
function openAccount() {
|
|
396
|
+
const tab = window.open("about:blank", "_blank");
|
|
397
|
+
if (!tab)
|
|
398
|
+
return false;
|
|
399
|
+
tab.opener = null;
|
|
400
|
+
tab.document.title = "Opening Flow ID";
|
|
401
|
+
tab.document.body.textContent = "Opening your Flow ID account…";
|
|
402
|
+
void fetch(sessionPath, {
|
|
403
|
+
method: "POST",
|
|
404
|
+
headers: { "Content-Type": "application/json" },
|
|
405
|
+
body: JSON.stringify({ action: "account" }),
|
|
406
|
+
})
|
|
407
|
+
.then(async (response) => {
|
|
408
|
+
if (!response.ok)
|
|
409
|
+
throw new Error("Account unavailable");
|
|
410
|
+
// SAFETY: the app session handler returns the SDK protocol response.
|
|
411
|
+
const result = (await response.json());
|
|
412
|
+
tab.location.replace(result.accountUrl);
|
|
413
|
+
})
|
|
414
|
+
.catch(() => {
|
|
415
|
+
tab.document.title = "Open Flow ID";
|
|
416
|
+
tab.document.body.textContent =
|
|
417
|
+
"Your app account could not be opened securely. Return to the app and try again, or open Flow ID and check the signed-in account before making changes. ";
|
|
418
|
+
const link = tab.document.createElement("a");
|
|
419
|
+
link.href = `${host}/account`;
|
|
420
|
+
link.textContent = "Open Flow ID";
|
|
421
|
+
link.rel = "noreferrer";
|
|
422
|
+
tab.document.body.appendChild(link);
|
|
423
|
+
});
|
|
424
|
+
return true;
|
|
423
425
|
}
|
|
424
|
-
/**
|
|
425
|
-
* Performs a full sign-out: tells the id.flow.industries dialog to
|
|
426
|
-
* invalidate its cookie session (so other Flow apps can't silently restore
|
|
427
|
-
* it), tells the app's session route to revoke the refresh lineage and
|
|
428
|
-
* clear the HttpOnly cookies, then clears local credential and access-key
|
|
429
|
-
* state and resets the in-memory store.
|
|
430
|
-
*
|
|
431
|
-
* Both server-side steps are best-effort — if a network call fails (e.g.,
|
|
432
|
-
* offline) we still clear local state so the UI reflects "signed out". The
|
|
433
|
-
* cookies and tokens eventually expire on their own.
|
|
434
|
-
*/
|
|
435
426
|
async function logout() {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
getDialog().requestSilent(METHODS.signOut, []),
|
|
440
|
-
fetch(sessionPath, { method: "DELETE" }),
|
|
441
|
-
]);
|
|
427
|
+
const response = await fetch(sessionPath, { method: "DELETE" });
|
|
428
|
+
if (!response.ok)
|
|
429
|
+
throw new Error("Could not sign out. Try again.");
|
|
442
430
|
await runLogout(store);
|
|
443
|
-
|
|
444
|
-
// An autoGuest app is never truly "signed out" — it always wants at least a
|
|
445
|
-
// guest session. Re-mint one so the UI (e.g. the profile widget pill) keeps
|
|
446
|
-
// working after logout instead of vanishing until the next page load.
|
|
431
|
+
additionalSubject = null;
|
|
447
432
|
if (options.autoGuest)
|
|
448
433
|
await ensureGuest();
|
|
449
434
|
}
|
|
435
|
+
let focusRefresh = null;
|
|
436
|
+
const refreshOnFocus = () => {
|
|
437
|
+
if (document.visibilityState === "hidden" || focusRefresh)
|
|
438
|
+
return;
|
|
439
|
+
focusRefresh = withOriginLock("flow.id.refresh", async () => {
|
|
440
|
+
const response = await fetch(`${sessionPath}?refresh=1`, {
|
|
441
|
+
headers: { Accept: "application/json" },
|
|
442
|
+
});
|
|
443
|
+
if (!response.ok)
|
|
444
|
+
return;
|
|
445
|
+
// SAFETY: the app session handler returns the SDK protocol response.
|
|
446
|
+
const result = (await response.json());
|
|
447
|
+
if (result.state)
|
|
448
|
+
commitSession(result.state);
|
|
449
|
+
else
|
|
450
|
+
await runLogout(store);
|
|
451
|
+
})
|
|
452
|
+
.catch(() => { })
|
|
453
|
+
.finally(() => {
|
|
454
|
+
focusRefresh = null;
|
|
455
|
+
});
|
|
456
|
+
};
|
|
457
|
+
window.addEventListener("focus", refreshOnFocus);
|
|
458
|
+
document.addEventListener("visibilitychange", refreshOnFocus);
|
|
450
459
|
const flow = {
|
|
451
460
|
get user() {
|
|
452
461
|
return store.getSnapshot().user;
|
|
@@ -467,8 +476,9 @@ export function createFlow(options = {}) {
|
|
|
467
476
|
return store.getSnapshot().user?.isGuest === true;
|
|
468
477
|
},
|
|
469
478
|
login,
|
|
479
|
+
openAccount,
|
|
470
480
|
logout,
|
|
471
|
-
restore:
|
|
481
|
+
restore: refreshViaSession,
|
|
472
482
|
ensureGuest,
|
|
473
483
|
refreshJwt,
|
|
474
484
|
getToken,
|
|
@@ -47,7 +47,7 @@ export function createFlowWidget(options) {
|
|
|
47
47
|
const hostOrigin = new URL(host).origin;
|
|
48
48
|
let theme = options.theme ?? "light dark";
|
|
49
49
|
const route = WIDGET_ROUTE[options.widget];
|
|
50
|
-
const frame = makeIframe(`${host}${route}`, WIDGET_TITLE[options.widget]);
|
|
50
|
+
const frame = makeIframe(`${host}${route}?v=2`, WIDGET_TITLE[options.widget]);
|
|
51
51
|
frame.style.background = "transparent";
|
|
52
52
|
const container = options.container ?? document.body;
|
|
53
53
|
// Appended before bridging: `contentWindow` is null until the frame is in the
|
package/dist/sdk/client/idb.d.ts
CHANGED
package/dist/sdk/client/idb.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const expiringEntrySchema = z.object({ expires: z.number().optional() });
|
|
1
3
|
let defaultGetStoreFunc;
|
|
2
4
|
function createStore(dbName, storeName) {
|
|
3
5
|
let dbp;
|
|
@@ -38,6 +40,28 @@ function promisify(request) {
|
|
|
38
40
|
});
|
|
39
41
|
}
|
|
40
42
|
export const idb = {
|
|
43
|
+
pruneExpired(prefix) {
|
|
44
|
+
if (!defaultGetStoreFunc)
|
|
45
|
+
defaultGetStoreFunc = createStore("flow-id", "keyval");
|
|
46
|
+
return defaultGetStoreFunc("readwrite", (store) => {
|
|
47
|
+
const request = store.openCursor();
|
|
48
|
+
request.onsuccess = () => {
|
|
49
|
+
const cursor = request.result;
|
|
50
|
+
if (!cursor)
|
|
51
|
+
return;
|
|
52
|
+
const key = z.string().safeParse(cursor.key);
|
|
53
|
+
if (key.success && key.data.startsWith(prefix)) {
|
|
54
|
+
const entry = expiringEntrySchema.safeParse(cursor.value);
|
|
55
|
+
if (!entry.success ||
|
|
56
|
+
!entry.data.expires ||
|
|
57
|
+
entry.data.expires <= Date.now())
|
|
58
|
+
cursor.delete();
|
|
59
|
+
}
|
|
60
|
+
cursor.continue();
|
|
61
|
+
};
|
|
62
|
+
return promisify(store.transaction);
|
|
63
|
+
});
|
|
64
|
+
},
|
|
41
65
|
get(key) {
|
|
42
66
|
if (!defaultGetStoreFunc)
|
|
43
67
|
defaultGetStoreFunc = createStore("flow-id", "keyval");
|
|
@@ -30,7 +30,7 @@ function createViewer(options) {
|
|
|
30
30
|
setOverlayFocus(frame, ready && visible);
|
|
31
31
|
}
|
|
32
32
|
function mount(username) {
|
|
33
|
-
const el = makeIframe(`${host}/dialog
|
|
33
|
+
const el = makeIframe(`${host}/dialog/?v=2`, "Flow ID profile");
|
|
34
34
|
el.dataset.flowProfileView = "";
|
|
35
35
|
el.inert = true;
|
|
36
36
|
Object.assign(el.style, OVERLAY_STYLE, { pointerEvents: "none" });
|
|
@@ -179,6 +179,14 @@ export function openProfile(username, options = {}) {
|
|
|
179
179
|
return false;
|
|
180
180
|
if (!("document" in globalThis))
|
|
181
181
|
return false;
|
|
182
|
+
const current = options.flow ?? getFlow();
|
|
183
|
+
if (current?.user?.username === username) {
|
|
184
|
+
closeProfile();
|
|
185
|
+
const opened = current.openAccount();
|
|
186
|
+
if (opened)
|
|
187
|
+
queueMicrotask(() => options.onClose?.());
|
|
188
|
+
return opened;
|
|
189
|
+
}
|
|
182
190
|
const live = viewer ?? createViewer(options);
|
|
183
191
|
viewer = live;
|
|
184
192
|
if (options.theme)
|
|
@@ -1,15 +1,3 @@
|
|
|
1
1
|
import type { MountProfileOptions, ProfileButtonHandle } from "../types";
|
|
2
|
-
/**
|
|
3
|
-
* Mounts the universal Flow ID "profile button" — a persistent inline iframe
|
|
4
|
-
* showing the signed-in user's avatar + username (the collapsed pill). Clicking
|
|
5
|
-
* it opens the profile dialog in a SEPARATE fullscreen overlay iframe, so the
|
|
6
|
-
* pill stays in place behind the dialog's dimmed backdrop rather than morphing
|
|
7
|
-
* into it. Both iframes ride the dialog SPA at `${host}/dialog/` (routes
|
|
8
|
-
* `/dialog/profile-pill` and `/dialog/profile`) so the chrome matches the auth
|
|
9
|
-
* dialog and the widget is first-party to Flow ID (reads `/api/me`, uploads
|
|
10
|
-
* avatars with the cookie session).
|
|
11
|
-
*
|
|
12
|
-
* Resolves the Flow singleton automatically (createFlow() must have run), so
|
|
13
|
-
* callers don't thread the instance through. No-op under SSR.
|
|
14
|
-
*/
|
|
2
|
+
/** Mount a native account button that opens Flow ID in a normal browser tab. */
|
|
15
3
|
export declare function createProfileButton(options: MountProfileOptions): ProfileButtonHandle;
|