@poly-x/react 0.1.0-alpha.8 → 0.1.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/index.cjs +1034 -81
- package/dist/index.d.cts +325 -4
- package/dist/index.d.mts +325 -4
- package/dist/index.mjs +1028 -83
- package/dist/styles.css +273 -0
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -300,31 +300,225 @@ function PolyXProvider({ publishableKey, baseUrl, signInUrl, children }) {
|
|
|
300
300
|
});
|
|
301
301
|
}
|
|
302
302
|
//#endregion
|
|
303
|
+
//#region src/bff-session.ts
|
|
304
|
+
/**
|
|
305
|
+
* Client session under BFF custody (ADR-0004).
|
|
306
|
+
*
|
|
307
|
+
* `@poly-x/next` seals the tokens in an httpOnly cookie, so a consuming app mounts no
|
|
308
|
+
* `<PolyXProvider>` and there is no client engine to read — there is nothing for it to own.
|
|
309
|
+
* The hooks still have to work there, so they fall back to this: a single module-level store
|
|
310
|
+
* that reads server truth from the `session` route handler once and feeds the *same*
|
|
311
|
+
* `SessionState` machine the engine drives. That is what ADR-0004 meant by custody being
|
|
312
|
+
* "invisible to the consumer API surface".
|
|
313
|
+
*
|
|
314
|
+
* It follows `<SignIn/>`'s precedent — work without a provider, degrade quietly — rather than
|
|
315
|
+
* asking BFF apps to adopt a provider whose whole purpose is holding tokens they don't have.
|
|
316
|
+
*/
|
|
317
|
+
const SESSION_ENDPOINT = "/api/polyx/session";
|
|
318
|
+
const SIGNOUT_ENDPOINT = "/api/polyx/signout";
|
|
319
|
+
var BffSessionStore = class {
|
|
320
|
+
state = { status: "resolving" };
|
|
321
|
+
listeners = /* @__PURE__ */ new Set();
|
|
322
|
+
loading = false;
|
|
323
|
+
watching = false;
|
|
324
|
+
getState = () => this.state;
|
|
325
|
+
/** SSR has no session to read; render the resolving branch and settle on the client. */
|
|
326
|
+
getServerState = () => ({ status: "resolving" });
|
|
327
|
+
subscribe = (listener) => {
|
|
328
|
+
this.listeners.add(listener);
|
|
329
|
+
this.load();
|
|
330
|
+
this.watchFocus();
|
|
331
|
+
return () => {
|
|
332
|
+
this.listeners.delete(listener);
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* The platform revokes every session for a user when an admin changes their permission,
|
|
337
|
+
* suspends them, or resets their password — and it pushes nothing, so a revoked user keeps
|
|
338
|
+
* looking signed in until they happen to make a request. Re-checking when the tab regains
|
|
339
|
+
* focus is the cheapest way to notice: come back to the tab, discover you're signed out.
|
|
340
|
+
*
|
|
341
|
+
* It is not a replacement for the 401 the next request will get; it just moves the discovery
|
|
342
|
+
* to a moment the user is actually looking at the screen.
|
|
343
|
+
*/
|
|
344
|
+
watchFocus() {
|
|
345
|
+
if (this.watching || typeof window === "undefined") return;
|
|
346
|
+
this.watching = true;
|
|
347
|
+
const recheck = () => {
|
|
348
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
|
349
|
+
this.revalidate();
|
|
350
|
+
};
|
|
351
|
+
window.addEventListener("focus", recheck);
|
|
352
|
+
document?.addEventListener?.("visibilitychange", recheck);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Re-read the session without passing through `resolving` — a confirmed session must not
|
|
356
|
+
* flicker the UI back to a loading state. Only a definite signed-out answer signs the user
|
|
357
|
+
* out: an unreachable server is a blip, not a revocation.
|
|
358
|
+
*/
|
|
359
|
+
async revalidate() {
|
|
360
|
+
if (this.loading || this.state.status === "resolving") return;
|
|
361
|
+
this.loading = true;
|
|
362
|
+
try {
|
|
363
|
+
const response = await fetch(SESSION_ENDPOINT, {
|
|
364
|
+
credentials: "same-origin",
|
|
365
|
+
headers: { accept: "application/json" }
|
|
366
|
+
});
|
|
367
|
+
const body = response.ok ? await response.json() : null;
|
|
368
|
+
const signedIn = Boolean(body?.isSignedIn && body.claims);
|
|
369
|
+
if (!signedIn && this.state.status !== "signed-out") this.set({
|
|
370
|
+
status: "signed-out",
|
|
371
|
+
reason: "revoked"
|
|
372
|
+
});
|
|
373
|
+
else if (signedIn && body?.claims) this.set({
|
|
374
|
+
status: "authenticated",
|
|
375
|
+
session: { claims: body.claims }
|
|
376
|
+
});
|
|
377
|
+
} catch {} finally {
|
|
378
|
+
this.loading = false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Reading the session is a one-shot per page load, shared by every component that asks —
|
|
383
|
+
* a user menu and three gates must not mean four round-trips.
|
|
384
|
+
*/
|
|
385
|
+
async load() {
|
|
386
|
+
if (this.loading || this.state.status !== "resolving") return;
|
|
387
|
+
this.loading = true;
|
|
388
|
+
try {
|
|
389
|
+
const response = await fetch(SESSION_ENDPOINT, {
|
|
390
|
+
credentials: "same-origin",
|
|
391
|
+
headers: { accept: "application/json" }
|
|
392
|
+
});
|
|
393
|
+
const body = response.ok ? await response.json() : null;
|
|
394
|
+
this.set(body?.isSignedIn && body.claims ? {
|
|
395
|
+
status: "authenticated",
|
|
396
|
+
session: { claims: body.claims }
|
|
397
|
+
} : {
|
|
398
|
+
status: "signed-out",
|
|
399
|
+
reason: "signed-out"
|
|
400
|
+
});
|
|
401
|
+
} catch {
|
|
402
|
+
this.set({
|
|
403
|
+
status: "signed-out",
|
|
404
|
+
reason: "signed-out"
|
|
405
|
+
});
|
|
406
|
+
} finally {
|
|
407
|
+
this.loading = false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* v2 (FR-LIVE-1/2): re-read server truth and update claims, resolving whether **authorization
|
|
412
|
+
* actually changed**. Drives the live-authz orchestrator (`startLiveAuthz`): a `true` means a
|
|
413
|
+
* consumer that gates on `useUser()` should re-guard. Never flickers to `resolving`; a definite
|
|
414
|
+
* signed-out answer signs the user out (revoked), a network blip keeps the last-known claims.
|
|
415
|
+
* The orchestrator single-flights callers, so this needs no internal in-flight guard.
|
|
416
|
+
*/
|
|
417
|
+
refresh = async () => {
|
|
418
|
+
try {
|
|
419
|
+
const response = await fetch(SESSION_ENDPOINT, {
|
|
420
|
+
credentials: "same-origin",
|
|
421
|
+
headers: { accept: "application/json" }
|
|
422
|
+
});
|
|
423
|
+
const body = response.ok ? await response.json() : null;
|
|
424
|
+
if (!Boolean(body?.isSignedIn && body.claims)) {
|
|
425
|
+
const changed = this.state.status !== "signed-out";
|
|
426
|
+
if (changed) this.set({
|
|
427
|
+
status: "signed-out",
|
|
428
|
+
reason: "revoked"
|
|
429
|
+
});
|
|
430
|
+
return changed;
|
|
431
|
+
}
|
|
432
|
+
const prevClaims = this.state.status === "authenticated" ? JSON.stringify(this.state.session.claims) : null;
|
|
433
|
+
const nextClaims = body.claims;
|
|
434
|
+
const changed = prevClaims !== JSON.stringify(nextClaims);
|
|
435
|
+
if (changed) this.set({
|
|
436
|
+
status: "authenticated",
|
|
437
|
+
session: { claims: nextClaims }
|
|
438
|
+
});
|
|
439
|
+
return changed;
|
|
440
|
+
} catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
set(next) {
|
|
445
|
+
this.state = next;
|
|
446
|
+
for (const listener of this.listeners) listener();
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Sign-out has to reach the server — only it can unseal an httpOnly cookie, and only it
|
|
450
|
+
* holds the token needed to revoke the session upstream (X002).
|
|
451
|
+
*
|
|
452
|
+
* A same-origin fetch, not a navigation: the response's Set-Cookie still applies, and it
|
|
453
|
+
* leaves the destination to the caller. That is why the handler is content-negotiated —
|
|
454
|
+
* a server-side redirect target would be an open redirect on the auth path, and the client
|
|
455
|
+
* already knows where it wants to land.
|
|
456
|
+
*/
|
|
457
|
+
signOut = async (options) => {
|
|
458
|
+
const query = options?.scope === "everywhere" ? "?scope=everywhere" : "";
|
|
459
|
+
try {
|
|
460
|
+
await fetch(`${SIGNOUT_ENDPOINT}${query}`, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
credentials: "same-origin",
|
|
463
|
+
headers: { accept: "application/json" }
|
|
464
|
+
});
|
|
465
|
+
} catch {}
|
|
466
|
+
this.set({
|
|
467
|
+
status: "signed-out",
|
|
468
|
+
reason: "signed-out"
|
|
469
|
+
});
|
|
470
|
+
if (options?.onSignOut) try {
|
|
471
|
+
await options.onSignOut();
|
|
472
|
+
} catch {}
|
|
473
|
+
window.location.assign(options?.redirectTo ?? "/");
|
|
474
|
+
};
|
|
475
|
+
/** Null by design: under BFF custody the token never reaches the browser (FR-SES-3). */
|
|
476
|
+
getToken = async () => null;
|
|
477
|
+
};
|
|
478
|
+
let store = new BffSessionStore();
|
|
479
|
+
function getBffSessionStore() {
|
|
480
|
+
return store;
|
|
481
|
+
}
|
|
482
|
+
//#endregion
|
|
303
483
|
//#region src/hooks.ts
|
|
304
|
-
function
|
|
484
|
+
function useSessionSource() {
|
|
305
485
|
const ctx = (0, react.useContext)(PolyXContext);
|
|
306
|
-
if (!ctx)
|
|
307
|
-
|
|
486
|
+
if (!ctx) return {
|
|
487
|
+
source: getBffSessionStore(),
|
|
488
|
+
canSignIn: false
|
|
489
|
+
};
|
|
490
|
+
const { engine, controller } = ctx;
|
|
491
|
+
return {
|
|
492
|
+
source: {
|
|
493
|
+
getState: () => engine.getState(),
|
|
494
|
+
subscribe: (listener) => engine.subscribe(listener),
|
|
495
|
+
signOut: () => engine.signOut(),
|
|
496
|
+
getToken: async () => {
|
|
497
|
+
try {
|
|
498
|
+
return await engine.getAccessToken();
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
canSignIn: Boolean(controller)
|
|
505
|
+
};
|
|
308
506
|
}
|
|
309
507
|
/** The raw session state machine value — tearing-free under concurrent React. */
|
|
310
508
|
function useSession() {
|
|
311
|
-
const {
|
|
312
|
-
|
|
313
|
-
const getSnapshot = (0, react.useCallback)(() => engine.getState(), [engine]);
|
|
314
|
-
return (0, react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
|
|
509
|
+
const { source } = useSessionSource();
|
|
510
|
+
return (0, react.useSyncExternalStore)((0, react.useCallback)((onChange) => source.subscribe(onChange), [source]), (0, react.useCallback)(() => source.getState(), [source]), (0, react.useCallback)(() => (source.getServerState ?? source.getState)(), [source]));
|
|
315
511
|
}
|
|
316
512
|
function useAuth() {
|
|
317
|
-
const
|
|
513
|
+
const ctx = (0, react.useContext)(PolyXContext);
|
|
514
|
+
const { source } = useSessionSource();
|
|
318
515
|
const state = useSession();
|
|
319
|
-
const signIn = (0, react.useCallback)((options) =>
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
return null;
|
|
326
|
-
}
|
|
327
|
-
}, [engine]);
|
|
516
|
+
const signIn = (0, react.useCallback)(async (options) => {
|
|
517
|
+
if (!ctx) throw new Error("PolyX: signIn() needs a <PolyXProvider> (it owns the publishable key and the popup flow). Under BFF custody, render <SignIn/> or link to your sign-in route instead.");
|
|
518
|
+
return ctx.controller.signIn(options);
|
|
519
|
+
}, [ctx]);
|
|
520
|
+
const signOut = (0, react.useCallback)((options) => source.signOut(options), [source]);
|
|
521
|
+
const getToken = (0, react.useCallback)(() => source.getToken(), [source]);
|
|
328
522
|
return {
|
|
329
523
|
isLoaded: state.status !== "resolving",
|
|
330
524
|
isSignedIn: state.status === "authenticated" || state.status === "refreshing",
|
|
@@ -335,8 +529,11 @@ function useAuth() {
|
|
|
335
529
|
}
|
|
336
530
|
/** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
|
|
337
531
|
function useAuthCallback() {
|
|
338
|
-
const
|
|
339
|
-
return (0, react.useCallback)(() =>
|
|
532
|
+
const ctx = (0, react.useContext)(PolyXContext);
|
|
533
|
+
return (0, react.useCallback)(() => {
|
|
534
|
+
if (!ctx) throw new Error("PolyX: <AuthCallback> / useAuthCallback() must be used within a <PolyXProvider>.");
|
|
535
|
+
return ctx.controller.handleCallback();
|
|
536
|
+
}, [ctx]);
|
|
340
537
|
}
|
|
341
538
|
function useUser() {
|
|
342
539
|
const state = useSession();
|
|
@@ -351,8 +548,89 @@ function Protected({ children, fallback = null, loading = null }) {
|
|
|
351
548
|
return isSignedIn ? children : fallback;
|
|
352
549
|
}
|
|
353
550
|
//#endregion
|
|
551
|
+
//#region src/geolocation.ts
|
|
552
|
+
/** W3C Geolocation `PositionError.PERMISSION_DENIED`. The only code that means a real "no". */
|
|
553
|
+
const PERMISSION_DENIED = 1;
|
|
554
|
+
/** One capture attempt at a given accuracy. Never rejects. */
|
|
555
|
+
function attemptCapture(geo, highAccuracy, timeout) {
|
|
556
|
+
return new Promise((resolve) => {
|
|
557
|
+
let settled = false;
|
|
558
|
+
const done = (outcome) => {
|
|
559
|
+
if (settled) return;
|
|
560
|
+
settled = true;
|
|
561
|
+
resolve(outcome);
|
|
562
|
+
};
|
|
563
|
+
const timer = setTimeout(() => done({
|
|
564
|
+
result: { consent: "granted" },
|
|
565
|
+
retriable: true
|
|
566
|
+
}), timeout + 1500);
|
|
567
|
+
const finish = (outcome) => {
|
|
568
|
+
clearTimeout(timer);
|
|
569
|
+
done(outcome);
|
|
570
|
+
};
|
|
571
|
+
try {
|
|
572
|
+
geo.getCurrentPosition((position) => finish({
|
|
573
|
+
result: {
|
|
574
|
+
latitude: String(position.coords.latitude),
|
|
575
|
+
longitude: String(position.coords.longitude),
|
|
576
|
+
accuracyMeters: typeof position.coords.accuracy === "number" ? position.coords.accuracy : void 0,
|
|
577
|
+
consent: "granted"
|
|
578
|
+
},
|
|
579
|
+
retriable: false
|
|
580
|
+
}), (err) => {
|
|
581
|
+
const code = err?.code;
|
|
582
|
+
const denied = typeof code === "number" && code === PERMISSION_DENIED;
|
|
583
|
+
finish({
|
|
584
|
+
result: { consent: denied ? "denied" : "granted" },
|
|
585
|
+
retriable: !denied
|
|
586
|
+
});
|
|
587
|
+
}, {
|
|
588
|
+
enableHighAccuracy: highAccuracy,
|
|
589
|
+
timeout,
|
|
590
|
+
maximumAge: 0
|
|
591
|
+
});
|
|
592
|
+
} catch {
|
|
593
|
+
finish({
|
|
594
|
+
result: { consent: "granted" },
|
|
595
|
+
retriable: true
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Capture the browser's precise location. Resolves to a consent-labelled result and never
|
|
602
|
+
* throws/rejects. High-accuracy first; on a no-fix (not a denial) retries once at low accuracy.
|
|
603
|
+
*/
|
|
604
|
+
async function capturePreciseLocation(consented, opts = {}) {
|
|
605
|
+
if (!consented) return { consent: "denied" };
|
|
606
|
+
const geo = opts.geolocation ?? (typeof navigator !== "undefined" ? navigator.geolocation : void 0);
|
|
607
|
+
if (!geo) return { consent: "denied" };
|
|
608
|
+
const highAccuracyMs = opts.timeoutMs ?? 8e3;
|
|
609
|
+
const lowAccuracyMs = opts.fallbackTimeoutMs ?? 12e3;
|
|
610
|
+
const first = await attemptCapture(geo, true, highAccuracyMs);
|
|
611
|
+
if (first.result.latitude !== void 0 || !first.retriable) return first.result;
|
|
612
|
+
return (await attemptCapture(geo, false, lowAccuracyMs)).result;
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/device-context.ts
|
|
616
|
+
/** Capture the browser's timezone + screen resolution. Never throws; omits anything unavailable. */
|
|
617
|
+
function captureDeviceContext() {
|
|
618
|
+
const context = {};
|
|
619
|
+
try {
|
|
620
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
621
|
+
if (typeof timezone === "string" && timezone) context.timezone = timezone;
|
|
622
|
+
} catch {}
|
|
623
|
+
try {
|
|
624
|
+
if (typeof window !== "undefined" && window.screen) {
|
|
625
|
+
const { width, height } = window.screen;
|
|
626
|
+
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) context.screenResolution = `${Math.round(width)}x${Math.round(height)}`;
|
|
627
|
+
}
|
|
628
|
+
} catch {}
|
|
629
|
+
return context;
|
|
630
|
+
}
|
|
631
|
+
//#endregion
|
|
354
632
|
//#region src/components/sign-in.tsx
|
|
355
|
-
const DEFAULT_LABELS$
|
|
633
|
+
const DEFAULT_LABELS$3 = {
|
|
356
634
|
heading: "Sign in",
|
|
357
635
|
subheading: "Enter your credentials to continue.",
|
|
358
636
|
tagline: "",
|
|
@@ -360,6 +638,8 @@ const DEFAULT_LABELS$2 = {
|
|
|
360
638
|
emailPlaceholder: "you@company.com",
|
|
361
639
|
passwordLabel: "Password",
|
|
362
640
|
passwordPlaceholder: "••••••••",
|
|
641
|
+
showPassword: "Show password",
|
|
642
|
+
hidePassword: "Hide password",
|
|
363
643
|
submit: "Sign in",
|
|
364
644
|
submitting: "Signing in…",
|
|
365
645
|
forgotPassword: "Forgot password?",
|
|
@@ -380,6 +660,77 @@ const DEFAULT_LABELS$2 = {
|
|
|
380
660
|
};
|
|
381
661
|
/** Mirrors poly-auth's own floor (Joi: min 6). */
|
|
382
662
|
const MIN_PASSWORD_LENGTH = 6;
|
|
663
|
+
function EyeIcon() {
|
|
664
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
665
|
+
viewBox: "0 0 24 24",
|
|
666
|
+
fill: "none",
|
|
667
|
+
stroke: "currentColor",
|
|
668
|
+
strokeWidth: "2",
|
|
669
|
+
strokeLinecap: "round",
|
|
670
|
+
strokeLinejoin: "round",
|
|
671
|
+
"aria-hidden": "true",
|
|
672
|
+
focusable: "false",
|
|
673
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
674
|
+
cx: "12",
|
|
675
|
+
cy: "12",
|
|
676
|
+
r: "3"
|
|
677
|
+
})]
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
function EyeOffIcon() {
|
|
681
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
682
|
+
viewBox: "0 0 24 24",
|
|
683
|
+
fill: "none",
|
|
684
|
+
stroke: "currentColor",
|
|
685
|
+
strokeWidth: "2",
|
|
686
|
+
strokeLinecap: "round",
|
|
687
|
+
strokeLinejoin: "round",
|
|
688
|
+
"aria-hidden": "true",
|
|
689
|
+
focusable: "false",
|
|
690
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M9.88 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 7 10 7a13.2 13.2 0 0 1-1.67 2.68M6.61 6.61A13.5 13.5 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.39-1.61" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
|
|
691
|
+
x1: "2",
|
|
692
|
+
y1: "2",
|
|
693
|
+
x2: "22",
|
|
694
|
+
y2: "22"
|
|
695
|
+
})]
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
/** A password input with an accessible show/hide reveal toggle (own visibility state). */
|
|
699
|
+
function PasswordField(props) {
|
|
700
|
+
const [visible, setVisible] = (0, react.useState)(false);
|
|
701
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
702
|
+
className: "polyx-signin__field",
|
|
703
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
|
|
704
|
+
className: "polyx-signin__label",
|
|
705
|
+
htmlFor: props.id,
|
|
706
|
+
children: props.label
|
|
707
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
708
|
+
className: "polyx-signin__input-wrap",
|
|
709
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
710
|
+
id: props.id,
|
|
711
|
+
className: "polyx-signin__input polyx-signin__input--has-reveal",
|
|
712
|
+
type: visible ? "text" : "password",
|
|
713
|
+
name: props.name,
|
|
714
|
+
autoComplete: props.autoComplete,
|
|
715
|
+
placeholder: props.placeholder,
|
|
716
|
+
required: true,
|
|
717
|
+
minLength: props.minLength,
|
|
718
|
+
value: props.value,
|
|
719
|
+
disabled: props.disabled,
|
|
720
|
+
onChange: (event) => props.onChange(event.target.value)
|
|
721
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
722
|
+
type: "button",
|
|
723
|
+
className: "polyx-signin__reveal",
|
|
724
|
+
"aria-pressed": visible,
|
|
725
|
+
"aria-label": visible ? props.hideLabel : props.showLabel,
|
|
726
|
+
title: visible ? props.hideLabel : props.showLabel,
|
|
727
|
+
disabled: props.disabled,
|
|
728
|
+
onClick: () => setVisible((value) => !value),
|
|
729
|
+
children: visible ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EyeOffIcon, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EyeIcon, {})
|
|
730
|
+
})]
|
|
731
|
+
})]
|
|
732
|
+
});
|
|
733
|
+
}
|
|
383
734
|
/**
|
|
384
735
|
* The embedded, Clerk-style sign-in form (D2b). Renders email/password inside the
|
|
385
736
|
* consuming app and POSTs to the SDK's BFF (`@poly-x/next` `authenticate`), which
|
|
@@ -394,7 +745,7 @@ function SignIn(props) {
|
|
|
394
745
|
const action = props.action ?? "/api/polyx/authenticate";
|
|
395
746
|
const variant = props.variant ?? "card";
|
|
396
747
|
const labels = {
|
|
397
|
-
...DEFAULT_LABELS$
|
|
748
|
+
...DEFAULT_LABELS$3,
|
|
398
749
|
...props.labels
|
|
399
750
|
};
|
|
400
751
|
const [email, setEmail] = (0, react.useState)("");
|
|
@@ -405,6 +756,29 @@ function SignIn(props) {
|
|
|
405
756
|
const [tenants, setTenants] = (0, react.useState)(null);
|
|
406
757
|
const [submitting, setSubmitting] = (0, react.useState)(false);
|
|
407
758
|
const [error, setError] = (0, react.useState)(null);
|
|
759
|
+
const polyx = (0, react.useContext)(PolyXContext);
|
|
760
|
+
const [branding, setBranding] = (0, react.useState)({});
|
|
761
|
+
(0, react.useEffect)(() => {
|
|
762
|
+
if (!polyx) return;
|
|
763
|
+
let active = true;
|
|
764
|
+
polyx.authClient.fetchBranding(props.organizationCode ? { organizationCode: props.organizationCode } : {}).then((tokens) => {
|
|
765
|
+
if (active) setBranding(tokens);
|
|
766
|
+
});
|
|
767
|
+
return () => {
|
|
768
|
+
active = false;
|
|
769
|
+
};
|
|
770
|
+
}, [polyx, props.organizationCode]);
|
|
771
|
+
const brandingStyle = {};
|
|
772
|
+
if (branding.primaryColor) brandingStyle["--polyx-brand"] = branding.primaryColor;
|
|
773
|
+
if (branding.backgroundColor) {
|
|
774
|
+
brandingStyle["--polyx-bg"] = branding.backgroundColor;
|
|
775
|
+
brandingStyle["--polyx-page-bg"] = branding.backgroundColor;
|
|
776
|
+
}
|
|
777
|
+
const brandingLogo = props.logo ?? (branding.logoUrl ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
778
|
+
className: "polyx-signin__brand-logo",
|
|
779
|
+
src: branding.logoUrl,
|
|
780
|
+
alt: branding.displayName ?? ""
|
|
781
|
+
}) : void 0);
|
|
408
782
|
/**
|
|
409
783
|
* One round-trip to the BFF. `tenantId` picks a workspace; `passwords` completes
|
|
410
784
|
* the temp-password flow (the BFF re-verifies the temporary password, sets the
|
|
@@ -414,6 +788,8 @@ function SignIn(props) {
|
|
|
414
788
|
setSubmitting(true);
|
|
415
789
|
setError(null);
|
|
416
790
|
try {
|
|
791
|
+
const geo = props.captureLocation ? await capturePreciseLocation(true) : void 0;
|
|
792
|
+
const deviceContext = captureDeviceContext();
|
|
417
793
|
const body = await (await fetch(action, {
|
|
418
794
|
method: "POST",
|
|
419
795
|
headers: { "content-type": "application/json" },
|
|
@@ -422,6 +798,8 @@ function SignIn(props) {
|
|
|
422
798
|
password,
|
|
423
799
|
organizationCode: props.organizationCode,
|
|
424
800
|
tenantId: options.tenantId,
|
|
801
|
+
...geo ? { geo } : {},
|
|
802
|
+
...Object.keys(deviceContext).length ? { deviceContext } : {},
|
|
425
803
|
...options.passwords
|
|
426
804
|
})
|
|
427
805
|
})).json().catch(() => ({}));
|
|
@@ -474,8 +852,7 @@ function SignIn(props) {
|
|
|
474
852
|
} });
|
|
475
853
|
}
|
|
476
854
|
const picking = tenants !== null;
|
|
477
|
-
const
|
|
478
|
-
const showHeaderLogo = Boolean(props.logo) && !hasPanel;
|
|
855
|
+
const showHeaderLogo = Boolean(brandingLogo) && !(variant === "page");
|
|
479
856
|
const rootClass = [
|
|
480
857
|
"polyx-signin",
|
|
481
858
|
`polyx-signin--${variant}`,
|
|
@@ -501,7 +878,7 @@ function SignIn(props) {
|
|
|
501
878
|
children: [
|
|
502
879
|
showHeaderLogo ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
503
880
|
className: "polyx-signin__logo",
|
|
504
|
-
children:
|
|
881
|
+
children: brandingLogo
|
|
505
882
|
}) : null,
|
|
506
883
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", {
|
|
507
884
|
className: "polyx-signin__heading",
|
|
@@ -546,41 +923,31 @@ function SignIn(props) {
|
|
|
546
923
|
className: "polyx-signin__form",
|
|
547
924
|
onSubmit: onSetPasswordSubmit,
|
|
548
925
|
children: [
|
|
549
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
minLength: MIN_PASSWORD_LENGTH,
|
|
562
|
-
value: newPassword,
|
|
563
|
-
disabled: submitting,
|
|
564
|
-
onChange: (event) => setNewPassword(event.target.value)
|
|
565
|
-
})]
|
|
926
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(PasswordField, {
|
|
927
|
+
id: "polyx-signin-new-password",
|
|
928
|
+
label: labels.newPasswordLabel,
|
|
929
|
+
name: "newPassword",
|
|
930
|
+
autoComplete: "new-password",
|
|
931
|
+
placeholder: labels.passwordPlaceholder,
|
|
932
|
+
minLength: MIN_PASSWORD_LENGTH,
|
|
933
|
+
value: newPassword,
|
|
934
|
+
disabled: submitting,
|
|
935
|
+
onChange: setNewPassword,
|
|
936
|
+
showLabel: labels.showPassword,
|
|
937
|
+
hideLabel: labels.hidePassword
|
|
566
938
|
}),
|
|
567
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
minLength: MIN_PASSWORD_LENGTH,
|
|
580
|
-
value: confirmPassword,
|
|
581
|
-
disabled: submitting,
|
|
582
|
-
onChange: (event) => setConfirmPassword(event.target.value)
|
|
583
|
-
})]
|
|
939
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(PasswordField, {
|
|
940
|
+
id: "polyx-signin-confirm-password",
|
|
941
|
+
label: labels.confirmPasswordLabel,
|
|
942
|
+
name: "confirmPassword",
|
|
943
|
+
autoComplete: "new-password",
|
|
944
|
+
placeholder: labels.passwordPlaceholder,
|
|
945
|
+
minLength: MIN_PASSWORD_LENGTH,
|
|
946
|
+
value: confirmPassword,
|
|
947
|
+
disabled: submitting,
|
|
948
|
+
onChange: setConfirmPassword,
|
|
949
|
+
showLabel: labels.showPassword,
|
|
950
|
+
hideLabel: labels.hidePassword
|
|
584
951
|
}),
|
|
585
952
|
errorNode,
|
|
586
953
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
@@ -616,22 +983,17 @@ function SignIn(props) {
|
|
|
616
983
|
onChange: (event) => setEmail(event.target.value)
|
|
617
984
|
})]
|
|
618
985
|
}),
|
|
619
|
-
/* @__PURE__ */ (0, react_jsx_runtime.
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
required: true,
|
|
631
|
-
value: password,
|
|
632
|
-
disabled: submitting,
|
|
633
|
-
onChange: (event) => setPassword(event.target.value)
|
|
634
|
-
})]
|
|
986
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(PasswordField, {
|
|
987
|
+
id: "polyx-signin-password",
|
|
988
|
+
label: labels.passwordLabel,
|
|
989
|
+
name: "password",
|
|
990
|
+
autoComplete: "current-password",
|
|
991
|
+
placeholder: labels.passwordPlaceholder,
|
|
992
|
+
value: password,
|
|
993
|
+
disabled: submitting,
|
|
994
|
+
onChange: setPassword,
|
|
995
|
+
showLabel: labels.showPassword,
|
|
996
|
+
hideLabel: labels.hidePassword
|
|
635
997
|
}),
|
|
636
998
|
props.forgotPasswordUrl ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
637
999
|
className: "polyx-signin__forgot",
|
|
@@ -656,11 +1018,12 @@ function SignIn(props) {
|
|
|
656
1018
|
if (variant === "page") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
657
1019
|
className: rootClass,
|
|
658
1020
|
"data-polyx-signin": step,
|
|
1021
|
+
style: brandingStyle,
|
|
659
1022
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("aside", {
|
|
660
1023
|
className: "polyx-signin__panel",
|
|
661
|
-
children: props.panel ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
1024
|
+
children: props.panel ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [brandingLogo ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
662
1025
|
className: "polyx-signin__panel-logo",
|
|
663
|
-
children:
|
|
1026
|
+
children: brandingLogo
|
|
664
1027
|
}) : null, labels.tagline ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
665
1028
|
className: "polyx-signin__tagline",
|
|
666
1029
|
children: labels.tagline
|
|
@@ -673,6 +1036,7 @@ function SignIn(props) {
|
|
|
673
1036
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
674
1037
|
className: rootClass,
|
|
675
1038
|
"data-polyx-signin": step,
|
|
1039
|
+
style: brandingStyle,
|
|
676
1040
|
children: card
|
|
677
1041
|
});
|
|
678
1042
|
}
|
|
@@ -774,7 +1138,7 @@ function RecoveryStatus(props) {
|
|
|
774
1138
|
}
|
|
775
1139
|
//#endregion
|
|
776
1140
|
//#region src/components/forgot-password.tsx
|
|
777
|
-
const DEFAULT_LABELS$
|
|
1141
|
+
const DEFAULT_LABELS$2 = {
|
|
778
1142
|
heading: "Reset your password",
|
|
779
1143
|
subheading: "Enter your email and we'll send you a reset link.",
|
|
780
1144
|
emailLabel: "Email",
|
|
@@ -782,7 +1146,7 @@ const DEFAULT_LABELS$1 = {
|
|
|
782
1146
|
submit: "Send reset link",
|
|
783
1147
|
submitting: "Sending…",
|
|
784
1148
|
sentHeading: "Check your email",
|
|
785
|
-
sentSubheading: "If an account exists for {email}, a password reset link is on its way
|
|
1149
|
+
sentSubheading: "If an account exists for {email}, a password reset link is on its way — it expires in 30 minutes. If it doesn't arrive within a few minutes, check your spam folder, then contact your workspace administrator.",
|
|
786
1150
|
differentEmail: "Use a different email",
|
|
787
1151
|
backToSignIn: "Back to sign in",
|
|
788
1152
|
securedBy: "Secured by PolyX",
|
|
@@ -809,7 +1173,7 @@ function ForgotPassword(props) {
|
|
|
809
1173
|
const action = props.action ?? "/api/polyx/forgot-password";
|
|
810
1174
|
const signInPath = props.signInPath ?? "/sign-in";
|
|
811
1175
|
const labels = {
|
|
812
|
-
...DEFAULT_LABELS$
|
|
1176
|
+
...DEFAULT_LABELS$2,
|
|
813
1177
|
...props.labels
|
|
814
1178
|
};
|
|
815
1179
|
const [email, setEmail] = (0, react.useState)("");
|
|
@@ -937,7 +1301,7 @@ function ForgotPassword(props) {
|
|
|
937
1301
|
}
|
|
938
1302
|
//#endregion
|
|
939
1303
|
//#region src/components/reset-password.tsx
|
|
940
|
-
const DEFAULT_LABELS = {
|
|
1304
|
+
const DEFAULT_LABELS$1 = {
|
|
941
1305
|
heading: "Choose a new password",
|
|
942
1306
|
subheading: "Enter and confirm your new password.",
|
|
943
1307
|
newPasswordLabel: "New password",
|
|
@@ -973,7 +1337,7 @@ function ResetPassword(props) {
|
|
|
973
1337
|
const signInPath = props.signInPath ?? "/sign-in";
|
|
974
1338
|
const forgotPasswordPath = props.forgotPasswordPath ?? "/forgot-password";
|
|
975
1339
|
const labels = {
|
|
976
|
-
...DEFAULT_LABELS,
|
|
1340
|
+
...DEFAULT_LABELS$1,
|
|
977
1341
|
...props.labels
|
|
978
1342
|
};
|
|
979
1343
|
const [token] = (0, react.useState)(() => props.token ?? (typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("token") ?? "" : ""));
|
|
@@ -1167,6 +1531,587 @@ function AuthCallback({ children = null }) {
|
|
|
1167
1531
|
return children;
|
|
1168
1532
|
}
|
|
1169
1533
|
//#endregion
|
|
1534
|
+
//#region src/components/placement.ts
|
|
1535
|
+
const OPPOSITE = {
|
|
1536
|
+
top: "bottom",
|
|
1537
|
+
bottom: "top",
|
|
1538
|
+
left: "right",
|
|
1539
|
+
right: "left"
|
|
1540
|
+
};
|
|
1541
|
+
/** Preference order when nothing is requested: vertical reads better for a menu than sideways. */
|
|
1542
|
+
const AUTO_ORDER = [
|
|
1543
|
+
"bottom",
|
|
1544
|
+
"top",
|
|
1545
|
+
"right",
|
|
1546
|
+
"left"
|
|
1547
|
+
];
|
|
1548
|
+
/** Room between the trigger and each viewport edge. */
|
|
1549
|
+
function spaceAround(trigger, viewport) {
|
|
1550
|
+
return {
|
|
1551
|
+
top: trigger.top,
|
|
1552
|
+
bottom: viewport.height - trigger.bottom,
|
|
1553
|
+
left: trigger.left,
|
|
1554
|
+
right: viewport.width - trigger.right
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
function required(menu, gap) {
|
|
1558
|
+
return {
|
|
1559
|
+
top: menu.height + gap,
|
|
1560
|
+
bottom: menu.height + gap,
|
|
1561
|
+
left: menu.width + gap,
|
|
1562
|
+
right: menu.width + gap
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Which side the menu should open on.
|
|
1567
|
+
*
|
|
1568
|
+
* An explicit side is honoured when it fits and flipped to its opposite when it doesn't — a
|
|
1569
|
+
* requested side that runs off-screen is a worse answer than the one that doesn't. `auto` takes
|
|
1570
|
+
* the first side that fits, and if none do, the roomiest: something has to be chosen, and the
|
|
1571
|
+
* least-bad option beats an arbitrary default.
|
|
1572
|
+
*/
|
|
1573
|
+
function resolveSide(preferred, trigger, menu, viewport, gap = 8) {
|
|
1574
|
+
const space = spaceAround(trigger, viewport);
|
|
1575
|
+
const need = required(menu, gap);
|
|
1576
|
+
const fits = (side) => space[side] >= need[side];
|
|
1577
|
+
if (preferred !== "auto") {
|
|
1578
|
+
if (fits(preferred)) return preferred;
|
|
1579
|
+
const opposite = OPPOSITE[preferred];
|
|
1580
|
+
return fits(opposite) ? opposite : preferred;
|
|
1581
|
+
}
|
|
1582
|
+
const firstFitting = AUTO_ORDER.find(fits);
|
|
1583
|
+
if (firstFitting) return firstFitting;
|
|
1584
|
+
return AUTO_ORDER.reduce((best, side) => space[side] > space[best] ? side : best, AUTO_ORDER[0]);
|
|
1585
|
+
}
|
|
1586
|
+
//#endregion
|
|
1587
|
+
//#region src/components/user-avatar.tsx
|
|
1588
|
+
/**
|
|
1589
|
+
* Up to two initials from a display name. Handles the shapes the platform actually
|
|
1590
|
+
* produces — "Ali Ikram", a bare username, an email address.
|
|
1591
|
+
*/
|
|
1592
|
+
function initialsFrom(name) {
|
|
1593
|
+
const source = (name ?? "").trim();
|
|
1594
|
+
if (source.length === 0) return "?";
|
|
1595
|
+
const words = (source.includes("@") ? source.split("@")[0] : source).split(/[\s._-]+/).filter(Boolean);
|
|
1596
|
+
if (words.length === 0) return "?";
|
|
1597
|
+
return (words.length === 1 ? [words[0][0]] : [words[0][0], words[words.length - 1][0]]).join("").toUpperCase();
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* The user's picture, or their initials. A missing avatar is the ordinary case rather than
|
|
1601
|
+
* an error, so initials are a first-class state, not a broken image.
|
|
1602
|
+
*/
|
|
1603
|
+
function UserAvatar({ src, name, size = 32, className }) {
|
|
1604
|
+
const rootClass = ["polyx-userbutton__avatar", className].filter(Boolean).join(" ");
|
|
1605
|
+
const style = {
|
|
1606
|
+
width: `${size}px`,
|
|
1607
|
+
height: `${size}px`
|
|
1608
|
+
};
|
|
1609
|
+
if (src) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
1610
|
+
className: rootClass,
|
|
1611
|
+
style,
|
|
1612
|
+
src,
|
|
1613
|
+
alt: name ?? ""
|
|
1614
|
+
});
|
|
1615
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1616
|
+
className: rootClass,
|
|
1617
|
+
style,
|
|
1618
|
+
"aria-hidden": "true",
|
|
1619
|
+
children: initialsFrom(name)
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
//#endregion
|
|
1623
|
+
//#region src/components/user-button.tsx
|
|
1624
|
+
const DEFAULT_LABELS = {
|
|
1625
|
+
trigger: "Account menu",
|
|
1626
|
+
manageAccount: "Manage account",
|
|
1627
|
+
signOut: "Sign out",
|
|
1628
|
+
signOutEverywhere: "Sign out everywhere"
|
|
1629
|
+
};
|
|
1630
|
+
const BUILT_INS = [
|
|
1631
|
+
"manageAccount",
|
|
1632
|
+
"signOut",
|
|
1633
|
+
"signOutEverywhere"
|
|
1634
|
+
];
|
|
1635
|
+
function isBuiltIn(label) {
|
|
1636
|
+
return BUILT_INS.includes(label);
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Declarative markers. `<UserButton/>` reads their props off the element tree and renders the
|
|
1640
|
+
* menu itself, so these never render anything. Their public prop types come from the signatures
|
|
1641
|
+
* declared on the exported object below.
|
|
1642
|
+
*
|
|
1643
|
+
* `Custom` and `Header` take arbitrary nodes on purpose: an app adopting this component in place
|
|
1644
|
+
* of its own user menu has to be able to bring its own UI, or it won't adopt it. That is
|
|
1645
|
+
* composition, not the style injection FR-BRAND-1 guards against — a consumer renders their own
|
|
1646
|
+
* subtree in a slot; they still cannot restyle the SDK's own chrome.
|
|
1647
|
+
*/
|
|
1648
|
+
function MenuItems() {
|
|
1649
|
+
return null;
|
|
1650
|
+
}
|
|
1651
|
+
function Action() {
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
function Link() {
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1657
|
+
function Custom() {
|
|
1658
|
+
return null;
|
|
1659
|
+
}
|
|
1660
|
+
function Header() {
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Read the declarative children into a flat item list. A custom entry naming a built-in
|
|
1665
|
+
* *moves* it; unnamed built-ins keep their default order at the end. This is the whole reason
|
|
1666
|
+
* the API is declarative rather than a config array: it composes without letting a consumer
|
|
1667
|
+
* inject arbitrary markup or styles into the menu (FR-BRAND-1).
|
|
1668
|
+
*/
|
|
1669
|
+
function resolveItems(children, builtIns) {
|
|
1670
|
+
const supplied = [];
|
|
1671
|
+
const placed = /* @__PURE__ */ new Set();
|
|
1672
|
+
react.Children.forEach(children, (child) => {
|
|
1673
|
+
if (!(0, react.isValidElement)(child) || child.type !== MenuItems) return;
|
|
1674
|
+
react.Children.forEach(child.props.children, (item, index) => {
|
|
1675
|
+
if (!(0, react.isValidElement)(item)) return;
|
|
1676
|
+
if (item.type === Action) {
|
|
1677
|
+
const props = item.props;
|
|
1678
|
+
if (isBuiltIn(props.label)) {
|
|
1679
|
+
const builtIn = builtIns.get(props.label);
|
|
1680
|
+
if (builtIn) {
|
|
1681
|
+
supplied.push(builtIn);
|
|
1682
|
+
placed.add(props.label);
|
|
1683
|
+
}
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
supplied.push({
|
|
1687
|
+
key: `action-${index}`,
|
|
1688
|
+
label: props.label,
|
|
1689
|
+
labelIcon: props.labelIcon,
|
|
1690
|
+
onClick: props.onClick
|
|
1691
|
+
});
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (item.type === Link) {
|
|
1695
|
+
const props = item.props;
|
|
1696
|
+
supplied.push({
|
|
1697
|
+
key: `link-${index}`,
|
|
1698
|
+
label: props.label,
|
|
1699
|
+
labelIcon: props.labelIcon,
|
|
1700
|
+
href: props.href
|
|
1701
|
+
});
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (item.type === Custom) supplied.push({
|
|
1705
|
+
key: `custom-${index}`,
|
|
1706
|
+
custom: item.props.children
|
|
1707
|
+
});
|
|
1708
|
+
});
|
|
1709
|
+
});
|
|
1710
|
+
const remaining = [...builtIns.entries()].filter(([name]) => !placed.has(name)).map(([, item]) => item);
|
|
1711
|
+
return [...supplied, ...remaining];
|
|
1712
|
+
}
|
|
1713
|
+
/** The consumer's replacement for the identity block, if they supplied one. */
|
|
1714
|
+
function findHeader(children) {
|
|
1715
|
+
let header;
|
|
1716
|
+
react.Children.forEach(children, (child) => {
|
|
1717
|
+
if ((0, react.isValidElement)(child) && child.type === Header) header = child.props.children;
|
|
1718
|
+
});
|
|
1719
|
+
return header;
|
|
1720
|
+
}
|
|
1721
|
+
function UserButtonRoot(props) {
|
|
1722
|
+
const labels = {
|
|
1723
|
+
...DEFAULT_LABELS,
|
|
1724
|
+
...props.labels
|
|
1725
|
+
};
|
|
1726
|
+
const { isLoaded, isSignedIn, signOut } = useAuth();
|
|
1727
|
+
const user = useUser();
|
|
1728
|
+
const [open, setOpen] = (0, react.useState)(props.defaultOpen ?? false);
|
|
1729
|
+
const [focused, setFocused] = (0, react.useState)(0);
|
|
1730
|
+
const [side, setSide] = (0, react.useState)(props.side && props.side !== "auto" ? props.side : "bottom");
|
|
1731
|
+
const triggerRef = (0, react.useRef)(null);
|
|
1732
|
+
const menuRef = (0, react.useRef)(null);
|
|
1733
|
+
const itemRefs = (0, react.useRef)([]);
|
|
1734
|
+
const menuId = (0, react.useId)();
|
|
1735
|
+
const align = props.align ?? "end";
|
|
1736
|
+
const close = (0, react.useCallback)((returnFocus) => {
|
|
1737
|
+
setOpen(false);
|
|
1738
|
+
if (returnFocus) triggerRef.current?.focus();
|
|
1739
|
+
}, []);
|
|
1740
|
+
const onOpenChange = props.onOpenChange;
|
|
1741
|
+
const mounted = (0, react.useRef)(false);
|
|
1742
|
+
(0, react.useEffect)(() => {
|
|
1743
|
+
if (!mounted.current) {
|
|
1744
|
+
mounted.current = true;
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
onOpenChange?.(open);
|
|
1748
|
+
}, [open, onOpenChange]);
|
|
1749
|
+
(0, react.useEffect)(() => {
|
|
1750
|
+
if (!open) return;
|
|
1751
|
+
const onKeyDown = (event) => {
|
|
1752
|
+
if (event.key === "Escape") close(true);
|
|
1753
|
+
};
|
|
1754
|
+
const onPointerDown = (event) => {
|
|
1755
|
+
const root = triggerRef.current?.closest(".polyx-userbutton");
|
|
1756
|
+
if (root && !root.contains(event.target)) close(false);
|
|
1757
|
+
};
|
|
1758
|
+
document.addEventListener("keydown", onKeyDown);
|
|
1759
|
+
document.addEventListener("mousedown", onPointerDown);
|
|
1760
|
+
return () => {
|
|
1761
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
1762
|
+
document.removeEventListener("mousedown", onPointerDown);
|
|
1763
|
+
};
|
|
1764
|
+
}, [open, close]);
|
|
1765
|
+
(0, react.useEffect)(() => {
|
|
1766
|
+
if (open) itemRefs.current[focused]?.focus();
|
|
1767
|
+
}, [open, focused]);
|
|
1768
|
+
/**
|
|
1769
|
+
* Choose the side once the menu exists and can be measured. `useLayoutEffect` runs before
|
|
1770
|
+
* paint, so the correction never shows as a jump — the menu is only ever painted where it
|
|
1771
|
+
* belongs. Re-measured on resize and on scroll, since either can invalidate the choice.
|
|
1772
|
+
*/
|
|
1773
|
+
(0, react.useLayoutEffect)(() => {
|
|
1774
|
+
if (!open) return;
|
|
1775
|
+
const place = () => {
|
|
1776
|
+
const trigger = triggerRef.current?.getBoundingClientRect();
|
|
1777
|
+
const menu = menuRef.current?.getBoundingClientRect();
|
|
1778
|
+
if (!trigger || !menu) return;
|
|
1779
|
+
setSide(resolveSide(props.side ?? "auto", trigger, {
|
|
1780
|
+
width: menu.width,
|
|
1781
|
+
height: menu.height
|
|
1782
|
+
}, {
|
|
1783
|
+
width: window.innerWidth,
|
|
1784
|
+
height: window.innerHeight
|
|
1785
|
+
}));
|
|
1786
|
+
};
|
|
1787
|
+
place();
|
|
1788
|
+
window.addEventListener("resize", place);
|
|
1789
|
+
window.addEventListener("scroll", place, true);
|
|
1790
|
+
return () => {
|
|
1791
|
+
window.removeEventListener("resize", place);
|
|
1792
|
+
window.removeEventListener("scroll", place, true);
|
|
1793
|
+
};
|
|
1794
|
+
}, [open, props.side]);
|
|
1795
|
+
if (!isLoaded) return props.fallback ?? null;
|
|
1796
|
+
if (!isSignedIn || !user) return null;
|
|
1797
|
+
const name = user.displayName ?? user.email ?? "";
|
|
1798
|
+
const builtIns = /* @__PURE__ */ new Map();
|
|
1799
|
+
if (props.manageAccountUrl) builtIns.set("manageAccount", {
|
|
1800
|
+
key: "manageAccount",
|
|
1801
|
+
label: labels.manageAccount,
|
|
1802
|
+
labelIcon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GearIcon, {}),
|
|
1803
|
+
href: props.manageAccountUrl
|
|
1804
|
+
});
|
|
1805
|
+
builtIns.set("signOut", {
|
|
1806
|
+
key: "signOut",
|
|
1807
|
+
label: labels.signOut,
|
|
1808
|
+
labelIcon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExitIcon, {}),
|
|
1809
|
+
onClick: () => void signOut({
|
|
1810
|
+
redirectTo: props.afterSignOutUrl,
|
|
1811
|
+
onSignOut: props.onSignOut
|
|
1812
|
+
})
|
|
1813
|
+
});
|
|
1814
|
+
if (props.showSignOutEverywhere) builtIns.set("signOutEverywhere", {
|
|
1815
|
+
key: "signOutEverywhere",
|
|
1816
|
+
label: labels.signOutEverywhere,
|
|
1817
|
+
labelIcon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ExitIcon, {}),
|
|
1818
|
+
onClick: () => void signOut({
|
|
1819
|
+
scope: "everywhere",
|
|
1820
|
+
redirectTo: props.afterSignOutUrl,
|
|
1821
|
+
onSignOut: props.onSignOut
|
|
1822
|
+
})
|
|
1823
|
+
});
|
|
1824
|
+
const items = resolveItems(props.children, builtIns);
|
|
1825
|
+
const customHeader = findHeader(props.children);
|
|
1826
|
+
const navigable = items.map((item, index) => ({
|
|
1827
|
+
item,
|
|
1828
|
+
index
|
|
1829
|
+
})).filter(({ item }) => !item.custom);
|
|
1830
|
+
const onMenuKeyDown = (event) => {
|
|
1831
|
+
if (navigable.length === 0) return;
|
|
1832
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
|
1833
|
+
event.preventDefault();
|
|
1834
|
+
const next = (navigable.findIndex(({ index }) => index === focused) + (event.key === "ArrowDown" ? 1 : -1) + navigable.length) % navigable.length;
|
|
1835
|
+
setFocused(navigable[next].index);
|
|
1836
|
+
};
|
|
1837
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1838
|
+
className: [
|
|
1839
|
+
"polyx-signin",
|
|
1840
|
+
"polyx-userbutton",
|
|
1841
|
+
props.className
|
|
1842
|
+
].filter(Boolean).join(" "),
|
|
1843
|
+
"data-polyx-userbutton": open ? "open" : "closed",
|
|
1844
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1845
|
+
ref: triggerRef,
|
|
1846
|
+
type: "button",
|
|
1847
|
+
className: ["polyx-userbutton__trigger", props.triggerClassName].filter(Boolean).join(" "),
|
|
1848
|
+
"aria-haspopup": "menu",
|
|
1849
|
+
"aria-expanded": open,
|
|
1850
|
+
"aria-controls": open ? menuId : void 0,
|
|
1851
|
+
"aria-label": labels.trigger,
|
|
1852
|
+
onClick: () => {
|
|
1853
|
+
setFocused(0);
|
|
1854
|
+
setOpen((current) => !current);
|
|
1855
|
+
},
|
|
1856
|
+
children: props.trigger ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
|
|
1857
|
+
src: user.avatarUrl,
|
|
1858
|
+
name,
|
|
1859
|
+
size: props.avatarSize
|
|
1860
|
+
}), props.showName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1861
|
+
className: "polyx-userbutton__trigger-name",
|
|
1862
|
+
children: name
|
|
1863
|
+
}) : null] })
|
|
1864
|
+
}), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1865
|
+
ref: menuRef,
|
|
1866
|
+
id: menuId,
|
|
1867
|
+
role: "menu",
|
|
1868
|
+
className: "polyx-userbutton__menu",
|
|
1869
|
+
"data-polyx-side": side,
|
|
1870
|
+
"data-polyx-align": align,
|
|
1871
|
+
onKeyDown: onMenuKeyDown,
|
|
1872
|
+
children: [customHeader ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1873
|
+
className: "polyx-userbutton__identity",
|
|
1874
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
|
|
1875
|
+
src: user.avatarUrl,
|
|
1876
|
+
name,
|
|
1877
|
+
size: 36
|
|
1878
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1879
|
+
className: "polyx-userbutton__identity-text",
|
|
1880
|
+
children: [user.displayName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1881
|
+
className: "polyx-userbutton__name",
|
|
1882
|
+
children: user.displayName
|
|
1883
|
+
}) : null, user.email ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1884
|
+
className: "polyx-userbutton__email",
|
|
1885
|
+
children: user.email
|
|
1886
|
+
}) : null]
|
|
1887
|
+
})]
|
|
1888
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
1889
|
+
className: "polyx-userbutton__items",
|
|
1890
|
+
children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: item.custom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1891
|
+
className: "polyx-userbutton__custom",
|
|
1892
|
+
children: item.custom
|
|
1893
|
+
}) : item.href ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
|
|
1894
|
+
ref: (node) => {
|
|
1895
|
+
itemRefs.current[index] = node;
|
|
1896
|
+
},
|
|
1897
|
+
role: "menuitem",
|
|
1898
|
+
tabIndex: index === focused ? 0 : -1,
|
|
1899
|
+
className: "polyx-userbutton__item",
|
|
1900
|
+
href: item.href,
|
|
1901
|
+
onClick: () => close(false),
|
|
1902
|
+
children: [item.labelIcon, item.label]
|
|
1903
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1904
|
+
ref: (node) => {
|
|
1905
|
+
itemRefs.current[index] = node;
|
|
1906
|
+
},
|
|
1907
|
+
role: "menuitem",
|
|
1908
|
+
tabIndex: index === focused ? 0 : -1,
|
|
1909
|
+
type: "button",
|
|
1910
|
+
className: "polyx-userbutton__item",
|
|
1911
|
+
onClick: () => {
|
|
1912
|
+
close(false);
|
|
1913
|
+
item.onClick?.();
|
|
1914
|
+
},
|
|
1915
|
+
children: [item.labelIcon, item.label]
|
|
1916
|
+
}) }, item.key))
|
|
1917
|
+
})]
|
|
1918
|
+
}) : null]
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
function GearIcon() {
|
|
1922
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
1923
|
+
className: "polyx-userbutton__icon",
|
|
1924
|
+
viewBox: "0 0 16 16",
|
|
1925
|
+
fill: "none",
|
|
1926
|
+
"aria-hidden": "true",
|
|
1927
|
+
focusable: "false",
|
|
1928
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
1929
|
+
cx: "8",
|
|
1930
|
+
cy: "8",
|
|
1931
|
+
r: "2.25",
|
|
1932
|
+
stroke: "currentColor",
|
|
1933
|
+
strokeWidth: "1.3"
|
|
1934
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
1935
|
+
d: "M8 1.75v1.5M8 12.75v1.5M14.25 8h-1.5M3.25 8h-1.5M12.42 3.58l-1.06 1.06M4.64 11.36l-1.06 1.06M12.42 12.42l-1.06-1.06M4.64 4.64L3.58 3.58",
|
|
1936
|
+
stroke: "currentColor",
|
|
1937
|
+
strokeWidth: "1.3",
|
|
1938
|
+
strokeLinecap: "round"
|
|
1939
|
+
})]
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
function ExitIcon() {
|
|
1943
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
1944
|
+
className: "polyx-userbutton__icon",
|
|
1945
|
+
viewBox: "0 0 16 16",
|
|
1946
|
+
fill: "none",
|
|
1947
|
+
"aria-hidden": "true",
|
|
1948
|
+
focusable: "false",
|
|
1949
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
1950
|
+
d: "M6 2.75H3.75a1 1 0 0 0-1 1v8.5a1 1 0 0 0 1 1H6M10.5 10.5 13 8l-2.5-2.5M13 8H6",
|
|
1951
|
+
stroke: "currentColor",
|
|
1952
|
+
strokeWidth: "1.3",
|
|
1953
|
+
strokeLinecap: "round",
|
|
1954
|
+
strokeLinejoin: "round"
|
|
1955
|
+
})
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* The signed-in user surface: avatar trigger + a menu with their identity and sign-out
|
|
1960
|
+
* (FR-SIGNIN-6). Reads only the hooks, so it behaves identically under either custody model
|
|
1961
|
+
* — an httpOnly BFF session or an in-memory SPA one — and needs no provider in a
|
|
1962
|
+
* `@poly-x/next` app.
|
|
1963
|
+
*
|
|
1964
|
+
* Custom entries compose declaratively:
|
|
1965
|
+
*
|
|
1966
|
+
* ```tsx
|
|
1967
|
+
* <UserButton manageAccountUrl="/account">
|
|
1968
|
+
* <UserButton.MenuItems>
|
|
1969
|
+
* <UserButton.Action label="signOut" /> // reposition a built-in
|
|
1970
|
+
* <UserButton.Link label="Docs" href="/docs" />
|
|
1971
|
+
* </UserButton.MenuItems>
|
|
1972
|
+
* </UserButton>
|
|
1973
|
+
* ```
|
|
1974
|
+
*/
|
|
1975
|
+
const UserButton = Object.assign(UserButtonRoot, {
|
|
1976
|
+
MenuItems,
|
|
1977
|
+
Action,
|
|
1978
|
+
Link,
|
|
1979
|
+
Custom,
|
|
1980
|
+
Header
|
|
1981
|
+
});
|
|
1982
|
+
//#endregion
|
|
1983
|
+
//#region src/live-authz.ts
|
|
1984
|
+
const DEFAULT_POLL_MS = 6e4;
|
|
1985
|
+
const DEFAULT_FOCUS_THROTTLE_MS = 5e3;
|
|
1986
|
+
/** Wire the triggers and return a handle. Safe to call with no window/document (SSR → no-op wiring). */
|
|
1987
|
+
function startLiveAuthz(opts) {
|
|
1988
|
+
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
1989
|
+
const throttleMs = opts.focusThrottleMs ?? DEFAULT_FOCUS_THROTTLE_MS;
|
|
1990
|
+
const win = opts.win ?? (typeof window !== "undefined" ? window : void 0);
|
|
1991
|
+
const doc = opts.doc ?? (typeof document !== "undefined" ? document : void 0);
|
|
1992
|
+
let stopped = false;
|
|
1993
|
+
let inFlight = false;
|
|
1994
|
+
let pending = false;
|
|
1995
|
+
let lastRefreshAt = -Infinity;
|
|
1996
|
+
/**
|
|
1997
|
+
* Single-flight refresh (FR-LIVE-5). `coalesce` = queue exactly one follow-up run if a refresh
|
|
1998
|
+
* is already in flight — used by the explicit push signal so a change mid-refresh isn't missed.
|
|
1999
|
+
* Ambient triggers (focus/online/poll) pass `false`: if a refresh is already running, drop —
|
|
2000
|
+
* they carry no new information the in-flight run won't already pick up.
|
|
2001
|
+
*/
|
|
2002
|
+
async function run(coalesce) {
|
|
2003
|
+
if (stopped) return;
|
|
2004
|
+
if (inFlight) {
|
|
2005
|
+
if (coalesce) pending = true;
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
inFlight = true;
|
|
2009
|
+
try {
|
|
2010
|
+
let changed = false;
|
|
2011
|
+
try {
|
|
2012
|
+
changed = await opts.refresh();
|
|
2013
|
+
} catch {}
|
|
2014
|
+
lastRefreshAt = Date.now();
|
|
2015
|
+
if (changed && !stopped) opts.onChanged?.();
|
|
2016
|
+
} finally {
|
|
2017
|
+
inFlight = false;
|
|
2018
|
+
if (pending && !stopped) {
|
|
2019
|
+
pending = false;
|
|
2020
|
+
run(true);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
/** Throttled, non-queuing refresh for ambient triggers (focus/visible/online). */
|
|
2025
|
+
function throttledRun() {
|
|
2026
|
+
if (Date.now() - lastRefreshAt >= throttleMs) run(false);
|
|
2027
|
+
}
|
|
2028
|
+
const isVisible = () => !doc || doc.visibilityState !== "hidden";
|
|
2029
|
+
const onFocus = () => throttledRun();
|
|
2030
|
+
const onOnline = () => throttledRun();
|
|
2031
|
+
const onVisibility = () => {
|
|
2032
|
+
if (isVisible()) throttledRun();
|
|
2033
|
+
};
|
|
2034
|
+
if (win) {
|
|
2035
|
+
win.addEventListener("focus", onFocus);
|
|
2036
|
+
win.addEventListener("online", onOnline);
|
|
2037
|
+
}
|
|
2038
|
+
if (doc) doc.addEventListener("visibilitychange", onVisibility);
|
|
2039
|
+
const unsubscribeSignal = opts.subscribeSignal?.(() => void run(true));
|
|
2040
|
+
let pollTimer;
|
|
2041
|
+
if (pollMs > 0) pollTimer = setInterval(() => {
|
|
2042
|
+
if (isVisible()) run(false);
|
|
2043
|
+
}, pollMs);
|
|
2044
|
+
return {
|
|
2045
|
+
refreshNow: () => void run(true),
|
|
2046
|
+
stop: () => {
|
|
2047
|
+
if (stopped) return;
|
|
2048
|
+
stopped = true;
|
|
2049
|
+
if (win) {
|
|
2050
|
+
win.removeEventListener("focus", onFocus);
|
|
2051
|
+
win.removeEventListener("online", onOnline);
|
|
2052
|
+
}
|
|
2053
|
+
if (doc) doc.removeEventListener("visibilitychange", onVisibility);
|
|
2054
|
+
unsubscribeSignal?.();
|
|
2055
|
+
if (pollTimer !== void 0) clearInterval(pollTimer);
|
|
2056
|
+
}
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
//#endregion
|
|
2060
|
+
//#region src/live-session.ts
|
|
2061
|
+
/**
|
|
2062
|
+
* @poly-x/react/live-session — the React glue that keeps the BFF session live (SDK v2).
|
|
2063
|
+
*
|
|
2064
|
+
* `useLiveAuthz` starts the framework-agnostic orchestrator (`startLiveAuthz`, F013) bound to the
|
|
2065
|
+
* BFF session store's `refresh()`: the platform `authz:changed` signal (when a `subscribeSignal`
|
|
2066
|
+
* transport is supplied) + window focus/reconnect + a ~60s safety poll → a single-flight
|
|
2067
|
+
* re-introspect that updates reactive `useUser()`. `useAuthzChanged` lets an app with its own guard
|
|
2068
|
+
* layer react to a change (FR-REACT-1). Apps that gate on `useUser()` re-guard automatically
|
|
2069
|
+
* (FR-REACT-2) — they need no callback.
|
|
2070
|
+
*
|
|
2071
|
+
* The `subscribeSignal` transport is optional: without it the passive triggers still converge
|
|
2072
|
+
* (FR-SIG-4). The default poly-alert transport is provided separately (a later slice) once the
|
|
2073
|
+
* subscription-credential custody decision lands.
|
|
2074
|
+
*/
|
|
2075
|
+
const changeListeners = /* @__PURE__ */ new Set();
|
|
2076
|
+
function emitChanged() {
|
|
2077
|
+
for (const listener of changeListeners) listener();
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Start the live-authz orchestrator for this app's BFF session. Mount once (e.g. in the app root).
|
|
2081
|
+
* Returns nothing; drives `useUser()` reactivity + fires `useAuthzChanged` callbacks on a change.
|
|
2082
|
+
*/
|
|
2083
|
+
function useLiveAuthz(options = {}) {
|
|
2084
|
+
const { subscribeSignal, pollMs, focusThrottleMs } = options;
|
|
2085
|
+
(0, react.useEffect)(() => {
|
|
2086
|
+
const store = getBffSessionStore();
|
|
2087
|
+
const handle = startLiveAuthz({
|
|
2088
|
+
refresh: () => store.refresh(),
|
|
2089
|
+
onChanged: emitChanged,
|
|
2090
|
+
subscribeSignal,
|
|
2091
|
+
pollMs,
|
|
2092
|
+
focusThrottleMs
|
|
2093
|
+
});
|
|
2094
|
+
return () => handle.stop();
|
|
2095
|
+
}, [
|
|
2096
|
+
subscribeSignal,
|
|
2097
|
+
pollMs,
|
|
2098
|
+
focusThrottleMs
|
|
2099
|
+
]);
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Register a callback fired when the user's authorization changes (FR-REACT-1) — for apps that keep
|
|
2103
|
+
* their own ability/route model and need to re-run their guard or refetch view data. Apps that gate
|
|
2104
|
+
* on `useUser()` do not need this. Requires `useLiveAuthz` to be mounted somewhere in the tree.
|
|
2105
|
+
*/
|
|
2106
|
+
function useAuthzChanged(callback) {
|
|
2107
|
+
(0, react.useEffect)(() => {
|
|
2108
|
+
changeListeners.add(callback);
|
|
2109
|
+
return () => {
|
|
2110
|
+
changeListeners.delete(callback);
|
|
2111
|
+
};
|
|
2112
|
+
}, [callback]);
|
|
2113
|
+
}
|
|
2114
|
+
//#endregion
|
|
1170
2115
|
//#region src/index.ts
|
|
1171
2116
|
/**
|
|
1172
2117
|
* @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
|
|
@@ -1187,11 +2132,19 @@ exports.Protected = Protected;
|
|
|
1187
2132
|
exports.ResetPassword = ResetPassword;
|
|
1188
2133
|
exports.SessionStoragePkceStore = SessionStoragePkceStore;
|
|
1189
2134
|
exports.SignIn = SignIn;
|
|
2135
|
+
exports.UserAvatar = UserAvatar;
|
|
2136
|
+
exports.UserButton = UserButton;
|
|
1190
2137
|
exports.WebLocksLock = WebLocksLock;
|
|
1191
2138
|
exports.WindowBrowserBridge = WindowBrowserBridge;
|
|
2139
|
+
exports.capturePreciseLocation = capturePreciseLocation;
|
|
1192
2140
|
exports.createLoginController = createLoginController;
|
|
2141
|
+
exports.initialsFrom = initialsFrom;
|
|
2142
|
+
exports.resolveSide = resolveSide;
|
|
2143
|
+
exports.startLiveAuthz = startLiveAuthz;
|
|
1193
2144
|
exports.useAuth = useAuth;
|
|
1194
2145
|
exports.useAuthCallback = useAuthCallback;
|
|
2146
|
+
exports.useAuthzChanged = useAuthzChanged;
|
|
2147
|
+
exports.useLiveAuthz = useLiveAuthz;
|
|
1195
2148
|
exports.useSession = useSession;
|
|
1196
2149
|
exports.useUser = useUser;
|
|
1197
2150
|
exports.version = version;
|