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