@poly-x/react 0.1.1 → 0.3.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 CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  let _poly_x_core = require("@poly-x/core");
4
4
  let react = require("react");
5
5
  let react_jsx_runtime = require("react/jsx-runtime");
6
+ let react_dom = require("react-dom");
6
7
  //#region src/auth-channel.ts
7
8
  var BroadcastAuthChannel = class {
8
9
  supported;
@@ -199,7 +200,7 @@ var BroadcastChannelSessionChannel = class {
199
200
  //#endregion
200
201
  //#region src/seams/session-storage-pkce.ts
201
202
  const KEY_PREFIX = "poly-x.pkce.";
202
- function hasSessionStorage() {
203
+ function hasSessionStorage$1() {
203
204
  try {
204
205
  return typeof sessionStorage !== "undefined";
205
206
  } catch {
@@ -215,7 +216,7 @@ function hasSessionStorage() {
215
216
  var SessionStoragePkceStore = class {
216
217
  fallback;
217
218
  constructor() {
218
- if (!hasSessionStorage()) this.fallback = new _poly_x_core.InMemoryPkceStore();
219
+ if (!hasSessionStorage$1()) this.fallback = new _poly_x_core.InMemoryPkceStore();
219
220
  }
220
221
  save(entry) {
221
222
  if (this.fallback) return this.fallback.save(entry);
@@ -480,6 +481,198 @@ function getBffSessionStore() {
480
481
  return store;
481
482
  }
482
483
  //#endregion
484
+ //#region src/warm-hop.ts
485
+ /**
486
+ * Warm hop — the browser half (F027/F028 · FR-HOP-1…6, FR-FALL-1…3, FR-SWITCH-1…7).
487
+ *
488
+ * The probe is made **from here, by the browser**, and that is not a style choice. The
489
+ * platform resolves the ecosystem session from a cookie on its own domain; a server-side
490
+ * request carries no browser cookie, so it would be told "sign-in required" every time and
491
+ * the whole feature would look built while never working. `@poly-x/core` refuses the
492
+ * server-side call outright for that reason.
493
+ *
494
+ * The three steps, and why the credential stays safe throughout:
495
+ *
496
+ * 1. Ask our own BFF to prepare (`/api/polyx/hop`). It mints PKCE and keeps the verifier
497
+ * in an httpOnly cookie; we receive only a URL.
498
+ * 2. Call that URL with `credentials:"include"` so the browser attaches the ecosystem
499
+ * cookie. This is the one request that must not come from a server.
500
+ * 3. Hand the resulting `code` back to our BFF (`/api/polyx/hop-complete`), which does the
501
+ * exchange. The code is worthless to anyone who intercepts it — the PKCE verifier that
502
+ * redeems it never left the server — so a page holding it briefly is not a custody leak.
503
+ *
504
+ * Nothing here navigates. That matters beyond tidiness: this exact flow produced a redirect
505
+ * loop in a hand-rolled implementation once already, and a fetch cannot loop.
506
+ */
507
+ const HOP_ENDPOINT = "/api/polyx/hop";
508
+ const HOP_COMPLETE_ENDPOINT = "/api/polyx/hop-complete";
509
+ /**
510
+ * How long to wait for the silent attempt before giving up and showing the form.
511
+ *
512
+ * A guess, and labelled as one. The requirement it serves (FR-FALL-2) is that a slow or
513
+ * dead platform must never leave someone watching a spinner — and for that, *any* bound
514
+ * beats none. The person this protects is already signed out and gains nothing from
515
+ * waiting, so the bound is deliberately short. Override it if you have measured yours.
516
+ */
517
+ const WARM_HOP_TIMEOUT_MS = 5e3;
518
+ /**
519
+ * Survives navigation on purpose (FR-FALL-3).
520
+ *
521
+ * The fall-back from a failed attempt renders the sign-in page, which in most apps is a
522
+ * route change — so a guard held in React state or a module variable is reset exactly when
523
+ * it is needed. `sessionStorage` is scoped to the tab and cleared when it closes, which is
524
+ * the same lifetime as "this entry".
525
+ */
526
+ const ATTEMPTED_KEY = "polyx.warmHop.attempted";
527
+ function hasSessionStorage() {
528
+ try {
529
+ return typeof sessionStorage !== "undefined";
530
+ } catch {
531
+ return false;
532
+ }
533
+ }
534
+ /** Has an automatic attempt already run in this tab? */
535
+ function warmHopAttempted() {
536
+ if (!hasSessionStorage()) return false;
537
+ try {
538
+ return sessionStorage.getItem(ATTEMPTED_KEY) === "1";
539
+ } catch {
540
+ return false;
541
+ }
542
+ }
543
+ function markWarmHopAttempted() {
544
+ if (!hasSessionStorage()) return;
545
+ try {
546
+ sessionStorage.setItem(ATTEMPTED_KEY, "1");
547
+ } catch {}
548
+ }
549
+ /** Clear the once-per-entry marker. Exported for sign-out, so the next sign-in may try again. */
550
+ function resetWarmHop() {
551
+ if (!hasSessionStorage()) return;
552
+ try {
553
+ sessionStorage.removeItem(ATTEMPTED_KEY);
554
+ } catch {}
555
+ }
556
+ async function postJson(url, body, signal) {
557
+ return fetch(url, {
558
+ method: "POST",
559
+ headers: { "content-type": "application/json" },
560
+ body: JSON.stringify(body ?? {}),
561
+ signal,
562
+ credentials: "same-origin"
563
+ });
564
+ }
565
+ /**
566
+ * Run one silent attempt and report what the platform said.
567
+ *
568
+ * Never throws: every failure resolves to `failed`, because a caller in the middle of
569
+ * rendering a sign-in page has no better answer than "show the form" and an exception
570
+ * there is how a blank screen happens.
571
+ */
572
+ async function attemptWarmHop(options = {}) {
573
+ if (typeof window === "undefined") return {
574
+ status: "failed",
575
+ reason: "warm hop attempted outside a browser"
576
+ };
577
+ const controller = new AbortController();
578
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 5e3);
579
+ try {
580
+ const prepared = await postJson(HOP_ENDPOINT, options.workspace ? { tenantId: options.workspace } : {}, controller.signal);
581
+ if (!prepared.ok) return {
582
+ status: "failed",
583
+ reason: `hop preparation failed (${prepared.status})`
584
+ };
585
+ const { authorizeUrl } = await prepared.json();
586
+ if (!authorizeUrl) return {
587
+ status: "failed",
588
+ reason: "hop preparation returned no authorize URL"
589
+ };
590
+ const probe = await fetch(authorizeUrl, {
591
+ method: "GET",
592
+ credentials: "include",
593
+ headers: { accept: "application/json" },
594
+ signal: controller.signal
595
+ });
596
+ const body = await probe.json().catch(() => ({}));
597
+ const outcome = (0, _poly_x_core.parseAuthorizeOutcome)(probe.status, body);
598
+ switch (outcome.type) {
599
+ case "login_required": return { status: "sign_in_required" };
600
+ case "no_access": return { status: "no_access" };
601
+ case "select_tenant": return {
602
+ status: "select_workspace",
603
+ workspaces: outcome.tenants
604
+ };
605
+ case "authorized": {
606
+ const completed = await postJson(HOP_COMPLETE_ENDPOINT, {
607
+ code: outcome.code,
608
+ state: outcome.state
609
+ }, controller.signal);
610
+ const settled = await completed.json().catch(() => ({}));
611
+ if (completed.ok) {
612
+ if (settled.status === "workspace_mismatch" && settled.requested && settled.workspace) return {
613
+ status: "workspace_mismatch",
614
+ requested: settled.requested,
615
+ workspace: settled.workspace
616
+ };
617
+ return {
618
+ status: "signed_in",
619
+ ...settled.workspace ? { workspace: settled.workspace } : {}
620
+ };
621
+ }
622
+ return {
623
+ status: "failed",
624
+ reason: settled.status ?? `exchange failed (${completed.status})`
625
+ };
626
+ }
627
+ }
628
+ } catch (error) {
629
+ return {
630
+ status: "failed",
631
+ reason: error instanceof Error ? error.message : "warm hop failed"
632
+ };
633
+ } finally {
634
+ clearTimeout(timeout);
635
+ }
636
+ }
637
+ /**
638
+ * Move to another workspace (F028 / FR-SWITCH-1…6).
639
+ *
640
+ * The same mechanism as the entry attempt, aimed deliberately rather than on arrival — and
641
+ * deliberately **not** subject to the once-per-entry guard, because this one is a thing the
642
+ * person asked for.
643
+ *
644
+ * Eligibility is decided by the platform at this moment, not from any list held here, so a
645
+ * workspace revoked since the picker was drawn can never be entered (FR-SWITCH-4). Which
646
+ * refusal you get depends on what is left, verified against a live platform: still holding
647
+ * other workspaces gives `select_workspace` listing them; having lost them all gives
648
+ * `no_access`. Do not treat `no_access` as the only refusal.
649
+ *
650
+ * On `signed_in` the session cookie now names the new workspace. The caller decides what to
651
+ * do next — the SDK does not reload the page or discard anything on their behalf, because it
652
+ * cannot know what is unsaved on the screen (FR-SWITCH-7).
653
+ */
654
+ async function switchWorkspace(workspace, options = {}) {
655
+ if (!workspace) return {
656
+ status: "failed",
657
+ reason: "no workspace given"
658
+ };
659
+ return attemptWarmHop({
660
+ ...options,
661
+ workspace
662
+ });
663
+ }
664
+ /**
665
+ * The automatic entry attempt: at most once per tab, whatever the caller does (FR-HOP-2).
666
+ *
667
+ * Returns `null` when it declines to attempt — already tried this entry — so a caller can
668
+ * tell "we tried and they must sign in" from "we did not try".
669
+ */
670
+ async function attemptWarmHopOnce(options = {}) {
671
+ if (warmHopAttempted()) return null;
672
+ markWarmHopAttempted();
673
+ return attemptWarmHop(options);
674
+ }
675
+ //#endregion
483
676
  //#region src/hooks.ts
484
677
  function useSessionSource() {
485
678
  const ctx = (0, react.useContext)(PolyXContext);
@@ -499,6 +692,14 @@ function useSessionSource() {
499
692
  } catch {
500
693
  return null;
501
694
  }
695
+ },
696
+ refresh: async () => {
697
+ try {
698
+ await engine.getAccessToken();
699
+ return true;
700
+ } catch {
701
+ return false;
702
+ }
502
703
  }
503
704
  },
504
705
  canSignIn: Boolean(controller)
@@ -519,12 +720,14 @@ function useAuth() {
519
720
  }, [ctx]);
520
721
  const signOut = (0, react.useCallback)((options) => source.signOut(options), [source]);
521
722
  const getToken = (0, react.useCallback)(() => source.getToken(), [source]);
723
+ const refresh = (0, react.useCallback)(() => source.refresh(), [source]);
522
724
  return {
523
725
  isLoaded: state.status !== "resolving",
524
726
  isSignedIn: state.status === "authenticated" || state.status === "refreshing",
525
727
  signIn,
526
728
  signOut,
527
- getToken
729
+ getToken,
730
+ refresh
528
731
  };
529
732
  }
530
733
  /** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
@@ -539,6 +742,52 @@ function useUser() {
539
742
  const state = useSession();
540
743
  return state.status === "authenticated" || state.status === "refreshing" ? state.session.claims : null;
541
744
  }
745
+ /**
746
+ * Try a silent sign-in once when this component mounts (F027 / FR-HOP-2, FR-FALL-1…3).
747
+ *
748
+ * The contract that matters is D11's: **an attempt that fails must be invisible.** Someone who
749
+ * is genuinely signed out pays the whole cost of this feature and gets nothing from it, so they
750
+ * must not see a flicker, a spinner that outlives their patience, or — worst — a redirect loop.
751
+ * Hence: no navigation anywhere in the flow, a hard timeout, and a guard that survives the route
752
+ * change to the sign-in page (a guard in React state would reset exactly when it is needed).
753
+ *
754
+ * ```tsx
755
+ * const hop = useWarmHop();
756
+ * if (hop.status !== "settled" ) return null; // no half-drawn form
757
+ * if (hop.result.status === "signed_in") return <Redirect/>;
758
+ * return <SignIn />; // the ordinary path
759
+ * ```
760
+ *
761
+ * Strict Mode's double-mount does not produce two attempts: the marker is written before the
762
+ * first request goes out.
763
+ */
764
+ function useWarmHop(options = {}) {
765
+ const { enabled = true, workspace, timeoutMs } = options;
766
+ const [state, setState] = (0, react.useState)({ status: "idle" });
767
+ (0, react.useEffect)(() => {
768
+ if (!enabled) return;
769
+ let live = true;
770
+ setState({ status: "attempting" });
771
+ attemptWarmHopOnce({
772
+ workspace,
773
+ timeoutMs
774
+ }).then((result) => {
775
+ if (!live) return;
776
+ setState({
777
+ status: "settled",
778
+ result: result ?? { status: "sign_in_required" }
779
+ });
780
+ });
781
+ return () => {
782
+ live = false;
783
+ };
784
+ }, [
785
+ enabled,
786
+ workspace,
787
+ timeoutMs
788
+ ]);
789
+ return state;
790
+ }
542
791
  //#endregion
543
792
  //#region src/components/protected.tsx
544
793
  /** Gate content on authentication state without flashing the wrong branch. */
@@ -630,7 +879,7 @@ function captureDeviceContext() {
630
879
  }
631
880
  //#endregion
632
881
  //#region src/components/sign-in.tsx
633
- const DEFAULT_LABELS$3 = {
882
+ const DEFAULT_LABELS$4 = {
634
883
  heading: "Sign in",
635
884
  subheading: "Enter your credentials to continue.",
636
885
  tagline: "",
@@ -656,7 +905,8 @@ const DEFAULT_LABELS$3 = {
656
905
  invalidCredentials: "Invalid email or password.",
657
906
  noAccess: "You don't have access to this application.",
658
907
  passwordChangeRequired: "You must reset your password before you can sign in.",
659
- genericError: "Something went wrong. Please try again."
908
+ genericError: "Something went wrong. Please try again.",
909
+ verificationRequired: "Additional verification is required to sign in."
660
910
  };
661
911
  /** Mirrors poly-auth's own floor (Joi: min 6). */
662
912
  const MIN_PASSWORD_LENGTH = 6;
@@ -745,7 +995,7 @@ function SignIn(props) {
745
995
  const action = props.action ?? "/api/polyx/authenticate";
746
996
  const variant = props.variant ?? "card";
747
997
  const labels = {
748
- ...DEFAULT_LABELS$3,
998
+ ...DEFAULT_LABELS$4,
749
999
  ...props.labels
750
1000
  };
751
1001
  const [email, setEmail] = (0, react.useState)("");
@@ -826,6 +1076,9 @@ function SignIn(props) {
826
1076
  case "no_access":
827
1077
  setError(labels.noAccess);
828
1078
  return;
1079
+ case "verification_required":
1080
+ setError(labels.verificationRequired ?? labels.genericError);
1081
+ return;
829
1082
  default:
830
1083
  setError(labels.genericError);
831
1084
  return;
@@ -1138,7 +1391,7 @@ function RecoveryStatus(props) {
1138
1391
  }
1139
1392
  //#endregion
1140
1393
  //#region src/components/forgot-password.tsx
1141
- const DEFAULT_LABELS$2 = {
1394
+ const DEFAULT_LABELS$3 = {
1142
1395
  heading: "Reset your password",
1143
1396
  subheading: "Enter your email and we'll send you a reset link.",
1144
1397
  emailLabel: "Email",
@@ -1173,7 +1426,7 @@ function ForgotPassword(props) {
1173
1426
  const action = props.action ?? "/api/polyx/forgot-password";
1174
1427
  const signInPath = props.signInPath ?? "/sign-in";
1175
1428
  const labels = {
1176
- ...DEFAULT_LABELS$2,
1429
+ ...DEFAULT_LABELS$3,
1177
1430
  ...props.labels
1178
1431
  };
1179
1432
  const [email, setEmail] = (0, react.useState)("");
@@ -1301,7 +1554,7 @@ function ForgotPassword(props) {
1301
1554
  }
1302
1555
  //#endregion
1303
1556
  //#region src/components/reset-password.tsx
1304
- const DEFAULT_LABELS$1 = {
1557
+ const DEFAULT_LABELS$2 = {
1305
1558
  heading: "Choose a new password",
1306
1559
  subheading: "Enter and confirm your new password.",
1307
1560
  newPasswordLabel: "New password",
@@ -1337,7 +1590,7 @@ function ResetPassword(props) {
1337
1590
  const signInPath = props.signInPath ?? "/sign-in";
1338
1591
  const forgotPasswordPath = props.forgotPasswordPath ?? "/forgot-password";
1339
1592
  const labels = {
1340
- ...DEFAULT_LABELS$1,
1593
+ ...DEFAULT_LABELS$2,
1341
1594
  ...props.labels
1342
1595
  };
1343
1596
  const [token] = (0, react.useState)(() => props.token ?? (typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("token") ?? "" : ""));
@@ -1620,6 +1873,511 @@ function UserAvatar({ src, name, size = 32, className }) {
1620
1873
  });
1621
1874
  }
1622
1875
  //#endregion
1876
+ //#region src/components/user-profile.tsx
1877
+ const PROFILE_ENDPOINT = "/api/polyx/profile";
1878
+ const DEFAULT_LABELS$1 = {
1879
+ trigger: "Manage profile",
1880
+ title: "Your profile",
1881
+ description: "Update your personal details.",
1882
+ firstName: "First name",
1883
+ lastName: "Last name",
1884
+ phoneNumber: "Phone number",
1885
+ cnic: "National ID",
1886
+ gender: "Gender",
1887
+ photo: "Photo",
1888
+ changePhoto: "Change photo",
1889
+ optional: "optional",
1890
+ save: "Save changes",
1891
+ saving: "Saving…",
1892
+ cancel: "Cancel",
1893
+ close: "Close",
1894
+ loading: "Loading your profile…",
1895
+ loadFailed: "We couldn't load your profile. Close this and try again.",
1896
+ saveFailed: "We couldn't save your changes. Your edits are still here — try again.",
1897
+ saved: "Profile updated.",
1898
+ signedOut: "Your session ended. Sign in again to make changes.",
1899
+ genderUnset: "Prefer not to say",
1900
+ genderMale: "Male",
1901
+ genderFemale: "Female",
1902
+ genderOther: "Other"
1903
+ };
1904
+ /** Matches the console's options, so the same stored values round-trip. */
1905
+ const GENDERS = [
1906
+ "male",
1907
+ "female",
1908
+ "other"
1909
+ ];
1910
+ const EMPTY = {
1911
+ firstName: "",
1912
+ lastName: "",
1913
+ phoneNumber: "",
1914
+ cnic: "",
1915
+ gender: ""
1916
+ };
1917
+ function readProfile(user) {
1918
+ const draft = { ...EMPTY };
1919
+ for (const field of _poly_x_core.PROFILE_FIELDS) {
1920
+ const value = user[field];
1921
+ if (typeof value === "string") draft[field] = value;
1922
+ }
1923
+ return draft;
1924
+ }
1925
+ /**
1926
+ * The signed-in person's own profile, as a dialog (v0.4 / FR-PROF).
1927
+ *
1928
+ * Every application that wanted this had to build the same four things: a form, a validation
1929
+ * ruleset guessed at from error messages, a file upload, and — because the browser holds no
1930
+ * credential under BFF custody — a server route to carry the call. The last of those is
1931
+ * security-adjacent work that had no business being duplicated per app.
1932
+ *
1933
+ * The credential never comes near this component: it posts to the SDK's own route, which
1934
+ * attaches the session server-side and takes the identity from the sealed session rather than
1935
+ * from anything sent here. This surface cannot be aimed at another person's profile even if a
1936
+ * caller tries (FR-CUSTODY-4 / D24).
1937
+ *
1938
+ * ```tsx
1939
+ * <UserProfile onSaved={() => router.refresh()} />
1940
+ * ```
1941
+ */
1942
+ function UserProfile(props) {
1943
+ const labels = {
1944
+ ...DEFAULT_LABELS$1,
1945
+ ...props.labels
1946
+ };
1947
+ const { isLoaded, isSignedIn, refresh } = useAuth();
1948
+ const controlled = props.open !== void 0;
1949
+ const [internalOpen, setInternalOpen] = (0, react.useState)(props.defaultOpen ?? false);
1950
+ const open = controlled ? props.open === true : internalOpen;
1951
+ const [status, setStatus] = (0, react.useState)("loading");
1952
+ const [stored, setStored] = (0, react.useState)(EMPTY);
1953
+ const [draft, setDraft] = (0, react.useState)(EMPTY);
1954
+ const [errors, setErrors] = (0, react.useState)({});
1955
+ const [photo, setPhoto] = (0, react.useState)(null);
1956
+ const [photoError, setPhotoError] = (0, react.useState)(null);
1957
+ const [storedPhoto, setStoredPhoto] = (0, react.useState)(void 0);
1958
+ const [saving, setSaving] = (0, react.useState)(false);
1959
+ const [feedback, setFeedback] = (0, react.useState)(null);
1960
+ const [portalHost, setPortalHost] = (0, react.useState)(null);
1961
+ (0, react.useEffect)(() => {
1962
+ const host = document.createElement("div");
1963
+ host.setAttribute("data-polyx-userprofile-portal", "");
1964
+ document.body.appendChild(host);
1965
+ setPortalHost(host);
1966
+ return () => host.remove();
1967
+ }, []);
1968
+ const triggerRef = (0, react.useRef)(null);
1969
+ const dialogRef = (0, react.useRef)(null);
1970
+ const fileRef = (0, react.useRef)(null);
1971
+ const titleId = (0, react.useId)();
1972
+ const fieldId = (0, react.useId)();
1973
+ const onOpenChange = props.onOpenChange;
1974
+ const setOpen = (0, react.useCallback)((next) => {
1975
+ if (!controlled) setInternalOpen(next);
1976
+ onOpenChange?.(next);
1977
+ }, [controlled, onOpenChange]);
1978
+ const polyx = (0, react.useContext)(PolyXContext);
1979
+ const [branding, setBranding] = (0, react.useState)({});
1980
+ (0, react.useEffect)(() => {
1981
+ if (!polyx || !open) return;
1982
+ let active = true;
1983
+ polyx.authClient.fetchBranding({}).then((tokens) => {
1984
+ if (active) setBranding(tokens);
1985
+ });
1986
+ return () => {
1987
+ active = false;
1988
+ };
1989
+ }, [polyx, open]);
1990
+ const brandingStyle = {};
1991
+ if (branding.primaryColor) brandingStyle["--polyx-brand"] = branding.primaryColor;
1992
+ /** Load on open. Never leaves blank fields looking like a stored (empty) profile — EC-PROF-1. */
1993
+ (0, react.useEffect)(() => {
1994
+ if (!open) return;
1995
+ let active = true;
1996
+ setStatus("loading");
1997
+ setFeedback(null);
1998
+ (async () => {
1999
+ try {
2000
+ const response = await fetch(PROFILE_ENDPOINT, { headers: { Accept: "application/json" } });
2001
+ const body = await response.json().catch(() => ({}));
2002
+ if (!active) return;
2003
+ if (!response.ok || !body.user) {
2004
+ setStatus("load_failed");
2005
+ return;
2006
+ }
2007
+ const loaded = readProfile(body.user);
2008
+ setStored(loaded);
2009
+ setDraft(loaded);
2010
+ setStoredPhoto(typeof body.user.profilePicture === "string" ? body.user.profilePicture : void 0);
2011
+ setErrors({});
2012
+ setStatus("ready");
2013
+ } catch {
2014
+ if (active) setStatus("load_failed");
2015
+ }
2016
+ })();
2017
+ return () => {
2018
+ active = false;
2019
+ };
2020
+ }, [open]);
2021
+ (0, react.useEffect)(() => {
2022
+ const preview = photo?.preview;
2023
+ return () => {
2024
+ if (preview) URL.revokeObjectURL(preview);
2025
+ };
2026
+ }, [photo]);
2027
+ /**
2028
+ * Hide the rest of the page from assistive technology while the dialog is open.
2029
+ *
2030
+ * `aria-modal="true"` alone is not enough — screen-reader support for it is inconsistent, so a
2031
+ * virtual cursor can still read and act on everything behind the dialog. The keyboard focus trap
2032
+ * does not cover this either: it constrains Tab, not the virtual cursor. Without both, the dialog
2033
+ * announces itself as modal and then does not behave modally.
2034
+ *
2035
+ * Every direct child of `<body>` except the dialog's own portal is marked, and only attributes
2036
+ * this effect added are removed again — a host that set its own `aria-hidden` keeps it.
2037
+ */
2038
+ (0, react.useEffect)(() => {
2039
+ if (!open || !portalHost) return;
2040
+ const marked = [];
2041
+ for (const node of Array.from(document.body.children)) {
2042
+ if (node === portalHost || !(node instanceof HTMLElement)) continue;
2043
+ if (node.hasAttribute("aria-hidden")) continue;
2044
+ node.setAttribute("aria-hidden", "true");
2045
+ node.setAttribute("inert", "");
2046
+ marked.push(node);
2047
+ }
2048
+ return () => {
2049
+ for (const node of marked) {
2050
+ node.removeAttribute("aria-hidden");
2051
+ node.removeAttribute("inert");
2052
+ }
2053
+ };
2054
+ }, [open, portalHost]);
2055
+ /**
2056
+ * Move focus into the dialog once there is something to focus (FR-A11Y-1).
2057
+ *
2058
+ * Deliberately keyed on `status`, not just `open`: the dialog opens in its loading state with
2059
+ * no focusable content, so placing focus at open time silently leaves it on the page behind —
2060
+ * which is exactly nowhere for anyone navigating by keyboard or screen reader.
2061
+ */
2062
+ (0, react.useEffect)(() => {
2063
+ if (!open || status === "loading") return;
2064
+ dialogRef.current?.querySelector("input, select, button")?.focus();
2065
+ }, [open, status]);
2066
+ /** Escape closes; focus is trapped while open (FR-A11Y-2/3). */
2067
+ (0, react.useEffect)(() => {
2068
+ if (!open) return;
2069
+ const node = dialogRef.current;
2070
+ const previous = document.activeElement;
2071
+ const onKeyDown = (event) => {
2072
+ if (event.key === "Escape") {
2073
+ setOpen(false);
2074
+ return;
2075
+ }
2076
+ if (event.key !== "Tab" || !node) return;
2077
+ const focusable = [...node.querySelectorAll("input, select, button, a[href]")].filter((element) => !element.hasAttribute("disabled"));
2078
+ if (focusable.length === 0) return;
2079
+ const first = focusable[0];
2080
+ const last = focusable[focusable.length - 1];
2081
+ if (event.shiftKey && document.activeElement === first) {
2082
+ event.preventDefault();
2083
+ last.focus();
2084
+ } else if (!event.shiftKey && document.activeElement === last) {
2085
+ event.preventDefault();
2086
+ first.focus();
2087
+ }
2088
+ };
2089
+ document.addEventListener("keydown", onKeyDown);
2090
+ return () => {
2091
+ document.removeEventListener("keydown", onKeyDown);
2092
+ (triggerRef.current ?? previous)?.focus();
2093
+ };
2094
+ }, [open, setOpen]);
2095
+ function set(field, value) {
2096
+ setDraft((current) => ({
2097
+ ...current,
2098
+ [field]: value
2099
+ }));
2100
+ setErrors((current) => ({
2101
+ ...current,
2102
+ [field]: void 0
2103
+ }));
2104
+ setFeedback(null);
2105
+ }
2106
+ /** Validate on leaving a field, so a mistake is caught next to where it was made (FR-VALID-8). */
2107
+ function blur(field) {
2108
+ const found = (0, _poly_x_core.validateProfileDraft)(draft)[field];
2109
+ setErrors((current) => ({
2110
+ ...current,
2111
+ [field]: found
2112
+ }));
2113
+ }
2114
+ function choosePhoto(file) {
2115
+ if (!file) return;
2116
+ const invalid = (0, _poly_x_core.validateProfileImage)(file);
2117
+ if (invalid) {
2118
+ setPhotoError(invalid);
2119
+ setPhoto(null);
2120
+ return;
2121
+ }
2122
+ setPhotoError(null);
2123
+ setPhoto({
2124
+ file,
2125
+ preview: URL.createObjectURL(file)
2126
+ });
2127
+ setFeedback(null);
2128
+ }
2129
+ async function save() {
2130
+ if (saving) return;
2131
+ const found = (0, _poly_x_core.validateProfileDraft)(draft);
2132
+ if (Object.keys(found).length > 0) {
2133
+ setErrors(found);
2134
+ return;
2135
+ }
2136
+ const changed = (0, _poly_x_core.buildProfileUpdate)(draft, stored);
2137
+ if (Object.keys(changed).length === 0 && !photo) {
2138
+ setOpen(false);
2139
+ return;
2140
+ }
2141
+ const body = new FormData();
2142
+ for (const [field, value] of Object.entries(changed)) body.append(field, value);
2143
+ if (photo) body.append("image", photo.file);
2144
+ setSaving(true);
2145
+ setFeedback(null);
2146
+ try {
2147
+ const response = await fetch(PROFILE_ENDPOINT, {
2148
+ method: "POST",
2149
+ body
2150
+ });
2151
+ const payload = await response.json().catch(() => ({}));
2152
+ if (response.status === 401) {
2153
+ setFeedback({
2154
+ kind: "error",
2155
+ message: labels.signedOut
2156
+ });
2157
+ return;
2158
+ }
2159
+ if (!response.ok) {
2160
+ const field = (0, _poly_x_core.conflictField)(payload.message);
2161
+ if (field) {
2162
+ setErrors((current) => ({
2163
+ ...current,
2164
+ [field]: payload.message
2165
+ }));
2166
+ return;
2167
+ }
2168
+ setFeedback({
2169
+ kind: "error",
2170
+ message: payload.message ?? labels.saveFailed
2171
+ });
2172
+ return;
2173
+ }
2174
+ const saved = payload.user ?? {};
2175
+ setStored(readProfile(saved));
2176
+ if (typeof saved.profilePicture === "string") setStoredPhoto(saved.profilePicture);
2177
+ setPhoto(null);
2178
+ setFeedback({
2179
+ kind: "success",
2180
+ message: labels.saved
2181
+ });
2182
+ await refresh().catch(() => false);
2183
+ props.onSaved?.(saved);
2184
+ setOpen(false);
2185
+ } catch {
2186
+ setFeedback({
2187
+ kind: "error",
2188
+ message: labels.saveFailed
2189
+ });
2190
+ } finally {
2191
+ setSaving(false);
2192
+ }
2193
+ }
2194
+ if (!isLoaded || !isSignedIn) return null;
2195
+ const rootClass = [
2196
+ "polyx-signin",
2197
+ "polyx-userprofile",
2198
+ props.className
2199
+ ].filter(Boolean).join(" ");
2200
+ const displayName = [draft.firstName, draft.lastName].filter(Boolean).join(" ");
2201
+ function field(name, label, extra = {}) {
2202
+ const id = `${fieldId}-${name}`;
2203
+ const error = errors[name];
2204
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2205
+ className: "polyx-signin__field",
2206
+ children: [
2207
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2208
+ className: "polyx-signin__label",
2209
+ htmlFor: id,
2210
+ children: [label, extra.optional ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2211
+ className: "polyx-userprofile__optional",
2212
+ children: [
2213
+ " (",
2214
+ labels.optional,
2215
+ ")"
2216
+ ]
2217
+ }) : null]
2218
+ }),
2219
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2220
+ id,
2221
+ className: "polyx-signin__input",
2222
+ type: extra.type ?? "text",
2223
+ value: draft[name] ?? "",
2224
+ placeholder: extra.placeholder,
2225
+ disabled: saving,
2226
+ "aria-required": extra.optional ? void 0 : true,
2227
+ "aria-invalid": error ? true : void 0,
2228
+ "aria-describedby": error ? `${id}-error` : void 0,
2229
+ onChange: (event) => set(name, event.target.value),
2230
+ onBlur: () => blur(name)
2231
+ }),
2232
+ error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2233
+ id: `${id}-error`,
2234
+ className: "polyx-userprofile__field-error",
2235
+ children: error
2236
+ }) : null
2237
+ ]
2238
+ });
2239
+ }
2240
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2241
+ className: rootClass,
2242
+ style: brandingStyle,
2243
+ children: [props.open === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2244
+ ref: triggerRef,
2245
+ type: "button",
2246
+ className: ["polyx-userprofile__trigger", props.triggerClassName].filter(Boolean).join(" "),
2247
+ onClick: () => setOpen(true),
2248
+ children: props.trigger ?? labels.trigger
2249
+ }) : null, open && portalHost ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2250
+ className: "polyx-signin polyx-userprofile polyx-userprofile__overlay",
2251
+ style: brandingStyle,
2252
+ onMouseDown: () => setOpen(false),
2253
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2254
+ ref: dialogRef,
2255
+ role: "dialog",
2256
+ "aria-modal": "true",
2257
+ "aria-labelledby": titleId,
2258
+ className: ["polyx-userprofile__dialog", props.dialogClassName].filter(Boolean).join(" "),
2259
+ onMouseDown: (event) => event.stopPropagation(),
2260
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2261
+ className: "polyx-userprofile__header",
2262
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
2263
+ id: titleId,
2264
+ className: "polyx-userprofile__title",
2265
+ children: labels.title
2266
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2267
+ className: "polyx-userprofile__description",
2268
+ children: labels.description
2269
+ })]
2270
+ }), status === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2271
+ className: "polyx-userprofile__status",
2272
+ role: "status",
2273
+ children: labels.loading
2274
+ }) : status === "load_failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2275
+ className: "polyx-signin__error",
2276
+ role: "alert",
2277
+ children: labels.loadFailed
2278
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
2279
+ className: "polyx-signin__form",
2280
+ onSubmit: (event) => {
2281
+ event.preventDefault();
2282
+ save();
2283
+ },
2284
+ children: [
2285
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2286
+ className: "polyx-userprofile__photo",
2287
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
2288
+ src: photo?.preview ?? storedPhoto,
2289
+ name: displayName,
2290
+ size: 56
2291
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2292
+ className: "polyx-userprofile__photo-actions",
2293
+ children: [
2294
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2295
+ type: "button",
2296
+ className: "polyx-userprofile__photo-button",
2297
+ disabled: saving,
2298
+ onClick: () => fileRef.current?.click(),
2299
+ children: labels.changePhoto
2300
+ }),
2301
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2302
+ ref: fileRef,
2303
+ type: "file",
2304
+ accept: "image/*",
2305
+ className: "polyx-userprofile__file",
2306
+ "aria-label": labels.photo,
2307
+ onChange: (event) => choosePhoto(event.target.files?.[0])
2308
+ }),
2309
+ photoError ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2310
+ className: "polyx-userprofile__field-error",
2311
+ children: photoError
2312
+ }) : null
2313
+ ]
2314
+ })]
2315
+ }),
2316
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2317
+ className: "polyx-userprofile__row",
2318
+ children: [field("firstName", labels.firstName), field("lastName", labels.lastName)]
2319
+ }),
2320
+ field("phoneNumber", labels.phoneNumber, {
2321
+ optional: true,
2322
+ type: "tel",
2323
+ placeholder: "+14155552671"
2324
+ }),
2325
+ field("cnic", labels.cnic, { optional: true }),
2326
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2327
+ className: "polyx-signin__field",
2328
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2329
+ className: "polyx-signin__label",
2330
+ htmlFor: `${fieldId}-gender`,
2331
+ children: [labels.gender, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2332
+ className: "polyx-userprofile__optional",
2333
+ children: [
2334
+ " (",
2335
+ labels.optional,
2336
+ ")"
2337
+ ]
2338
+ })]
2339
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2340
+ id: `${fieldId}-gender`,
2341
+ className: "polyx-signin__input",
2342
+ value: draft.gender ?? "",
2343
+ disabled: saving,
2344
+ onChange: (event) => set("gender", event.target.value),
2345
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2346
+ value: "",
2347
+ children: labels.genderUnset
2348
+ }), GENDERS.map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2349
+ value,
2350
+ children: value === "male" ? labels.genderMale : value === "female" ? labels.genderFemale : labels.genderOther
2351
+ }, value))]
2352
+ })]
2353
+ }),
2354
+ feedback ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2355
+ className: feedback.kind === "error" ? "polyx-signin__error" : "polyx-userprofile__success",
2356
+ role: feedback.kind === "error" ? "alert" : "status",
2357
+ children: feedback.message
2358
+ }) : null,
2359
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2360
+ className: "polyx-userprofile__actions",
2361
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2362
+ type: "button",
2363
+ className: "polyx-userprofile__cancel",
2364
+ disabled: saving,
2365
+ onClick: () => setOpen(false),
2366
+ children: labels.cancel
2367
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2368
+ type: "submit",
2369
+ className: "polyx-signin__submit",
2370
+ disabled: saving,
2371
+ children: saving ? labels.saving : labels.save
2372
+ })]
2373
+ })
2374
+ ]
2375
+ })]
2376
+ })
2377
+ }), portalHost) : null]
2378
+ });
2379
+ }
2380
+ //#endregion
1623
2381
  //#region src/components/user-button.tsx
