@poly-x/react 0.1.1 → 0.2.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;
@@ -499,6 +500,14 @@ function useSessionSource() {
499
500
  } catch {
500
501
  return null;
501
502
  }
503
+ },
504
+ refresh: async () => {
505
+ try {
506
+ await engine.getAccessToken();
507
+ return true;
508
+ } catch {
509
+ return false;
510
+ }
502
511
  }
503
512
  },
504
513
  canSignIn: Boolean(controller)
@@ -519,12 +528,14 @@ function useAuth() {
519
528
  }, [ctx]);
520
529
  const signOut = (0, react.useCallback)((options) => source.signOut(options), [source]);
521
530
  const getToken = (0, react.useCallback)(() => source.getToken(), [source]);
531
+ const refresh = (0, react.useCallback)(() => source.refresh(), [source]);
522
532
  return {
523
533
  isLoaded: state.status !== "resolving",
524
534
  isSignedIn: state.status === "authenticated" || state.status === "refreshing",
525
535
  signIn,
526
536
  signOut,
527
- getToken
537
+ getToken,
538
+ refresh
528
539
  };
529
540
  }
530
541
  /** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
@@ -630,7 +641,7 @@ function captureDeviceContext() {
630
641
  }
631
642
  //#endregion
632
643
  //#region src/components/sign-in.tsx
633
- const DEFAULT_LABELS$3 = {
644
+ const DEFAULT_LABELS$4 = {
634
645
  heading: "Sign in",
635
646
  subheading: "Enter your credentials to continue.",
636
647
  tagline: "",
@@ -656,7 +667,8 @@ const DEFAULT_LABELS$3 = {
656
667
  invalidCredentials: "Invalid email or password.",
657
668
  noAccess: "You don't have access to this application.",
658
669
  passwordChangeRequired: "You must reset your password before you can sign in.",
659
- genericError: "Something went wrong. Please try again."
670
+ genericError: "Something went wrong. Please try again.",
671
+ verificationRequired: "Additional verification is required to sign in."
660
672
  };
661
673
  /** Mirrors poly-auth's own floor (Joi: min 6). */
662
674
  const MIN_PASSWORD_LENGTH = 6;
