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