1624
2382
  const DEFAULT_LABELS = {
1625
2383
  trigger: "Account menu",
@@ -1726,6 +2484,7 @@ function UserButtonRoot(props) {
1726
2484
  const { isLoaded, isSignedIn, signOut } = useAuth();
1727
2485
  const user = useUser();
1728
2486
  const [open, setOpen] = (0, react.useState)(props.defaultOpen ?? false);
2487
+ const [profileOpen, setProfileOpen] = (0, react.useState)(false);
1729
2488
  const [focused, setFocused] = (0, react.useState)(0);
1730
2489
  const [side, setSide] = (0, react.useState)(props.side && props.side !== "auto" ? props.side : "bottom");
1731
2490
  const triggerRef = (0, react.useRef)(null);
@@ -1802,6 +2561,12 @@ function UserButtonRoot(props) {
1802
2561
  labelIcon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GearIcon, {}),
1803
2562
  href: props.manageAccountUrl
1804
2563
  });
2564
+ else if (props.showUserProfile) builtIns.set("manageAccount", {
2565
+ key: "manageAccount",
2566
+ label: labels.manageAccount,
2567
+ labelIcon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GearIcon, {}),
2568
+ onClick: () => setProfileOpen(true)
2569
+ });
1805
2570
  builtIns.set("signOut", {
1806
2571
  key: "signOut",
1807
2572
  label: labels.signOut,
@@ -1841,81 +2606,89 @@ function UserButtonRoot(props) {
1841
2606
  props.className
1842
2607
  ].filter(Boolean).join(" "),
1843
2608
  "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, {
2609
+ children: [
2610
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2611
+ ref: triggerRef,
2612
+ type: "button",
2613
+ className: ["polyx-userbutton__trigger", props.triggerClassName].filter(Boolean).join(" "),
2614
+ "aria-haspopup": "menu",
2615
+ "aria-expanded": open,
2616
+ "aria-controls": open ? menuId : void 0,
2617
+ "aria-label": labels.trigger,
2618
+ onClick: () => {
2619
+ setFocused(0);
2620
+ setOpen((current) => !current);
2621
+ },
2622
+ children: props.trigger ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
1875
2623
  src: user.avatarUrl,
1876
2624
  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]
2625
+ size: props.avatarSize
2626
+ }), props.showName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2627
+ className: "polyx-userbutton__trigger-name",
2628
+ children: name
2629
+ }) : null] })
2630
+ }),
2631
+ open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2632
+ ref: menuRef,
2633
+ id: menuId,
2634
+ role: "menu",
2635
+ className: "polyx-userbutton__menu",
2636
+ "data-polyx-side": side,
2637
+ "data-polyx-align": align,
2638
+ onKeyDown: onMenuKeyDown,
2639
+ children: [customHeader ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2640
+ className: "polyx-userbutton__identity",
2641
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
2642
+ src: user.avatarUrl,
2643
+ name,
2644
+ size: 36
2645
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2646
+ className: "polyx-userbutton__identity-text",
2647
+ children: [user.displayName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2648
+ className: "polyx-userbutton__name",
2649
+ children: user.displayName
2650
+ }) : null, user.email ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2651
+ className: "polyx-userbutton__email",
2652
+ children: user.email
2653
+ }) : null]
2654
+ })]
2655
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2656
+ className: "polyx-userbutton__items",
2657
+ children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: item.custom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2658
+ className: "polyx-userbutton__custom",
2659
+ children: item.custom
2660
+ }) : item.href ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
2661
+ ref: (node) => {
2662
+ itemRefs.current[index] = node;
2663
+ },
2664
+ role: "menuitem",
2665
+ tabIndex: index === focused ? 0 : -1,
2666
+ className: "polyx-userbutton__item",
2667
+ href: item.href,
2668
+ onClick: () => close(false),
2669
+ children: [item.labelIcon, item.label]
2670
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2671
+ ref: (node) => {
2672
+ itemRefs.current[index] = node;
2673
+ },
2674
+ role: "menuitem",
2675
+ tabIndex: index === focused ? 0 : -1,
2676
+ type: "button",
2677
+ className: "polyx-userbutton__item",
2678
+ onClick: () => {
2679
+ close(false);
2680
+ item.onClick?.();
2681
+ },
2682
+ children: [item.labelIcon, item.label]
2683
+ }) }, item.key))
1887
2684
  })]
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]
2685
+ }) : null,
2686
+ props.showUserProfile && !props.manageAccountUrl ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserProfile, {
2687
+ ...props.userProfileProps,
2688
+ open: profileOpen,
2689
+ onOpenChange: setProfileOpen
2690
+ }) : null
2691
+ ]
1919
2692
  });