@@ -745,7 +757,7 @@ function SignIn(props) {
745
757
  const action = props.action ?? "/api/polyx/authenticate";
746
758
  const variant = props.variant ?? "card";
747
759
  const labels = {
748
- ...DEFAULT_LABELS$3,
760
+ ...DEFAULT_LABELS$4,
749
761
  ...props.labels
750
762
  };
751
763
  const [email, setEmail] = (0, react.useState)("");
@@ -826,6 +838,9 @@ function SignIn(props) {
826
838
  case "no_access":
827
839
  setError(labels.noAccess);
828
840
  return;
841
+ case "verification_required":
842
+ setError(labels.verificationRequired ?? labels.genericError);
843
+ return;
829
844
  default:
830
845
  setError(labels.genericError);
831
846
  return;
@@ -1138,7 +1153,7 @@ function RecoveryStatus(props) {
1138
1153
  }
1139
1154
  //#endregion
1140
1155
  //#region src/components/forgot-password.tsx
1141
- const DEFAULT_LABELS$2 = {
1156
+ const DEFAULT_LABELS$3 = {
1142
1157
  heading: "Reset your password",
1143
1158
  subheading: "Enter your email and we'll send you a reset link.",
1144
1159
  emailLabel: "Email",
@@ -1173,7 +1188,7 @@ function ForgotPassword(props) {
1173
1188
  const action = props.action ?? "/api/polyx/forgot-password";
1174
1189
  const signInPath = props.signInPath ?? "/sign-in";
1175
1190
  const labels = {
1176
- ...DEFAULT_LABELS$2,
1191
+ ...DEFAULT_LABELS$3,
1177
1192
  ...props.labels
1178
1193
  };
1179
1194
  const [email, setEmail] = (0, react.useState)("");
@@ -1301,7 +1316,7 @@ function ForgotPassword(props) {
1301
1316
  }
1302
1317
  //#endregion
1303
1318
  //#region src/components/reset-password.tsx
1304
- const DEFAULT_LABELS$1 = {
1319
+ const DEFAULT_LABELS$2 = {
1305
1320
  heading: "Choose a new password",
1306
1321
  subheading: "Enter and confirm your new password.",
1307
1322
  newPasswordLabel: "New password",
@@ -1337,7 +1352,7 @@ function ResetPassword(props) {
1337
1352
  const signInPath = props.signInPath ?? "/sign-in";
1338
1353
  const forgotPasswordPath = props.forgotPasswordPath ?? "/forgot-password";
1339
1354
  const labels = {
1340
- ...DEFAULT_LABELS$1,
1355
+ ...DEFAULT_LABELS$2,
1341
1356
  ...props.labels
1342
1357
  };
1343
1358
  const [token] = (0, react.useState)(() => props.token ?? (typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("token") ?? "" : ""));
@@ -1620,6 +1635,511 @@ function UserAvatar({ src, name, size = 32, className }) {
1620
1635
  });
1621
1636
  }
1622
1637
  //#endregion
1638
+ //#region src/components/user-profile.tsx
1639
+ const PROFILE_ENDPOINT = "/api/polyx/profile";
1640
+ const DEFAULT_LABELS$1 = {
1641
+ trigger: "Manage profile",
1642
+ title: "Your profile",
1643
+ description: "Update your personal details.",
1644
+ firstName: "First name",
1645
+ lastName: "Last name",
1646
+ phoneNumber: "Phone number",
1647
+ cnic: "National ID",
1648
+ gender: "Gender",
1649
+ photo: "Photo",
1650
+ changePhoto: "Change photo",
1651
+ optional: "optional",
1652
+ save: "Save changes",
1653
+ saving: "Saving…",
1654
+ cancel: "Cancel",
1655
+ close: "Close",
1656
+ loading: "Loading your profile…",
1657
+ loadFailed: "We couldn't load your profile. Close this and try again.",
1658
+ saveFailed: "We couldn't save your changes. Your edits are still here — try again.",
1659
+ saved: "Profile updated.",
1660
+ signedOut: "Your session ended. Sign in again to make changes.",
1661
+ genderUnset: "Prefer not to say",
1662
+ genderMale: "Male",
1663
+ genderFemale: "Female",
1664
+ genderOther: "Other"
1665
+ };
1666
+ /** Matches the console's options, so the same stored values round-trip. */
1667
+ const GENDERS = [
1668
+ "male",
1669
+ "female",
1670
+ "other"
1671
+ ];
1672
+ const EMPTY = {
1673
+ firstName: "",
1674
+ lastName: "",
1675
+ phoneNumber: "",
1676
+ cnic: "",
1677
+ gender: ""
1678
+ };
1679
+ function readProfile(user) {
1680
+ const draft = { ...EMPTY };
1681
+ for (const field of _poly_x_core.PROFILE_FIELDS) {
1682
+ const value = user[field];
1683
+ if (typeof value === "string") draft[field] = value;
1684
+ }
1685
+ return draft;
1686
+ }
1687
+ /**
1688
+ * The signed-in person's own profile, as a dialog (v0.4 / FR-PROF).
1689
+ *
1690
+ * Every application that wanted this had to build the same four things: a form, a validation
1691
+ * ruleset guessed at from error messages, a file upload, and — because the browser holds no
1692
+ * credential under BFF custody — a server route to carry the call. The last of those is
1693
+ * security-adjacent work that had no business being duplicated per app.
1694
+ *
1695
+ * The credential never comes near this component: it posts to the SDK's own route, which
1696
+ * attaches the session server-side and takes the identity from the sealed session rather than
1697
+ * from anything sent here. This surface cannot be aimed at another person's profile even if a
1698
+ * caller tries (FR-CUSTODY-4 / D24).
1699
+ *
1700
+ * ```tsx
1701
+ * <UserProfile onSaved={() => router.refresh()} />
1702
+ * ```
1703
+ */
1704
+ function UserProfile(props) {
1705
+ const labels = {
1706
+ ...DEFAULT_LABELS$1,
1707
+ ...props.labels
1708
+ };
1709
+ const { isLoaded, isSignedIn, refresh } = useAuth();
1710
+ const controlled = props.open !== void 0;
1711
+ const [internalOpen, setInternalOpen] = (0, react.useState)(props.defaultOpen ?? false);
1712
+ const open = controlled ? props.open === true : internalOpen;
1713
+ const [status, setStatus] = (0, react.useState)("loading");
1714
+ const [stored, setStored] = (0, react.useState)(EMPTY);
1715
+ const [draft, setDraft] = (0, react.useState)(EMPTY);
1716
+ const [errors, setErrors] = (0, react.useState)({});
1717
+ const [photo, setPhoto] = (0, react.useState)(null);
1718
+ const [photoError, setPhotoError] = (0, react.useState)(null);
1719
+ const [storedPhoto, setStoredPhoto] = (0, react.useState)(void 0);
1720
+ const [saving, setSaving] = (0, react.useState)(false);
1721
+ const [feedback, setFeedback] = (0, react.useState)(null);
1722
+ const [portalHost, setPortalHost] = (0, react.useState)(null);
1723
+ (0, react.useEffect)(() => {
1724
+ const host = document.createElement("div");
1725
+ host.setAttribute("data-polyx-userprofile-portal", "");
1726
+ document.body.appendChild(host);
1727
+ setPortalHost(host);
1728
+ return () => host.remove();
1729
+ }, []);
1730
+ const triggerRef = (0, react.useRef)(null);
1731
+ const dialogRef = (0, react.useRef)(null);
1732
+ const fileRef = (0, react.useRef)(null);
1733
+ const titleId = (0, react.useId)();
1734
+ const fieldId = (0, react.useId)();
1735
+ const onOpenChange = props.onOpenChange;
1736
+ const setOpen = (0, react.useCallback)((next) => {
1737
+ if (!controlled) setInternalOpen(next);
1738
+ onOpenChange?.(next);
1739
+ }, [controlled, onOpenChange]);
1740
+ const polyx = (0, react.useContext)(PolyXContext);
1741
+ const [branding, setBranding] = (0, react.useState)({});
1742
+ (0, react.useEffect)(() => {
1743
+ if (!polyx || !open) return;
1744
+ let active = true;
1745
+ polyx.authClient.fetchBranding({}).then((tokens) => {
1746
+ if (active) setBranding(tokens);
1747
+ });
1748
+ return () => {
1749
+ active = false;
1750
+ };
1751
+ }, [polyx, open]);
1752
+ const brandingStyle = {};
1753
+ if (branding.primaryColor) brandingStyle["--polyx-brand"] = branding.primaryColor;
1754
+ /** Load on open. Never leaves blank fields looking like a stored (empty) profile — EC-PROF-1. */
1755
+ (0, react.useEffect)(() => {
1756
+ if (!open) return;
1757
+ let active = true;
1758
+ setStatus("loading");
1759
+ setFeedback(null);
1760
+ (async () => {
1761
+ try {
1762
+ const response = await fetch(PROFILE_ENDPOINT, { headers: { Accept: "application/json" } });
1763
+ const body = await response.json().catch(() => ({}));
1764
+ if (!active) return;
1765
+ if (!response.ok || !body.user) {
1766
+ setStatus("load_failed");
1767
+ return;
1768
+ }
1769
+ const loaded = readProfile(body.user);
1770
+ setStored(loaded);
1771
+ setDraft(loaded);
1772
+ setStoredPhoto(typeof body.user.profilePicture === "string" ? body.user.profilePicture : void 0);
1773
+ setErrors({});
1774
+ setStatus("ready");
1775
+ } catch {
1776
+ if (active) setStatus("load_failed");
1777
+ }
1778
+ })();
1779
+ return () => {
1780
+ active = false;
1781
+ };
1782
+ }, [open]);
1783
+ (0, react.useEffect)(() => {
1784
+ const preview = photo?.preview;
1785
+ return () => {
1786
+ if (preview) URL.revokeObjectURL(preview);
1787
+ };
1788
+ }, [photo]);
1789
+ /**
1790
+ * Hide the rest of the page from assistive technology while the dialog is open.
1791
+ *
1792
+ * `aria-modal="true"` alone is not enough — screen-reader support for it is inconsistent, so a
1793
+ * virtual cursor can still read and act on everything behind the dialog. The keyboard focus trap
1794
+ * does not cover this either: it constrains Tab, not the virtual cursor. Without both, the dialog
1795
+ * announces itself as modal and then does not behave modally.
1796
+ *
1797
+ * Every direct child of `<body>` except the dialog's own portal is marked, and only attributes
1798
+ * this effect added are removed again — a host that set its own `aria-hidden` keeps it.
1799
+ */
1800
+ (0, react.useEffect)(() => {
1801
+ if (!open || !portalHost) return;
1802
+ const marked = [];
1803
+ for (const node of Array.from(document.body.children)) {
1804
+ if (node === portalHost || !(node instanceof HTMLElement)) continue;
1805
+ if (node.hasAttribute("aria-hidden")) continue;
1806
+ node.setAttribute("aria-hidden", "true");
1807
+ node.setAttribute("inert", "");
1808
+ marked.push(node);
1809
+ }
1810
+ return () => {
1811
+ for (const node of marked) {
1812
+ node.removeAttribute("aria-hidden");
1813
+ node.removeAttribute("inert");
1814
+ }
1815
+ };
1816
+ }, [open, portalHost]);
1817
+ /**
1818
+ * Move focus into the dialog once there is something to focus (FR-A11Y-1).
1819
+ *
1820
+ * Deliberately keyed on `status`, not just `open`: the dialog opens in its loading state with
1821
+ * no focusable content, so placing focus at open time silently leaves it on the page behind —
1822
+ * which is exactly nowhere for anyone navigating by keyboard or screen reader.
1823
+ */
1824
+ (0, react.useEffect)(() => {
1825
+ if (!open || status === "loading") return;
1826
+ dialogRef.current?.querySelector("input, select, button")?.focus();
1827
+ }, [open, status]);
1828
+ /** Escape closes; focus is trapped while open (FR-A11Y-2/3). */
1829
+ (0, react.useEffect)(() => {
1830
+ if (!open) return;
1831
+ const node = dialogRef.current;
1832
+ const previous = document.activeElement;
1833
+ const onKeyDown = (event) => {
1834
+ if (event.key === "Escape") {
1835
+ setOpen(false);
1836
+ return;
1837
+ }
1838
+ if (event.key !== "Tab" || !node) return;
1839
+ const focusable = [...node.querySelectorAll("input, select, button, a[href]")].filter((element) => !element.hasAttribute("disabled"));
1840
+ if (focusable.length === 0) return;
1841
+ const first = focusable[0];
1842
+ const last = focusable[focusable.length - 1];
1843
+ if (event.shiftKey && document.activeElement === first) {
1844
+ event.preventDefault();
1845
+ last.focus();
1846
+ } else if (!event.shiftKey && document.activeElement === last) {
1847
+ event.preventDefault();
1848
+ first.focus();
1849
+ }
1850
+ };
1851
+ document.addEventListener("keydown", onKeyDown);
1852
+ return () => {
1853
+ document.removeEventListener("keydown", onKeyDown);
1854
+ (triggerRef.current ?? previous)?.focus();
1855
+ };
1856
+ }, [open, setOpen]);
1857
+ function set(field, value) {
1858
+ setDraft((current) => ({
1859
+ ...current,
1860
+ [field]: value
1861
+ }));
1862
+ setErrors((current) => ({
1863
+ ...current,
1864
+ [field]: void 0
1865
+ }));
1866
+ setFeedback(null);
1867
+ }
1868
+ /** Validate on leaving a field, so a mistake is caught next to where it was made (FR-VALID-8). */
1869
+ function blur(field) {
1870
+ const found = (0, _poly_x_core.validateProfileDraft)(draft)[field];
1871
+ setErrors((current) => ({
1872
+ ...current,
1873
+ [field]: found
1874
+ }));
1875
+ }
1876
+ function choosePhoto(file) {
1877
+ if (!file) return;
1878
+ const invalid = (0, _poly_x_core.validateProfileImage)(file);
1879
+ if (invalid) {
1880
+ setPhotoError(invalid);
1881
+ setPhoto(null);
1882
+ return;
1883
+ }
1884
+ setPhotoError(null);
1885
+ setPhoto({
1886
+ file,
1887
+ preview: URL.createObjectURL(file)
1888
+ });
1889
+ setFeedback(null);
1890
+ }
1891
+ async function save() {
1892
+ if (saving) return;
1893
+ const found = (0, _poly_x_core.validateProfileDraft)(draft);
1894
+ if (Object.keys(found).length > 0) {
1895
+ setErrors(found);
1896
+ return;
1897
+ }
1898
+ const changed = (0, _poly_x_core.buildProfileUpdate)(draft, stored);
1899
+ if (Object.keys(changed).length === 0 && !photo) {
1900
+ setOpen(false);
1901
+ return;
1902
+ }
1903
+ const body = new FormData();
1904
+ for (const [field, value] of Object.entries(changed)) body.append(field, value);
1905
+ if (photo) body.append("image", photo.file);
1906
+ setSaving(true);
1907
+ setFeedback(null);
1908
+ try {
1909
+ const response = await fetch(PROFILE_ENDPOINT, {
1910
+ method: "POST",
1911
+ body
1912
+ });
1913
+ const payload = await response.json().catch(() => ({}));
1914
+ if (response.status === 401) {
1915
+ setFeedback({
1916
+ kind: "error",
1917
+ message: labels.signedOut
1918
+ });
1919
+ return;
1920
+ }
1921
+ if (!response.ok) {
1922
+ const field = (0, _poly_x_core.conflictField)(payload.message);
1923
+ if (field) {
1924
+ setErrors((current) => ({
1925
+ ...current,
1926
+ [field]: payload.message
1927
+ }));
1928
+ return;
1929
+ }
1930
+ setFeedback({
1931
+ kind: "error",
1932
+ message: payload.message ?? labels.saveFailed
1933
+ });
1934
+ return;
1935
+ }
1936
+ const saved = payload.user ?? {};
1937
+ setStored(readProfile(saved));
1938
+ if (typeof saved.profilePicture === "string") setStoredPhoto(saved.profilePicture);
1939
+ setPhoto(null);
1940
+ setFeedback({
1941
+ kind: "success",
1942
+ message: labels.saved
1943
+ });
1944
+ await refresh().catch(() => false);
1945
+ props.onSaved?.(saved);
1946
+ setOpen(false);
1947
+ } catch {
1948
+ setFeedback({
1949
+ kind: "error",
1950
+ message: labels.saveFailed
1951
+ });
1952
+ } finally {
1953
+ setSaving(false);
1954
+ }
1955
+ }
1956
+ if (!isLoaded || !isSignedIn) return null;
1957
+ const rootClass = [
1958
+ "polyx-signin",
1959
+ "polyx-userprofile",
1960
+ props.className
1961
+ ].filter(Boolean).join(" ");
1962
+ const displayName = [draft.firstName, draft.lastName].filter(Boolean).join(" ");
1963
+ function field(name, label, extra = {}) {
1964
+ const id = `${fieldId}-${name}`;
1965
+ const error = errors[name];
1966
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1967
+ className: "polyx-signin__field",
1968
+ children: [
1969
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1970
+ className: "polyx-signin__label",
1971
+ htmlFor: id,
1972
+ children: [label, extra.optional ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1973
+ className: "polyx-userprofile__optional",
1974
+ children: [
1975
+ " (",
1976
+ labels.optional,
1977
+ ")"
1978
+ ]
1979
+ }) : null]
1980
+ }),
1981
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1982
+ id,
1983
+ className: "polyx-signin__input",
1984
+ type: extra.type ?? "text",
1985
+ value: draft[name] ?? "",
1986
+ placeholder: extra.placeholder,
1987
+ disabled: saving,
1988
+ "aria-required": extra.optional ? void 0 : true,
1989
+ "aria-invalid": error ? true : void 0,
1990
+ "aria-describedby": error ? `${id}-error` : void 0,
1991
+ onChange: (event) => set(name, event.target.value),
1992
+ onBlur: () => blur(name)
1993
+ }),
1994
+ error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1995
+ id: `${id}-error`,
1996
+ className: "polyx-userprofile__field-error",
1997
+ children: error
1998
+ }) : null
1999
+ ]
2000
+ });
2001
+ }
2002
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2003
+ className: rootClass,
2004
+ style: brandingStyle,
2005
+ children: [props.open === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2006
+ ref: triggerRef,
2007
+ type: "button",
2008
+ className: ["polyx-userprofile__trigger", props.triggerClassName].filter(Boolean).join(" "),
2009
+ onClick: () => setOpen(true),
2010
+ children: props.trigger ?? labels.trigger
2011
+ }) : null, open && portalHost ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2012
+ className: "polyx-signin polyx-userprofile polyx-userprofile__overlay",
2013
+ style: brandingStyle,
2014
+ onMouseDown: () => setOpen(false),
2015
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2016
+ ref: dialogRef,
2017
+ role: "dialog",
2018
+ "aria-modal": "true",
2019
+ "aria-labelledby": titleId,
2020
+ className: ["polyx-userprofile__dialog", props.dialogClassName].filter(Boolean).join(" "),
2021
+ onMouseDown: (event) => event.stopPropagation(),
2022
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2023
+ className: "polyx-userprofile__header",
2024
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
2025
+ id: titleId,
2026
+ className: "polyx-userprofile__title",
2027
+ children: labels.title
2028
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2029
+ className: "polyx-userprofile__description",
2030
+ children: labels.description
2031
+ })]
2032
+ }), status === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2033
+ className: "polyx-userprofile__status",
2034
+ role: "status",
2035
+ children: labels.loading
2036
+ }) : status === "load_failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2037
+ className: "polyx-signin__error",
2038
+ role: "alert",
2039
+ children: labels.loadFailed
2040
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
2041
+ className: "polyx-signin__form",
2042
+ onSubmit: (event) => {
2043
+ event.preventDefault();
2044
+ save();
2045
+ },
2046
+ children: [
2047
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2048
+ className: "polyx-userprofile__photo",
2049
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
2050
+ src: photo?.preview ?? storedPhoto,
2051
+ name: displayName,
2052
+ size: 56
2053
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2054
+ className: "polyx-userprofile__photo-actions",
2055
+ children: [
2056
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2057
+ type: "button",
2058
+ className: "polyx-userprofile__photo-button",
2059
+ disabled: saving,
2060
+ onClick: () => fileRef.current?.click(),
2061
+ children: labels.changePhoto
2062
+ }),
2063
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2064
+ ref: fileRef,
2065
+ type: "file",
2066
+ accept: "image/*",
2067
+ className: "polyx-userprofile__file",
2068
+ "aria-label": labels.photo,
2069
+ onChange: (event) => choosePhoto(event.target.files?.[0])
2070
+ }),
2071
+ photoError ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2072
+ className: "polyx-userprofile__field-error",
2073
+ children: photoError
2074
+ }) : null
2075
+ ]
2076
+ })]
2077
+ }),
2078
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2079
+ className: "polyx-userprofile__row",
2080
+ children: [field("firstName", labels.firstName), field("lastName", labels.lastName)]
2081
+ }),
2082
+ field("phoneNumber", labels.phoneNumber, {
2083
+ optional: true,
2084
+ type: "tel",
2085
+ placeholder: "+14155552671"
2086
+ }),
2087
+ field("cnic", labels.cnic, { optional: true }),
2088
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2089
+ className: "polyx-signin__field",
2090
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2091
+ className: "polyx-signin__label",
2092
+ htmlFor: `${fieldId}-gender`,
2093
+ children: [labels.gender, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2094
+ className: "polyx-userprofile__optional",
2095
+ children: [
2096
+ " (",
2097
+ labels.optional,
2098
+ ")"
2099
+ ]
2100
+ })]
2101
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
2102
+ id: `${fieldId}-gender`,
2103
+ className: "polyx-signin__input",
2104
+ value: draft.gender ?? "",
2105
+ disabled: saving,
2106
+ onChange: (event) => set("gender", event.target.value),
2107
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2108
+ value: "",
2109
+ children: labels.genderUnset
2110
+ }), GENDERS.map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2111
+ value,
2112
+ children: value === "male" ? labels.genderMale : value === "female" ? labels.genderFemale : labels.genderOther
2113
+ }, value))]
2114
+ })]
2115
+ }),
2116
+ feedback ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2117
+ className: feedback.kind === "error" ? "polyx-signin__error" : "polyx-userprofile__success",
2118
+ role: feedback.kind === "error" ? "alert" : "status",
2119
+ children: feedback.message
2120
+ }) : null,
2121
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2122
+ className: "polyx-userprofile__actions",
2123
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2124
+ type: "button",
2125
+ className: "polyx-userprofile__cancel",
2126
+ disabled: saving,
2127
+ onClick: () => setOpen(false),
2128
+ children: labels.cancel
2129
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2130
+ type: "submit",
2131
+ className: "polyx-signin__submit",
2132
+ disabled: saving,
2133
+ children: saving ? labels.saving : labels.save
2134
+ })]
2135
+ })
2136
+ ]
2137
+ })]
2138
+ })
2139
+ }), portalHost) : null]
2140
+ });
2141
+ }
2142
+ //#endregion
1623
2143
  //#region src/components/user-button.tsx
