@poly-x/react 0.1.0-alpha.9 → 0.1.1

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 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 usePolyX() {
484
+ function useSessionSource() {
305
485
  const ctx = (0, react.useContext)(PolyXContext);
306
- if (!ctx) throw new Error("PolyX hooks (useAuth/useUser/useSession) must be used within a <PolyXProvider>.");
307
- return ctx;
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 { engine } = usePolyX();
312
- const subscribe = (0, react.useCallback)((onChange) => engine.subscribe(onChange), [engine]);
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 { engine, controller } = usePolyX();
513
+ const ctx = (0, react.useContext)(PolyXContext);
514
+ const { source } = useSessionSource();
318
515
  const state = useSession();
319
- const signIn = (0, react.useCallback)((options) => controller.signIn(options), [controller]);
320
- const signOut = (0, react.useCallback)(() => engine.signOut(), [engine]);
321
- const getToken = (0, react.useCallback)(async () => {
322
- try {
323
- return await engine.getAccessToken();
324
- } catch {
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 { controller } = usePolyX();
339
- return (0, react.useCallback)(() => controller.handleCallback(), [controller]);
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$2 = {
633
+ const DEFAULT_LABELS$3 = {
356
634
  heading: "Sign in",
357
635
  subheading: "Enter your credentials to continue.",
358
636
  tagline: "",
@@ -467,7 +745,7 @@ function SignIn(props) {
467
745
  const action = props.action ?? "/api/polyx/authenticate";
468
746
  const variant = props.variant ?? "card";
469
747
  const labels = {
470
- ...DEFAULT_LABELS$2,
748
+ ...DEFAULT_LABELS$3,
471
749
  ...props.labels
472
750
  };
473
751
  const [email, setEmail] = (0, react.useState)("");
@@ -510,6 +788,8 @@ function SignIn(props) {
510
788
  setSubmitting(true);
511
789
  setError(null);
512
790
  try {
791
+ const geo = props.captureLocation ? await capturePreciseLocation(true) : void 0;
792
+ const deviceContext = captureDeviceContext();
513
793
  const body = await (await fetch(action, {
514
794
  method: "POST",
515
795
  headers: { "content-type": "application/json" },
@@ -518,6 +798,8 @@ function SignIn(props) {
518
798
  password,
519
799
  organizationCode: props.organizationCode,
520
800
  tenantId: options.tenantId,
801
+ ...geo ? { geo } : {},
802
+ ...Object.keys(deviceContext).length ? { deviceContext } : {},
521
803
  ...options.passwords
522
804
  })
523
805
  })).json().catch(() => ({}));
@@ -856,7 +1138,7 @@ function RecoveryStatus(props) {
856
1138
  }
857
1139
  //#endregion
858
1140
  //#region src/components/forgot-password.tsx
859
- const DEFAULT_LABELS$1 = {
1141
+ const DEFAULT_LABELS$2 = {
860
1142
  heading: "Reset your password",
861
1143
  subheading: "Enter your email and we'll send you a reset link.",
862
1144
  emailLabel: "Email",
@@ -864,7 +1146,7 @@ const DEFAULT_LABELS$1 = {
864
1146
  submit: "Send reset link",
865
1147
  submitting: "Sending…",
866
1148
  sentHeading: "Check your email",
867
- sentSubheading: "If an account exists for {email}, a password reset link is on its way. It expires in 30 minutes.",
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.",
868
1150
  differentEmail: "Use a different email",
869
1151
  backToSignIn: "Back to sign in",
870
1152
  securedBy: "Secured by PolyX",
@@ -891,7 +1173,7 @@ function ForgotPassword(props) {
891
1173
  const action = props.action ?? "/api/polyx/forgot-password";
892
1174
  const signInPath = props.signInPath ?? "/sign-in";
893
1175
  const labels = {
894
- ...DEFAULT_LABELS$1,
1176
+ ...DEFAULT_LABELS$2,
895
1177
  ...props.labels
896
1178
  };
897
1179
  const [email, setEmail] = (0, react.useState)("");
@@ -1019,7 +1301,7 @@ function ForgotPassword(props) {
1019
1301
  }
1020
1302
  //#endregion
1021
1303
  //#region src/components/reset-password.tsx
1022
- const DEFAULT_LABELS = {
1304
+ const DEFAULT_LABELS$1 = {
1023
1305
  heading: "Choose a new password",
1024
1306
  subheading: "Enter and confirm your new password.",
1025
1307
  newPasswordLabel: "New password",
@@ -1055,7 +1337,7 @@ function ResetPassword(props) {
1055
1337
  const signInPath = props.signInPath ?? "/sign-in";
1056
1338
  const forgotPasswordPath = props.forgotPasswordPath ?? "/forgot-password";
1057
1339
  const labels = {
1058
- ...DEFAULT_LABELS,
1340
+ ...DEFAULT_LABELS$1,
1059
1341
  ...props.labels
1060
1342
  };
1061
1343
  const [token] = (0, react.useState)(() => props.token ?? (typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("token") ?? "" : ""));
@@ -1249,6 +1531,587 @@ function AuthCallback({ children = null }) {
1249
1531
  return children;
1250
1532
  }
1251
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
1252
2115
  //#region src/index.ts
1253
2116
  /**
1254
2117
  * @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
@@ -1269,11 +2132,19 @@ exports.Protected = Protected;
1269
2132
  exports.ResetPassword = ResetPassword;
1270
2133
  exports.SessionStoragePkceStore = SessionStoragePkceStore;
1271
2134
  exports.SignIn = SignIn;
2135
+ exports.UserAvatar = UserAvatar;
2136
+ exports.UserButton = UserButton;
1272
2137
  exports.WebLocksLock = WebLocksLock;
1273
2138
  exports.WindowBrowserBridge = WindowBrowserBridge;
2139
+ exports.capturePreciseLocation = capturePreciseLocation;
1274
2140
  exports.createLoginController = createLoginController;
2141
+ exports.initialsFrom = initialsFrom;
2142
+ exports.resolveSide = resolveSide;
2143
+ exports.startLiveAuthz = startLiveAuthz;
1275
2144
  exports.useAuth = useAuth;
1276
2145
  exports.useAuthCallback = useAuthCallback;
2146
+ exports.useAuthzChanged = useAuthzChanged;
2147
+ exports.useLiveAuthz = useLiveAuthz;
1277
2148
  exports.useSession = useSession;
1278
2149
  exports.useUser = useUser;
1279
2150
  exports.version = version;