1920
2693
  }
1921
2694
  function GearIcon() {
@@ -1980,6 +2753,33 @@ const UserButton = Object.assign(UserButtonRoot, {
1980
2753
  Header
1981
2754
  });
1982
2755
  //#endregion
2756
+ //#region src/auth-event.ts
2757
+ /**
2758
+ * Read a one-shot auth event published by the BFF (F017 / FR-CLIENT-5).
2759
+ *
2760
+ * Sign-in and sign-out are full-page navigations, so an app cannot simply react in the
2761
+ * handler that triggered them — the page is gone. The BFF leaves a short-lived, readable
2762
+ * cookie; the destination page consumes it once.
2763
+ */
2764
+ /** Must match `AUTH_EVENT_COOKIE` in `@poly-x/next`. */
2765
+ const AUTH_EVENT_COOKIE = "polyx_auth_event";
2766
+ function readCookie(name) {
2767
+ if (typeof document === "undefined") return void 0;
2768
+ return document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1);
2769
+ }
2770
+ /**
2771
+ * Read the pending auth event and clear it, so it fires exactly once.
2772
+ *
2773
+ * Returns `null` when there is nothing pending — which is the normal case on almost every
2774
+ * page load. Safe to call during render or in an effect; it is a no-op on the server.
2775
+ */
2776
+ function consumeAuthEvent() {
2777
+ const value = readCookie(AUTH_EVENT_COOKIE);
2778
+ if (value !== "signed-in" && value !== "signed-out") return null;
2779
+ document.cookie = `${AUTH_EVENT_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`;
2780
+ return value;
2781
+ }
2782
+ //#endregion
1983
2783
  //#region src/live-authz.ts