1624
2144
  const DEFAULT_LABELS = {
1625
2145
  trigger: "Account menu",
@@ -1726,6 +2246,7 @@ function UserButtonRoot(props) {
1726
2246
  const { isLoaded, isSignedIn, signOut } = useAuth();
1727
2247
  const user = useUser();
1728
2248
  const [open, setOpen] = (0, react.useState)(props.defaultOpen ?? false);
2249
+ const [profileOpen, setProfileOpen] = (0, react.useState)(false);
1729
2250
  const [focused, setFocused] = (0, react.useState)(0);
1730
2251
  const [side, setSide] = (0, react.useState)(props.side && props.side !== "auto" ? props.side : "bottom");
1731
2252
  const triggerRef = (0, react.useRef)(null);
@@ -1802,6 +2323,12 @@ function UserButtonRoot(props) {
1802
2323
  labelIcon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GearIcon, {}),
1803
2324
  href: props.manageAccountUrl
1804
2325
  });
2326
+ else if (props.showUserProfile) builtIns.set("manageAccount", {
2327
+ key: "manageAccount",
2328
+ label: labels.manageAccount,
2329
+ labelIcon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GearIcon, {}),
2330
+ onClick: () => setProfileOpen(true)
2331
+ });
1805
2332
  builtIns.set("signOut", {
1806
2333
  key: "signOut",
1807
2334
  label: labels.signOut,
@@ -1841,81 +2368,89 @@ function UserButtonRoot(props) {
1841
2368
  props.className
1842
2369
  ].filter(Boolean).join(" "),
1843
2370
  "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, {
2371
+ children: [
2372
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2373
+ ref: triggerRef,
2374
+ type: "button",
2375
+ className: ["polyx-userbutton__trigger", props.triggerClassName].filter(Boolean).join(" "),
2376
+ "aria-haspopup": "menu",
2377
+ "aria-expanded": open,
2378
+ "aria-controls": open ? menuId : void 0,
2379
+ "aria-label": labels.trigger,
2380
+ onClick: () => {
2381
+ setFocused(0);
2382
+ setOpen((current) => !current);
2383
+ },
2384
+ children: props.trigger ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
1875
2385
  src: user.avatarUrl,
1876
2386
  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]
2387
+ size: props.avatarSize
2388
+ }), props.showName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2389
+ className: "polyx-userbutton__trigger-name",
2390
+ children: name
2391
+ }) : null] })
2392
+ }),
2393
+ open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2394
+ ref: menuRef,
2395
+ id: menuId,
2396
+ role: "menu",
2397
+ className: "polyx-userbutton__menu",
2398
+ "data-polyx-side": side,
2399
+ "data-polyx-align": align,
2400
+ onKeyDown: onMenuKeyDown,
2401
+ children: [customHeader ?? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2402
+ className: "polyx-userbutton__identity",
2403
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserAvatar, {
2404
+ src: user.avatarUrl,
2405
+ name,
2406
+ size: 36
2407
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2408
+ className: "polyx-userbutton__identity-text",
2409
+ children: [user.displayName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2410
+ className: "polyx-userbutton__name",
2411
+ children: user.displayName
2412
+ }) : null, user.email ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2413
+ className: "polyx-userbutton__email",
2414
+ children: user.email
2415
+ }) : null]
2416
+ })]
2417
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
2418
+ className: "polyx-userbutton__items",
2419
+ children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: item.custom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2420
+ className: "polyx-userbutton__custom",
2421
+ children: item.custom
2422
+ }) : item.href ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
2423
+ ref: (node) => {
2424
+ itemRefs.current[index] = node;
2425
+ },
2426
+ role: "menuitem",
2427
+ tabIndex: index === focused ? 0 : -1,
2428
+ className: "polyx-userbutton__item",
2429
+ href: item.href,
2430
+ onClick: () => close(false),
2431
+ children: [item.labelIcon, item.label]
2432
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2433
+ ref: (node) => {
2434
+ itemRefs.current[index] = node;
2435
+ },
2436
+ role: "menuitem",
2437
+ tabIndex: index === focused ? 0 : -1,
2438
+ type: "button",
2439
+ className: "polyx-userbutton__item",
2440
+ onClick: () => {
2441
+ close(false);
2442
+ item.onClick?.();
2443
+ },
2444
+ children: [item.labelIcon, item.label]
2445
+ }) }, item.key))
1887
2446
  })]
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]
2447
+ }) : null,
2448
+ props.showUserProfile && !props.manageAccountUrl ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UserProfile, {
2449
+ ...props.userProfileProps,
2450
+ open: profileOpen,
2451
+ onOpenChange: setProfileOpen
2452
+ }) : null
2453
+ ]
1919
2454
  });
1920
2455
  }
1921
2456
  function GearIcon() {
@@ -1980,6 +2515,33 @@ const UserButton = Object.assign(UserButtonRoot, {
1980
2515
  Header
1981
2516
  });
1982
2517
  //#endregion
2518
+ //#region src/auth-event.ts
2519
+ /**
2520
+ * Read a one-shot auth event published by the BFF (F017 / FR-CLIENT-5).
2521
+ *
2522
+ * Sign-in and sign-out are full-page navigations, so an app cannot simply react in the
2523
+ * handler that triggered them — the page is gone. The BFF leaves a short-lived, readable
2524
+ * cookie; the destination page consumes it once.
2525
+ */
2526
+ /** Must match `AUTH_EVENT_COOKIE` in `@poly-x/next`. */
2527
+ const AUTH_EVENT_COOKIE = "polyx_auth_event";
2528
+ function readCookie(name) {
2529
+ if (typeof document === "undefined") return void 0;
2530
+ return document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1);
2531
+ }
2532
+ /**
2533
+ * Read the pending auth event and clear it, so it fires exactly once.
2534
+ *
2535
+ * Returns `null` when there is nothing pending — which is the normal case on almost every
2536
+ * page load. Safe to call during render or in an effect; it is a no-op on the server.
2537
+ */
2538
+ function consumeAuthEvent() {
2539
+ const value = readCookie(AUTH_EVENT_COOKIE);
2540
+ if (value !== "signed-in" && value !== "signed-out") return null;
2541
+ document.cookie = `${AUTH_EVENT_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`;
2542
+ return value;
2543
+ }
2544
+ //#endregion
1983
2545
  //#region src/live-authz.ts