1984
2784
  const DEFAULT_POLL_MS = 6e4;
1985
2785
  const DEFAULT_FOCUS_THROTTLE_MS = 5e3;
@@ -2118,7 +2918,8 @@ function useAuthzChanged(callback) {
2118
2918
  * F006; this entry is the state foundation (provider, hooks, browser seams).
2119
2919
  */
2120
2920
  const PACKAGE_NAME = "@poly-x/react";
2121
- const version = "0.0.0";
2921
+ /** The published version of this package, injected at build time. */
2922
+ const version = "0.3.0";
2122
2923
  //#endregion
2123
2924
  exports.AuthCallback = AuthCallback;
2124
2925
  exports.BroadcastAuthChannel = BroadcastAuthChannel;
@@ -2134,17 +2935,26 @@ exports.SessionStoragePkceStore = SessionStoragePkceStore;
2134
2935
  exports.SignIn = SignIn;
2135
2936
  exports.UserAvatar = UserAvatar;
2136
2937
  exports.UserButton = UserButton;
2938
+ exports.UserProfile = UserProfile;
2939
+ exports.WARM_HOP_TIMEOUT_MS = WARM_HOP_TIMEOUT_MS;
2137
2940
  exports.WebLocksLock = WebLocksLock;
2138
2941
  exports.WindowBrowserBridge = WindowBrowserBridge;
2942
+ exports.attemptWarmHop = attemptWarmHop;
2943
+ exports.attemptWarmHopOnce = attemptWarmHopOnce;
2139
2944
  exports.capturePreciseLocation = capturePreciseLocation;
2945
+ exports.consumeAuthEvent = consumeAuthEvent;
2140
2946
  exports.createLoginController = createLoginController;
2141
2947
  exports.initialsFrom = initialsFrom;
2948
+ exports.resetWarmHop = resetWarmHop;
2142
2949
  exports.resolveSide = resolveSide;
2143
2950
  exports.startLiveAuthz = startLiveAuthz;
2951
+ exports.switchWorkspace = switchWorkspace;
2144
2952
  exports.useAuth = useAuth;
2145
2953
  exports.useAuthCallback = useAuthCallback;
2146
2954
  exports.useAuthzChanged = useAuthzChanged;
2147
2955
  exports.useLiveAuthz = useLiveAuthz;
2148
2956
  exports.useSession = useSession;
2149
2957
  exports.useUser = useUser;
2958
+ exports.useWarmHop = useWarmHop;
2150
2959
  exports.version = version;
2960
+ exports.warmHopAttempted = warmHopAttempted;