1984
2546
  const DEFAULT_POLL_MS = 6e4;
1985
2547
  const DEFAULT_FOCUS_THROTTLE_MS = 5e3;
@@ -2118,7 +2680,8 @@ function useAuthzChanged(callback) {
2118
2680
  * F006; this entry is the state foundation (provider, hooks, browser seams).
2119
2681
  */
2120
2682
  const PACKAGE_NAME = "@poly-x/react";
2121
- const version = "0.0.0";
2683
+ /** The published version of this package, injected at build time. */
2684
+ const version = "0.2.0";
2122
2685
  //#endregion
2123
2686
  exports.AuthCallback = AuthCallback;
2124
2687
  exports.BroadcastAuthChannel = BroadcastAuthChannel;
@@ -2134,9 +2697,11 @@ exports.SessionStoragePkceStore = SessionStoragePkceStore;
2134
2697
  exports.SignIn = SignIn;
2135
2698
  exports.UserAvatar = UserAvatar;
2136
2699
  exports.UserButton = UserButton;
2700
+ exports.UserProfile = UserProfile;
2137
2701
  exports.WebLocksLock = WebLocksLock;
2138
2702
  exports.WindowBrowserBridge = WindowBrowserBridge;
2139
2703
  exports.capturePreciseLocation = capturePreciseLocation;
2704
+ exports.consumeAuthEvent = consumeAuthEvent;
2140
2705
  exports.createLoginController = createLoginController;
2141
2706
  exports.initialsFrom = initialsFrom;
2142
2707
  exports.resolveSide = resolveSide;