@poly-x/react 0.1.0-alpha.1 → 0.1.0-alpha.11

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,7 @@
1
1
  "use client";
2
2
  import { AuthClient, FetchTransport, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, createSessionEngine, generatePkce, randomState, resolveConfig } from "@poly-x/core";
3
- import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
4
- import { jsx } from "react/jsx-runtime";
3
+ import { Children, Fragment, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/auth-channel.ts
6
6
  var BroadcastAuthChannel = class {
7
7
  supported;
@@ -299,31 +299,188 @@ function PolyXProvider({ publishableKey, baseUrl, signInUrl, children }) {
299
299
  });
300
300
  }
301
301
  //#endregion
302
+ //#region src/bff-session.ts
303
+ /**
304
+ * Client session under BFF custody (ADR-0004).
305
+ *
306
+ * `@poly-x/next` seals the tokens in an httpOnly cookie, so a consuming app mounts no
307
+ * `<PolyXProvider>` and there is no client engine to read — there is nothing for it to own.
308
+ * The hooks still have to work there, so they fall back to this: a single module-level store
309
+ * that reads server truth from the `session` route handler once and feeds the *same*
310
+ * `SessionState` machine the engine drives. That is what ADR-0004 meant by custody being
311
+ * "invisible to the consumer API surface".
312
+ *
313
+ * It follows `<SignIn/>`'s precedent — work without a provider, degrade quietly — rather than
314
+ * asking BFF apps to adopt a provider whose whole purpose is holding tokens they don't have.
315
+ */
316
+ const SESSION_ENDPOINT = "/api/polyx/session";
317
+ const SIGNOUT_ENDPOINT = "/api/polyx/signout";
318
+ var BffSessionStore = class {
319
+ state = { status: "resolving" };
320
+ listeners = /* @__PURE__ */ new Set();
321
+ loading = false;
322
+ watching = false;
323
+ getState = () => this.state;
324
+ /** SSR has no session to read; render the resolving branch and settle on the client. */
325
+ getServerState = () => ({ status: "resolving" });
326
+ subscribe = (listener) => {
327
+ this.listeners.add(listener);
328
+ this.load();
329
+ this.watchFocus();
330
+ return () => {
331
+ this.listeners.delete(listener);
332
+ };
333
+ };
334
+ /**
335
+ * The platform revokes every session for a user when an admin changes their permission,
336
+ * suspends them, or resets their password — and it pushes nothing, so a revoked user keeps
337
+ * looking signed in until they happen to make a request. Re-checking when the tab regains
338
+ * focus is the cheapest way to notice: come back to the tab, discover you're signed out.
339
+ *
340
+ * It is not a replacement for the 401 the next request will get; it just moves the discovery
341
+ * to a moment the user is actually looking at the screen.
342
+ */
343
+ watchFocus() {
344
+ if (this.watching || typeof window === "undefined") return;
345
+ this.watching = true;
346
+ const recheck = () => {
347
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
348
+ this.revalidate();
349
+ };
350
+ window.addEventListener("focus", recheck);
351
+ document?.addEventListener?.("visibilitychange", recheck);
352
+ }
353
+ /**
354
+ * Re-read the session without passing through `resolving` — a confirmed session must not
355
+ * flicker the UI back to a loading state. Only a definite signed-out answer signs the user
356
+ * out: an unreachable server is a blip, not a revocation.
357
+ */
358
+ async revalidate() {
359
+ if (this.loading || this.state.status === "resolving") return;
360
+ this.loading = true;
361
+ try {
362
+ const response = await fetch(SESSION_ENDPOINT, {
363
+ credentials: "same-origin",
364
+ headers: { accept: "application/json" }
365
+ });
366
+ const body = response.ok ? await response.json() : null;
367
+ const signedIn = Boolean(body?.isSignedIn && body.claims);
368
+ if (!signedIn && this.state.status !== "signed-out") this.set({
369
+ status: "signed-out",
370
+ reason: "revoked"
371
+ });
372
+ else if (signedIn && body?.claims) this.set({
373
+ status: "authenticated",
374
+ session: { claims: body.claims }
375
+ });
376
+ } catch {} finally {
377
+ this.loading = false;
378
+ }
379
+ }
380
+ /**
381
+ * Reading the session is a one-shot per page load, shared by every component that asks —
382
+ * a user menu and three gates must not mean four round-trips.
383
+ */
384
+ async load() {
385
+ if (this.loading || this.state.status !== "resolving") return;
386
+ this.loading = true;
387
+ try {
388
+ const response = await fetch(SESSION_ENDPOINT, {
389
+ credentials: "same-origin",
390
+ headers: { accept: "application/json" }
391
+ });
392
+ const body = response.ok ? await response.json() : null;
393
+ this.set(body?.isSignedIn && body.claims ? {
394
+ status: "authenticated",
395
+ session: { claims: body.claims }
396
+ } : {
397
+ status: "signed-out",
398
+ reason: "signed-out"
399
+ });
400
+ } catch {
401
+ this.set({
402
+ status: "signed-out",
403
+ reason: "signed-out"
404
+ });
405
+ } finally {
406
+ this.loading = false;
407
+ }
408
+ }
409
+ set(next) {
410
+ this.state = next;
411
+ for (const listener of this.listeners) listener();
412
+ }
413
+ /**
414
+ * Sign-out has to reach the server — only it can unseal an httpOnly cookie, and only it
415
+ * holds the token needed to revoke the session upstream (X002).
416
+ *
417
+ * A same-origin fetch, not a navigation: the response's Set-Cookie still applies, and it
418
+ * leaves the destination to the caller. That is why the handler is content-negotiated —
419
+ * a server-side redirect target would be an open redirect on the auth path, and the client
420
+ * already knows where it wants to land.
421
+ */
422
+ signOut = async (options) => {
423
+ const query = options?.scope === "everywhere" ? "?scope=everywhere" : "";
424
+ try {
425
+ await fetch(`${SIGNOUT_ENDPOINT}${query}`, {
426
+ method: "POST",
427
+ credentials: "same-origin",
428
+ headers: { accept: "application/json" }
429
+ });
430
+ } catch {}
431
+ this.set({
432
+ status: "signed-out",
433
+ reason: "signed-out"
434
+ });
435
+ window.location.assign(options?.redirectTo ?? "/");
436
+ };
437
+ /** Null by design: under BFF custody the token never reaches the browser (FR-SES-3). */
438
+ getToken = async () => null;
439
+ };
440
+ let store = new BffSessionStore();
441
+ function getBffSessionStore() {
442
+ return store;
443
+ }
444
+ //#endregion
302
445
  //#region src/hooks.ts
303
- function usePolyX() {
446
+ function useSessionSource() {
304
447
  const ctx = useContext(PolyXContext);
305
- if (!ctx) throw new Error("PolyX hooks (useAuth/useUser/useSession) must be used within a <PolyXProvider>.");
306
- return ctx;
448
+ if (!ctx) return {
449
+ source: getBffSessionStore(),
450
+ canSignIn: false
451
+ };
452
+ const { engine, controller } = ctx;
453
+ return {
454
+ source: {
455
+ getState: () => engine.getState(),
456
+ subscribe: (listener) => engine.subscribe(listener),
457
+ signOut: () => engine.signOut(),
458
+ getToken: async () => {
459
+ try {
460
+ return await engine.getAccessToken();
461
+ } catch {
462
+ return null;
463
+ }
464
+ }
465
+ },
466
+ canSignIn: Boolean(controller)
467
+ };
307
468
  }
308
469
  /** The raw session state machine value — tearing-free under concurrent React. */
309
470
  function useSession() {
310
- const { engine } = usePolyX();
311
- const subscribe = useCallback((onChange) => engine.subscribe(onChange), [engine]);
312
- const getSnapshot = useCallback(() => engine.getState(), [engine]);
313
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
471
+ const { source } = useSessionSource();
472
+ return useSyncExternalStore(useCallback((onChange) => source.subscribe(onChange), [source]), useCallback(() => source.getState(), [source]), useCallback(() => (source.getServerState ?? source.getState)(), [source]));
314
473
  }
315
474
  function useAuth() {
316
- const { engine, controller } = usePolyX();
475
+ const ctx = useContext(PolyXContext);
476
+ const { source } = useSessionSource();
317
477
  const state = useSession();
318
- const signIn = useCallback((options) => controller.signIn(options), [controller]);
319
- const signOut = useCallback(() => engine.signOut(), [engine]);
320
- const getToken = useCallback(async () => {
321
- try {
322
- return await engine.getAccessToken();
323
- } catch {
324
- return null;
325
- }
326
- }, [engine]);
478
+ const signIn = useCallback(async (options) => {
479
+ if (!ctx) throw new Error("PolyX: signIn() needs a <PolyXProvider> (it owns the publishable key and the popup flow). Under BFF custody, render <SignIn/> or link to your sign-in route instead.");
480
+ return ctx.controller.signIn(options);
481
+ }, [ctx]);
482
+ const signOut = useCallback((options) => source.signOut(options), [source]);
483
+ const getToken = useCallback(() => source.getToken(), [source]);
327
484
  return {
328
485
  isLoaded: state.status !== "resolving",
329
486
  isSignedIn: state.status === "authenticated" || state.status === "refreshing",
@@ -334,8 +491,11 @@ function useAuth() {
334
491
  }
335
492
  /** Run this on your callback route to complete a sign-in (see `<AuthCallback>`). */
336
493
  function useAuthCallback() {
337
- const { controller } = usePolyX();
338
- return useCallback(() => controller.handleCallback(), [controller]);
494
+ const ctx = useContext(PolyXContext);
495
+ return useCallback(() => {
496
+ if (!ctx) throw new Error("PolyX: <AuthCallback> / useAuthCallback() must be used within a <PolyXProvider>.");
497
+ return ctx.controller.handleCallback();
498
+ }, [ctx]);
339
499
  }
340
500
  function useUser() {
341
501
  const state = useSession();
@@ -351,14 +511,890 @@ function Protected({ children, fallback = null, loading = null }) {
351
511
  }
352
512
  //#endregion
353
513
  //#region src/components/sign-in.tsx
354
- /** Minimal sign-in trigger — popup by default. Style via `className`/children. */
355
- function SignIn({ children, className, ...options }) {
356
- const { signIn } = useAuth();
357
- return /* @__PURE__ */ jsx("button", {
358
- type: "button",
359
- className,
360
- onClick: () => void signIn(options),
361
- children: children ?? "Sign in"
514
+ const DEFAULT_LABELS$3 = {
515
+ heading: "Sign in",
516
+ subheading: "Enter your credentials to continue.",
517
+ tagline: "",
518
+ emailLabel: "Email",
519
+ emailPlaceholder: "you@company.com",
520
+ passwordLabel: "Password",
521
+ passwordPlaceholder: "••••••••",
522
+ showPassword: "Show password",
523
+ hidePassword: "Hide password",
524
+ submit: "Sign in",
525
+ submitting: "Signing in…",
526
+ forgotPassword: "Forgot password?",
527
+ chooseWorkspace: "Choose a workspace",
528
+ chooseWorkspaceSubheading: "You have access to more than one workspace.",
529
+ securedBy: "Secured by PolyX",
530
+ setPasswordHeading: "Choose a new password",
531
+ setPasswordSubheading: "Your temporary password must be replaced before you continue.",
532
+ newPasswordLabel: "New password",
533
+ confirmPasswordLabel: "Confirm new password",
534
+ setPasswordSubmit: "Set password and sign in",
535
+ passwordMismatch: "Those passwords don't match.",
536
+ weakPassword: "Password must be at least 6 characters.",
537
+ invalidCredentials: "Invalid email or password.",
538
+ noAccess: "You don't have access to this application.",
539
+ passwordChangeRequired: "You must reset your password before you can sign in.",
540
+ genericError: "Something went wrong. Please try again."
541
+ };
542
+ /** Mirrors poly-auth's own floor (Joi: min 6). */
543
+ const MIN_PASSWORD_LENGTH = 6;
544
+ function EyeIcon() {
545
+ return /* @__PURE__ */ jsxs("svg", {
546
+ viewBox: "0 0 24 24",
547
+ fill: "none",
548
+ stroke: "currentColor",
549
+ strokeWidth: "2",
550
+ strokeLinecap: "round",
551
+ strokeLinejoin: "round",
552
+ "aria-hidden": "true",
553
+ focusable: "false",
554
+ children: [/* @__PURE__ */ jsx("path", { d: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" }), /* @__PURE__ */ jsx("circle", {
555
+ cx: "12",
556
+ cy: "12",
557
+ r: "3"
558
+ })]
559
+ });
560
+ }
561
+ function EyeOffIcon() {
562
+ return /* @__PURE__ */ jsxs("svg", {
563
+ viewBox: "0 0 24 24",
564
+ fill: "none",
565
+ stroke: "currentColor",
566
+ strokeWidth: "2",
567
+ strokeLinecap: "round",
568
+ strokeLinejoin: "round",
569
+ "aria-hidden": "true",
570
+ focusable: "false",
571
+ children: [/* @__PURE__ */ jsx("path", { d: "M9.88 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 7 10 7a13.2 13.2 0 0 1-1.67 2.68M6.61 6.61A13.5 13.5 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.39-1.61" }), /* @__PURE__ */ jsx("line", {
572
+ x1: "2",
573
+ y1: "2",
574
+ x2: "22",
575
+ y2: "22"
576
+ })]
577
+ });
578
+ }
579
+ /** A password input with an accessible show/hide reveal toggle (own visibility state). */
580
+ function PasswordField(props) {
581
+ const [visible, setVisible] = useState(false);
582
+ return /* @__PURE__ */ jsxs("div", {
583
+ className: "polyx-signin__field",
584
+ children: [/* @__PURE__ */ jsx("label", {
585
+ className: "polyx-signin__label",
586
+ htmlFor: props.id,
587
+ children: props.label
588
+ }), /* @__PURE__ */ jsxs("div", {
589
+ className: "polyx-signin__input-wrap",
590
+ children: [/* @__PURE__ */ jsx("input", {
591
+ id: props.id,
592
+ className: "polyx-signin__input polyx-signin__input--has-reveal",
593
+ type: visible ? "text" : "password",
594
+ name: props.name,
595
+ autoComplete: props.autoComplete,
596
+ placeholder: props.placeholder,
597
+ required: true,
598
+ minLength: props.minLength,
599
+ value: props.value,
600
+ disabled: props.disabled,
601
+ onChange: (event) => props.onChange(event.target.value)
602
+ }), /* @__PURE__ */ jsx("button", {
603
+ type: "button",
604
+ className: "polyx-signin__reveal",
605
+ "aria-pressed": visible,
606
+ "aria-label": visible ? props.hideLabel : props.showLabel,
607
+ title: visible ? props.hideLabel : props.showLabel,
608
+ disabled: props.disabled,
609
+ onClick: () => setVisible((value) => !value),
610
+ children: visible ? /* @__PURE__ */ jsx(EyeOffIcon, {}) : /* @__PURE__ */ jsx(EyeIcon, {})
611
+ })]
612
+ })]
613
+ });
614
+ }
615
+ /**
616
+ * The embedded, Clerk-style sign-in form (D2b). Renders email/password inside the
617
+ * consuming app and POSTs to the SDK's BFF (`@poly-x/next` `authenticate`), which
618
+ * runs the credential authorize server-side and seals an httpOnly session — no
619
+ * redirect to a hosted PolyX login page, no tokens or credentials in the browser
620
+ * after submit. Handles the workspace picker (select_tenant) inline.
621
+ *
622
+ * Ships styled: `import "@poly-x/react/styles.css"` once, then theme it by
623
+ * overriding the `--polyx-*` custom properties on any ancestor.
624
+ */
625
+ function SignIn(props) {
626
+ const action = props.action ?? "/api/polyx/authenticate";
627
+ const variant = props.variant ?? "card";
628
+ const labels = {
629
+ ...DEFAULT_LABELS$3,
630
+ ...props.labels
631
+ };
632
+ const [email, setEmail] = useState("");
633
+ const [password, setPassword] = useState("");
634
+ const [newPassword, setNewPassword] = useState("");
635
+ const [confirmPassword, setConfirmPassword] = useState("");
636
+ const [mustSetPassword, setMustSetPassword] = useState(false);
637
+ const [tenants, setTenants] = useState(null);
638
+ const [submitting, setSubmitting] = useState(false);
639
+ const [error, setError] = useState(null);
640
+ const polyx = useContext(PolyXContext);
641
+ const [branding, setBranding] = useState({});
642
+ useEffect(() => {
643
+ if (!polyx) return;
644
+ let active = true;
645
+ polyx.authClient.fetchBranding(props.organizationCode ? { organizationCode: props.organizationCode } : {}).then((tokens) => {
646
+ if (active) setBranding(tokens);
647
+ });
648
+ return () => {
649
+ active = false;
650
+ };
651
+ }, [polyx, props.organizationCode]);
652
+ const brandingStyle = {};
653
+ if (branding.primaryColor) brandingStyle["--polyx-brand"] = branding.primaryColor;
654
+ if (branding.backgroundColor) {
655
+ brandingStyle["--polyx-bg"] = branding.backgroundColor;
656
+ brandingStyle["--polyx-page-bg"] = branding.backgroundColor;
657
+ }
658
+ const brandingLogo = props.logo ?? (branding.logoUrl ? /* @__PURE__ */ jsx("img", {
659
+ className: "polyx-signin__brand-logo",
660
+ src: branding.logoUrl,
661
+ alt: branding.displayName ?? ""
662
+ }) : void 0);
663
+ /**
664
+ * One round-trip to the BFF. `tenantId` picks a workspace; `passwords` completes
665
+ * the temp-password flow (the BFF re-verifies the temporary password, sets the
666
+ * new one, and signs the user straight in).
667
+ */
668
+ async function authenticate(options = {}) {
669
+ setSubmitting(true);
670
+ setError(null);
671
+ try {
672
+ const body = await (await fetch(action, {
673
+ method: "POST",
674
+ headers: { "content-type": "application/json" },
675
+ body: JSON.stringify({
676
+ email,
677
+ password,
678
+ organizationCode: props.organizationCode,
679
+ tenantId: options.tenantId,
680
+ ...options.passwords
681
+ })
682
+ })).json().catch(() => ({}));
683
+ switch (body.status) {
684
+ case "success":
685
+ window.location.assign(props.afterSignInPath ?? body.redirectTo ?? "/");
686
+ return;
687
+ case "select_tenant":
688
+ setTenants(body.tenants ?? []);
689
+ return;
690
+ case "password_change_required":
691
+ setMustSetPassword(true);
692
+ if (body.userId) props.onPasswordChangeRequired?.(body.userId);
693
+ return;
694
+ case "password_mismatch":
695
+ setError(labels.passwordMismatch);
696
+ return;
697
+ case "weak_password":
698
+ setError(labels.weakPassword);
699
+ return;
700
+ case "invalid_credentials":
701
+ setError(labels.invalidCredentials);
702
+ return;
703
+ case "no_access":
704
+ setError(labels.noAccess);
705
+ return;
706
+ default:
707
+ setError(labels.genericError);
708
+ return;
709
+ }
710
+ } catch {
711
+ setError(labels.genericError);
712
+ } finally {
713
+ setSubmitting(false);
714
+ }
715
+ }
716
+ function onSubmit(event) {
717
+ event.preventDefault();
718
+ authenticate();
719
+ }
720
+ function onSetPasswordSubmit(event) {
721
+ event.preventDefault();
722
+ if (newPassword !== confirmPassword) {
723
+ setError(labels.passwordMismatch);
724
+ return;
725
+ }
726
+ authenticate({ passwords: {
727
+ newPassword,
728
+ confirmPassword
729
+ } });
730
+ }
731
+ const picking = tenants !== null;
732
+ const showHeaderLogo = Boolean(brandingLogo) && !(variant === "page");
733
+ const rootClass = [
734
+ "polyx-signin",
735
+ `polyx-signin--${variant}`,
736
+ props.className
737
+ ].filter(Boolean).join(" ");
738
+ const step = picking ? "select_tenant" : mustSetPassword ? "set_password" : "credentials";
739
+ const HEADINGS = {
740
+ credentials: {
741
+ heading: labels.heading,
742
+ subheading: labels.subheading
743
+ },
744
+ select_tenant: {
745
+ heading: labels.chooseWorkspace,
746
+ subheading: labels.chooseWorkspaceSubheading
747
+ },
748
+ set_password: {
749
+ heading: labels.setPasswordHeading,
750
+ subheading: labels.setPasswordSubheading
751
+ }
752
+ };
753
+ const header = /* @__PURE__ */ jsxs("div", {
754
+ className: "polyx-signin__header",
755
+ children: [
756
+ showHeaderLogo ? /* @__PURE__ */ jsx("div", {
757
+ className: "polyx-signin__logo",
758
+ children: brandingLogo
759
+ }) : null,
760
+ /* @__PURE__ */ jsx("h1", {
761
+ className: "polyx-signin__heading",
762
+ children: HEADINGS[step].heading
763
+ }),
764
+ /* @__PURE__ */ jsx("p", {
765
+ className: "polyx-signin__subheading",
766
+ children: HEADINGS[step].subheading
767
+ })
768
+ ]
769
+ });
770
+ const errorNode = error ? /* @__PURE__ */ jsx("p", {
771
+ role: "alert",
772
+ className: "polyx-signin__error",
773
+ children: error
774
+ }) : null;
775
+ const footer = props.showSecuredBy === false ? null : /* @__PURE__ */ jsxs("p", {
776
+ className: "polyx-signin__footer",
777
+ children: [/* @__PURE__ */ jsx("svg", {
778
+ className: "polyx-signin__shield",
779
+ viewBox: "0 0 16 16",
780
+ fill: "currentColor",
781
+ "aria-hidden": "true",
782
+ focusable: "false",
783
+ children: /* @__PURE__ */ jsx("path", { d: "M8 0.5 2 3v4.2c0 3.5 2.5 6.7 6 7.8 3.5-1.1 6-4.3 6-7.8V3L8 .5Zm2.9 5.6-3.4 3.4a.7.7 0 0 1-1 0L5.1 8.1a.7.7 0 1 1 1-1l.9.9 2.9-2.9a.7.7 0 0 1 1 1Z" })
784
+ }), labels.securedBy]
785
+ });
786
+ const tenantPicker = /* @__PURE__ */ jsxs("div", {
787
+ className: "polyx-signin__form",
788
+ children: [/* @__PURE__ */ jsx("ul", {
789
+ className: "polyx-signin__tenants",
790
+ children: (tenants ?? []).map((tenant) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
791
+ type: "button",
792
+ className: "polyx-signin__tenant",
793
+ disabled: submitting,
794
+ onClick: () => void authenticate({ tenantId: tenant.tenantId }),
795
+ children: tenant.name ?? tenant.slug ?? tenant.organizationCode ?? tenant.tenantId
796
+ }) }, tenant.tenantId))
797
+ }), errorNode]
798
+ });
799
+ const setPasswordForm = /* @__PURE__ */ jsxs("form", {
800
+ className: "polyx-signin__form",
801
+ onSubmit: onSetPasswordSubmit,
802
+ children: [
803
+ /* @__PURE__ */ jsx(PasswordField, {
804
+ id: "polyx-signin-new-password",
805
+ label: labels.newPasswordLabel,
806
+ name: "newPassword",
807
+ autoComplete: "new-password",
808
+ placeholder: labels.passwordPlaceholder,
809
+ minLength: MIN_PASSWORD_LENGTH,
810
+ value: newPassword,
811
+ disabled: submitting,
812
+ onChange: setNewPassword,
813
+ showLabel: labels.showPassword,
814
+ hideLabel: labels.hidePassword
815
+ }),
816
+ /* @__PURE__ */ jsx(PasswordField, {
817
+ id: "polyx-signin-confirm-password",
818
+ label: labels.confirmPasswordLabel,
819
+ name: "confirmPassword",
820
+ autoComplete: "new-password",
821
+ placeholder: labels.passwordPlaceholder,
822
+ minLength: MIN_PASSWORD_LENGTH,
823
+ value: confirmPassword,
824
+ disabled: submitting,
825
+ onChange: setConfirmPassword,
826
+ showLabel: labels.showPassword,
827
+ hideLabel: labels.hidePassword
828
+ }),
829
+ errorNode,
830
+ /* @__PURE__ */ jsx("button", {
831
+ type: "submit",
832
+ className: "polyx-signin__submit",
833
+ disabled: submitting,
834
+ children: submitting ? labels.submitting : labels.setPasswordSubmit
835
+ })
836
+ ]
837
+ });
838
+ const card = /* @__PURE__ */ jsxs("div", {
839
+ className: "polyx-signin__card",
840
+ children: [
841
+ header,
842
+ step === "select_tenant" ? tenantPicker : step === "set_password" ? setPasswordForm : /* @__PURE__ */ jsxs("form", {
843
+ className: "polyx-signin__form",
844
+ onSubmit,
845
+ children: [
846
+ /* @__PURE__ */ jsxs("label", {
847
+ className: "polyx-signin__field",
848
+ children: [/* @__PURE__ */ jsx("span", {
849
+ className: "polyx-signin__label",
850
+ children: labels.emailLabel
851
+ }), /* @__PURE__ */ jsx("input", {
852
+ className: "polyx-signin__input",
853
+ type: "email",
854
+ name: "email",
855
+ autoComplete: "username",
856
+ placeholder: labels.emailPlaceholder,
857
+ required: true,
858
+ value: email,
859
+ disabled: submitting,
860
+ onChange: (event) => setEmail(event.target.value)
861
+ })]
862
+ }),
863
+ /* @__PURE__ */ jsx(PasswordField, {
864
+ id: "polyx-signin-password",
865
+ label: labels.passwordLabel,
866
+ name: "password",
867
+ autoComplete: "current-password",
868
+ placeholder: labels.passwordPlaceholder,
869
+ value: password,
870
+ disabled: submitting,
871
+ onChange: setPassword,
872
+ showLabel: labels.showPassword,
873
+ hideLabel: labels.hidePassword
874
+ }),
875
+ props.forgotPasswordUrl ? /* @__PURE__ */ jsx("div", {
876
+ className: "polyx-signin__forgot",
877
+ children: /* @__PURE__ */ jsx("a", {
878
+ className: "polyx-signin__link",
879
+ href: props.forgotPasswordUrl,
880
+ children: labels.forgotPassword
881
+ })
882
+ }) : null,
883
+ errorNode,
884
+ /* @__PURE__ */ jsx("button", {
885
+ type: "submit",
886
+ className: "polyx-signin__submit",
887
+ disabled: submitting,
888
+ children: submitting ? labels.submitting : labels.submit
889
+ })
890
+ ]
891
+ }),
892
+ footer
893
+ ]
894
+ });
895
+ if (variant === "page") return /* @__PURE__ */ jsxs("div", {
896
+ className: rootClass,
897
+ "data-polyx-signin": step,
898
+ style: brandingStyle,
899
+ children: [/* @__PURE__ */ jsx("aside", {
900
+ className: "polyx-signin__panel",
901
+ children: props.panel ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [brandingLogo ? /* @__PURE__ */ jsx("div", {
902
+ className: "polyx-signin__panel-logo",
903
+ children: brandingLogo
904
+ }) : null, labels.tagline ? /* @__PURE__ */ jsx("p", {
905
+ className: "polyx-signin__tagline",
906
+ children: labels.tagline
907
+ }) : null] })
908
+ }), /* @__PURE__ */ jsx("div", {
909
+ className: "polyx-signin__main",
910
+ children: card
911
+ })]
912
+ });
913
+ return /* @__PURE__ */ jsx("div", {
914
+ className: rootClass,
915
+ "data-polyx-signin": step,
916
+ style: brandingStyle,
917
+ children: card
918
+ });
919
+ }
920
+ //#endregion
921
+ //#region src/components/_recovery-status.tsx
922
+ const ICONS = {
923
+ mail: /* @__PURE__ */ jsxs("svg", {
924
+ viewBox: "0 0 24 24",
925
+ fill: "none",
926
+ stroke: "currentColor",
927
+ strokeWidth: "1.75",
928
+ "aria-hidden": "true",
929
+ focusable: "false",
930
+ children: [/* @__PURE__ */ jsx("rect", {
931
+ x: "3",
932
+ y: "5",
933
+ width: "18",
934
+ height: "14",
935
+ rx: "2.5"
936
+ }), /* @__PURE__ */ jsx("path", {
937
+ d: "m4 7 8 6 8-6",
938
+ strokeLinecap: "round",
939
+ strokeLinejoin: "round"
940
+ })]
941
+ }),
942
+ check: /* @__PURE__ */ jsx("svg", {
943
+ viewBox: "0 0 24 24",
944
+ fill: "none",
945
+ stroke: "currentColor",
946
+ strokeWidth: "2",
947
+ "aria-hidden": "true",
948
+ focusable: "false",
949
+ children: /* @__PURE__ */ jsx("path", {
950
+ d: "m5 12.5 4.5 4.5L19 7",
951
+ strokeLinecap: "round",
952
+ strokeLinejoin: "round"
953
+ })
954
+ }),
955
+ warning: /* @__PURE__ */ jsxs("svg", {
956
+ viewBox: "0 0 24 24",
957
+ fill: "none",
958
+ stroke: "currentColor",
959
+ strokeWidth: "1.75",
960
+ "aria-hidden": "true",
961
+ focusable: "false",
962
+ children: [
963
+ /* @__PURE__ */ jsx("circle", {
964
+ cx: "12",
965
+ cy: "12",
966
+ r: "9"
967
+ }),
968
+ /* @__PURE__ */ jsx("path", {
969
+ d: "M12 7.5v5",
970
+ strokeLinecap: "round"
971
+ }),
972
+ /* @__PURE__ */ jsx("circle", {
973
+ cx: "12",
974
+ cy: "16",
975
+ r: "0.6",
976
+ fill: "currentColor",
977
+ stroke: "none"
978
+ })
979
+ ]
980
+ })
981
+ };
982
+ /**
983
+ * A centered, icon-topped confirmation card — the shape every recovery terminal
984
+ * state shares (link sent, password updated, link expired). Reuses the `<SignIn/>`
985
+ * class anatomy so it themes from the same `--polyx-*` custom properties.
986
+ */
987
+ function RecoveryStatus(props) {
988
+ return /* @__PURE__ */ jsxs("div", {
989
+ className: "polyx-signin__card",
990
+ children: [
991
+ /* @__PURE__ */ jsxs("div", {
992
+ className: "polyx-signin__header polyx-signin__header--center",
993
+ children: [
994
+ /* @__PURE__ */ jsx("span", {
995
+ className: `polyx-signin__status-icon polyx-signin__status-icon--${props.tone ?? "brand"}`,
996
+ children: ICONS[props.icon]
997
+ }),
998
+ /* @__PURE__ */ jsx("h1", {
999
+ className: "polyx-signin__heading",
1000
+ children: props.heading
1001
+ }),
1002
+ /* @__PURE__ */ jsx("p", {
1003
+ className: "polyx-signin__subheading",
1004
+ children: props.children
1005
+ })
1006
+ ]
1007
+ }),
1008
+ props.actions ? /* @__PURE__ */ jsx("div", {
1009
+ className: "polyx-signin__actions",
1010
+ children: props.actions
1011
+ }) : null,
1012
+ props.footer
1013
+ ]
1014
+ });
1015
+ }
1016
+ //#endregion
1017
+ //#region src/components/forgot-password.tsx
1018
+ const DEFAULT_LABELS$2 = {
1019
+ heading: "Reset your password",
1020
+ subheading: "Enter your email and we'll send you a reset link.",
1021
+ emailLabel: "Email",
1022
+ emailPlaceholder: "you@company.com",
1023
+ submit: "Send reset link",
1024
+ submitting: "Sending…",
1025
+ sentHeading: "Check your email",
1026
+ sentSubheading: "If an account exists for {email}, a password reset link is on its way. It expires in 30 minutes.",
1027
+ differentEmail: "Use a different email",
1028
+ backToSignIn: "Back to sign in",
1029
+ securedBy: "Secured by PolyX",
1030
+ genericError: "Something went wrong. Please try again."
1031
+ };
1032
+ /** Splits a label on `{email}` and drops the entered address in, emphasized. */
1033
+ function withEmail(template, email) {
1034
+ return template.split("{email}").map((chunk, index, all) => /* @__PURE__ */ jsxs(Fragment, { children: [chunk, index < all.length - 1 ? /* @__PURE__ */ jsx("span", {
1035
+ className: "polyx-signin__email",
1036
+ children: email || "that address"
1037
+ }) : null] }, index));
1038
+ }
1039
+ /**
1040
+ * The self-service "forgot password" form (poly-x v5 / F040). Renders an email field
1041
+ * inside the consuming app and POSTs to the SDK's BFF (`@poly-x/next` `forgotPassword`),
1042
+ * which asks poly-auth to email a single-use reset link SERVER-SIDE. The response is
1043
+ * always the same — no token, no account-existence signal — so after submit the form
1044
+ * shows an identical "check your email" state whether or not the account exists.
1045
+ *
1046
+ * Reuses the `<SignIn/>` styling: `import "@poly-x/react/styles.css"` once, then theme
1047
+ * via the `--polyx-*` custom properties. Target `.polyx-forgot` for form-specific tweaks.
1048
+ */
1049
+ function ForgotPassword(props) {
1050
+ const action = props.action ?? "/api/polyx/forgot-password";
1051
+ const signInPath = props.signInPath ?? "/sign-in";
1052
+ const labels = {
1053
+ ...DEFAULT_LABELS$2,
1054
+ ...props.labels
1055
+ };
1056
+ const [email, setEmail] = useState("");
1057
+ const [submitting, setSubmitting] = useState(false);
1058
+ const [sent, setSent] = useState(false);
1059
+ const [error, setError] = useState(null);
1060
+ async function onSubmit(event) {
1061
+ event.preventDefault();
1062
+ setSubmitting(true);
1063
+ setError(null);
1064
+ try {
1065
+ if ((await (await fetch(action, {
1066
+ method: "POST",
1067
+ headers: { "content-type": "application/json" },
1068
+ body: JSON.stringify({ email })
1069
+ })).json().catch(() => ({}))).status === "accepted") {
1070
+ setSent(true);
1071
+ return;
1072
+ }
1073
+ setError(labels.genericError);
1074
+ } catch {
1075
+ setError(labels.genericError);
1076
+ } finally {
1077
+ setSubmitting(false);
1078
+ }
1079
+ }
1080
+ const rootClass = [
1081
+ "polyx-signin",
1082
+ "polyx-forgot",
1083
+ props.className
1084
+ ].filter(Boolean).join(" ");
1085
+ const footer = props.showSecuredBy === false ? null : /* @__PURE__ */ jsxs("p", {
1086
+ className: "polyx-signin__footer",
1087
+ children: [/* @__PURE__ */ jsx("svg", {
1088
+ className: "polyx-signin__shield",
1089
+ viewBox: "0 0 16 16",
1090
+ fill: "currentColor",
1091
+ "aria-hidden": "true",
1092
+ focusable: "false",
1093
+ children: /* @__PURE__ */ jsx("path", { d: "M8 0.5 2 3v4.2c0 3.5 2.5 6.7 6 7.8 3.5-1.1 6-4.3 6-7.8V3L8 .5Zm2.9 5.6-3.4 3.4a.7.7 0 0 1-1 0L5.1 8.1a.7.7 0 1 1 1-1l.9.9 2.9-2.9a.7.7 0 0 1 1 1Z" })
1094
+ }), labels.securedBy]
1095
+ });
1096
+ const header = (heading, subheading) => /* @__PURE__ */ jsxs("div", {
1097
+ className: "polyx-signin__header",
1098
+ children: [
1099
+ props.logo ? /* @__PURE__ */ jsx("div", {
1100
+ className: "polyx-signin__logo",
1101
+ children: props.logo
1102
+ }) : null,
1103
+ /* @__PURE__ */ jsx("h1", {
1104
+ className: "polyx-signin__heading",
1105
+ children: heading
1106
+ }),
1107
+ /* @__PURE__ */ jsx("p", {
1108
+ className: "polyx-signin__subheading",
1109
+ children: subheading
1110
+ })
1111
+ ]
1112
+ });
1113
+ if (sent) return /* @__PURE__ */ jsx("div", {
1114
+ className: rootClass,
1115
+ "data-polyx-forgot": "sent",
1116
+ children: /* @__PURE__ */ jsx(RecoveryStatus, {
1117
+ icon: "mail",
1118
+ heading: labels.sentHeading,
1119
+ footer,
1120
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
1121
+ type: "button",
1122
+ className: "polyx-signin__link",
1123
+ onClick: () => setSent(false),
1124
+ children: labels.differentEmail
1125
+ }), /* @__PURE__ */ jsx("a", {
1126
+ className: "polyx-signin__link",
1127
+ href: signInPath,
1128
+ children: labels.backToSignIn
1129
+ })] }),
1130
+ children: withEmail(labels.sentSubheading, email)
1131
+ })
1132
+ });
1133
+ return /* @__PURE__ */ jsx("div", {
1134
+ className: rootClass,
1135
+ "data-polyx-forgot": "request",
1136
+ children: /* @__PURE__ */ jsxs("div", {
1137
+ className: "polyx-signin__card",
1138
+ children: [
1139
+ header(labels.heading, labels.subheading),
1140
+ /* @__PURE__ */ jsxs("form", {
1141
+ className: "polyx-signin__form",
1142
+ onSubmit,
1143
+ children: [
1144
+ /* @__PURE__ */ jsxs("label", {
1145
+ className: "polyx-signin__field",
1146
+ children: [/* @__PURE__ */ jsx("span", {
1147
+ className: "polyx-signin__label",
1148
+ children: labels.emailLabel
1149
+ }), /* @__PURE__ */ jsx("input", {
1150
+ className: "polyx-signin__input",
1151
+ type: "email",
1152
+ name: "email",
1153
+ autoComplete: "username",
1154
+ placeholder: labels.emailPlaceholder,
1155
+ required: true,
1156
+ value: email,
1157
+ disabled: submitting,
1158
+ onChange: (event) => setEmail(event.target.value)
1159
+ })]
1160
+ }),
1161
+ error ? /* @__PURE__ */ jsx("p", {
1162
+ role: "alert",
1163
+ className: "polyx-signin__error",
1164
+ children: error
1165
+ }) : null,
1166
+ /* @__PURE__ */ jsx("button", {
1167
+ type: "submit",
1168
+ className: "polyx-signin__submit",
1169
+ disabled: submitting,
1170
+ children: submitting ? labels.submitting : labels.submit
1171
+ })
1172
+ ]
1173
+ }),
1174
+ footer
1175
+ ]
1176
+ })
1177
+ });
1178
+ }
1179
+ //#endregion
1180
+ //#region src/components/reset-password.tsx
1181
+ const DEFAULT_LABELS$1 = {
1182
+ heading: "Choose a new password",
1183
+ subheading: "Enter and confirm your new password.",
1184
+ newPasswordLabel: "New password",
1185
+ confirmPasswordLabel: "Confirm new password",
1186
+ passwordPlaceholder: "••••••••",
1187
+ submit: "Reset password",
1188
+ submitting: "Resetting…",
1189
+ successHeading: "Password updated",
1190
+ successSubheading: "You can now sign in with your new password.",
1191
+ invalidHeading: "This link is invalid or expired",
1192
+ invalidSubheading: "Password reset links can be used once and expire quickly. Request a new one.",
1193
+ requestNewLink: "Request a new link",
1194
+ backToSignIn: "Back to sign in",
1195
+ securedBy: "Secured by PolyX",
1196
+ passwordMismatch: "Those passwords don't match.",
1197
+ weakPassword: "Password must be at least 8 characters.",
1198
+ genericError: "Something went wrong. Please try again."
1199
+ };
1200
+ /** Matches poly-auth's v5 reset floor (F040). */
1201
+ const DEFAULT_MIN_LENGTH = 8;
1202
+ /**
1203
+ * The reset-completion form (poly-x v5 / F040). Reads the opaque token from the URL,
1204
+ * collects a new password, and POSTs to the SDK's BFF (`@poly-x/next` `resetPassword`),
1205
+ * which redeems the token single-use server-side. The token is never decoded in the
1206
+ * browser — identity, validity, and expiry are all enforced by the backend.
1207
+ *
1208
+ * Reuses the `<SignIn/>` styling: `import "@poly-x/react/styles.css"` once, then theme
1209
+ * via the `--polyx-*` custom properties. Target `.polyx-reset` for form-specific tweaks.
1210
+ */
1211
+ function ResetPassword(props) {
1212
+ const action = props.action ?? "/api/polyx/reset-password";
1213
+ const minLength = props.minLength ?? DEFAULT_MIN_LENGTH;
1214
+ const signInPath = props.signInPath ?? "/sign-in";
1215
+ const forgotPasswordPath = props.forgotPasswordPath ?? "/forgot-password";
1216
+ const labels = {
1217
+ ...DEFAULT_LABELS$1,
1218
+ ...props.labels
1219
+ };
1220
+ const [token] = useState(() => props.token ?? (typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("token") ?? "" : ""));
1221
+ const [newPassword, setNewPassword] = useState("");
1222
+ const [confirmPassword, setConfirmPassword] = useState("");
1223
+ const [submitting, setSubmitting] = useState(false);
1224
+ const [error, setError] = useState(null);
1225
+ const [step, setStep] = useState(token ? "form" : "invalid");
1226
+ async function onSubmit(event) {
1227
+ event.preventDefault();
1228
+ if (newPassword !== confirmPassword) {
1229
+ setError(labels.passwordMismatch);
1230
+ return;
1231
+ }
1232
+ setSubmitting(true);
1233
+ setError(null);
1234
+ try {
1235
+ switch ((await (await fetch(action, {
1236
+ method: "POST",
1237
+ headers: { "content-type": "application/json" },
1238
+ body: JSON.stringify({
1239
+ token,
1240
+ newPassword,
1241
+ confirmPassword
1242
+ })
1243
+ })).json().catch(() => ({}))).status) {
1244
+ case "success":
1245
+ setStep("success");
1246
+ if (props.afterResetPath) window.location.assign(props.afterResetPath);
1247
+ return;
1248
+ case "weak_password":
1249
+ setError(labels.weakPassword);
1250
+ return;
1251
+ case "password_mismatch":
1252
+ setError(labels.passwordMismatch);
1253
+ return;
1254
+ case "invalid_or_expired":
1255
+ setStep("invalid");
1256
+ return;
1257
+ default:
1258
+ setError(labels.genericError);
1259
+ return;
1260
+ }
1261
+ } catch {
1262
+ setError(labels.genericError);
1263
+ } finally {
1264
+ setSubmitting(false);
1265
+ }
1266
+ }
1267
+ const rootClass = [
1268
+ "polyx-signin",
1269
+ "polyx-reset",
1270
+ props.className
1271
+ ].filter(Boolean).join(" ");
1272
+ const footer = props.showSecuredBy === false ? null : /* @__PURE__ */ jsxs("p", {
1273
+ className: "polyx-signin__footer",
1274
+ children: [/* @__PURE__ */ jsx("svg", {
1275
+ className: "polyx-signin__shield",
1276
+ viewBox: "0 0 16 16",
1277
+ fill: "currentColor",
1278
+ "aria-hidden": "true",
1279
+ focusable: "false",
1280
+ children: /* @__PURE__ */ jsx("path", { d: "M8 0.5 2 3v4.2c0 3.5 2.5 6.7 6 7.8 3.5-1.1 6-4.3 6-7.8V3L8 .5Zm2.9 5.6-3.4 3.4a.7.7 0 0 1-1 0L5.1 8.1a.7.7 0 1 1 1-1l.9.9 2.9-2.9a.7.7 0 0 1 1 1Z" })
1281
+ }), labels.securedBy]
1282
+ });
1283
+ const header = (heading, subheading) => /* @__PURE__ */ jsxs("div", {
1284
+ className: "polyx-signin__header",
1285
+ children: [
1286
+ props.logo ? /* @__PURE__ */ jsx("div", {
1287
+ className: "polyx-signin__logo",
1288
+ children: props.logo
1289
+ }) : null,
1290
+ /* @__PURE__ */ jsx("h1", {
1291
+ className: "polyx-signin__heading",
1292
+ children: heading
1293
+ }),
1294
+ /* @__PURE__ */ jsx("p", {
1295
+ className: "polyx-signin__subheading",
1296
+ children: subheading
1297
+ })
1298
+ ]
1299
+ });
1300
+ if (step === "success") return /* @__PURE__ */ jsx("div", {
1301
+ className: rootClass,
1302
+ "data-polyx-reset": "success",
1303
+ children: /* @__PURE__ */ jsx(RecoveryStatus, {
1304
+ icon: "check",
1305
+ heading: labels.successHeading,
1306
+ footer,
1307
+ actions: /* @__PURE__ */ jsx("a", {
1308
+ className: "polyx-signin__link",
1309
+ href: signInPath,
1310
+ children: labels.backToSignIn
1311
+ }),
1312
+ children: labels.successSubheading
1313
+ })
1314
+ });
1315
+ if (step === "invalid") return /* @__PURE__ */ jsx("div", {
1316
+ className: rootClass,
1317
+ "data-polyx-reset": "invalid",
1318
+ children: /* @__PURE__ */ jsx(RecoveryStatus, {
1319
+ icon: "warning",
1320
+ tone: "danger",
1321
+ heading: labels.invalidHeading,
1322
+ footer,
1323
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("a", {
1324
+ className: "polyx-signin__link",
1325
+ href: forgotPasswordPath,
1326
+ children: labels.requestNewLink
1327
+ }), /* @__PURE__ */ jsx("a", {
1328
+ className: "polyx-signin__link",
1329
+ href: signInPath,
1330
+ children: labels.backToSignIn
1331
+ })] }),
1332
+ children: labels.invalidSubheading
1333
+ })
1334
+ });
1335
+ return /* @__PURE__ */ jsx("div", {
1336
+ className: rootClass,
1337
+ "data-polyx-reset": step,
1338
+ children: /* @__PURE__ */ jsxs("div", {
1339
+ className: "polyx-signin__card",
1340
+ children: [
1341
+ header(labels.heading, labels.subheading),
1342
+ /* @__PURE__ */ jsxs("form", {
1343
+ className: "polyx-signin__form",
1344
+ onSubmit,
1345
+ children: [
1346
+ /* @__PURE__ */ jsxs("label", {
1347
+ className: "polyx-signin__field",
1348
+ children: [/* @__PURE__ */ jsx("span", {
1349
+ className: "polyx-signin__label",
1350
+ children: labels.newPasswordLabel
1351
+ }), /* @__PURE__ */ jsx("input", {
1352
+ className: "polyx-signin__input",
1353
+ type: "password",
1354
+ name: "newPassword",
1355
+ autoComplete: "new-password",
1356
+ placeholder: labels.passwordPlaceholder,
1357
+ required: true,
1358
+ minLength,
1359
+ value: newPassword,
1360
+ disabled: submitting,
1361
+ onChange: (event) => setNewPassword(event.target.value)
1362
+ })]
1363
+ }),
1364
+ /* @__PURE__ */ jsxs("label", {
1365
+ className: "polyx-signin__field",
1366
+ children: [/* @__PURE__ */ jsx("span", {
1367
+ className: "polyx-signin__label",
1368
+ children: labels.confirmPasswordLabel
1369
+ }), /* @__PURE__ */ jsx("input", {
1370
+ className: "polyx-signin__input",
1371
+ type: "password",
1372
+ name: "confirmPassword",
1373
+ autoComplete: "new-password",
1374
+ placeholder: labels.passwordPlaceholder,
1375
+ required: true,
1376
+ minLength,
1377
+ value: confirmPassword,
1378
+ disabled: submitting,
1379
+ onChange: (event) => setConfirmPassword(event.target.value)
1380
+ })]
1381
+ }),
1382
+ error ? /* @__PURE__ */ jsx("p", {
1383
+ role: "alert",
1384
+ className: "polyx-signin__error",
1385
+ children: error
1386
+ }) : null,
1387
+ /* @__PURE__ */ jsx("button", {
1388
+ type: "submit",
1389
+ className: "polyx-signin__submit",
1390
+ disabled: submitting,
1391
+ children: submitting ? labels.submitting : labels.submit
1392
+ })
1393
+ ]
1394
+ }),
1395
+ footer
1396
+ ]
1397
+ })
362
1398
  });
363
1399
  }
364
1400
  //#endregion
@@ -372,6 +1408,442 @@ function AuthCallback({ children = null }) {
372
1408
  return children;
373
1409
  }
374
1410
  //#endregion
1411
+ //#region src/components/placement.ts
1412
+ const OPPOSITE = {
1413
+ top: "bottom",
1414
+ bottom: "top",
1415
+ left: "right",
1416
+ right: "left"
1417
+ };
1418
+ /** Preference order when nothing is requested: vertical reads better for a menu than sideways. */
1419
+ const AUTO_ORDER = [
1420
+ "bottom",
1421
+ "top",
1422
+ "right",
1423
+ "left"
1424
+ ];
1425
+ /** Room between the trigger and each viewport edge. */
1426
+ function spaceAround(trigger, viewport) {
1427
+ return {
1428
+ top: trigger.top,
1429
+ bottom: viewport.height - trigger.bottom,
1430
+ left: trigger.left,
1431
+ right: viewport.width - trigger.right
1432
+ };
1433
+ }
1434
+ function required(menu, gap) {
1435
+ return {
1436
+ top: menu.height + gap,
1437
+ bottom: menu.height + gap,
1438
+ left: menu.width + gap,
1439
+ right: menu.width + gap
1440
+ };
1441
+ }
1442
+ /**
1443
+ * Which side the menu should open on.
1444
+ *
1445
+ * An explicit side is honoured when it fits and flipped to its opposite when it doesn't — a
1446
+ * requested side that runs off-screen is a worse answer than the one that doesn't. `auto` takes
1447
+ * the first side that fits, and if none do, the roomiest: something has to be chosen, and the
1448
+ * least-bad option beats an arbitrary default.
1449
+ */
1450
+ function resolveSide(preferred, trigger, menu, viewport, gap = 8) {
1451
+ const space = spaceAround(trigger, viewport);
1452
+ const need = required(menu, gap);
1453
+ const fits = (side) => space[side] >= need[side];
1454
+ if (preferred !== "auto") {
1455
+ if (fits(preferred)) return preferred;
1456
+ const opposite = OPPOSITE[preferred];
1457
+ return fits(opposite) ? opposite : preferred;
1458
+ }
1459
+ const firstFitting = AUTO_ORDER.find(fits);
1460
+ if (firstFitting) return firstFitting;
1461
+ return AUTO_ORDER.reduce((best, side) => space[side] > space[best] ? side : best, AUTO_ORDER[0]);
1462
+ }
1463
+ //#endregion
1464
+ //#region src/components/user-avatar.tsx
1465
+ /**
1466
+ * Up to two initials from a display name. Handles the shapes the platform actually
1467
+ * produces — "Ali Ikram", a bare username, an email address.
1468
+ */
1469
+ function initialsFrom(name) {
1470
+ const source = (name ?? "").trim();
1471
+ if (source.length === 0) return "?";
1472
+ const words = (source.includes("@") ? source.split("@")[0] : source).split(/[\s._-]+/).filter(Boolean);
1473
+ if (words.length === 0) return "?";
1474
+ return (words.length === 1 ? [words[0][0]] : [words[0][0], words[words.length - 1][0]]).join("").toUpperCase();
1475
+ }
1476
+ /**
1477
+ * The user's picture, or their initials. A missing avatar is the ordinary case rather than
1478
+ * an error, so initials are a first-class state, not a broken image.
1479
+ */
1480
+ function UserAvatar({ src, name, size = 32, className }) {
1481
+ const rootClass = ["polyx-userbutton__avatar", className].filter(Boolean).join(" ");
1482
+ const style = {
1483
+ width: `${size}px`,
1484
+ height: `${size}px`
1485
+ };
1486
+ if (src) return /* @__PURE__ */ jsx("img", {
1487
+ className: rootClass,
1488
+ style,
1489
+ src,
1490
+ alt: name ?? ""
1491
+ });
1492
+ return /* @__PURE__ */ jsx("span", {
1493
+ className: rootClass,
1494
+ style,
1495
+ "aria-hidden": "true",
1496
+ children: initialsFrom(name)
1497
+ });
1498
+ }
1499
+ //#endregion
1500
+ //#region src/components/user-button.tsx
1501
+ const DEFAULT_LABELS = {
1502
+ trigger: "Account menu",
1503
+ manageAccount: "Manage account",
1504
+ signOut: "Sign out",
1505
+ signOutEverywhere: "Sign out everywhere"
1506
+ };
1507
+ const BUILT_INS = [
1508
+ "manageAccount",
1509
+ "signOut",
1510
+ "signOutEverywhere"
1511
+ ];
1512
+ function isBuiltIn(label) {
1513
+ return BUILT_INS.includes(label);
1514
+ }
1515
+ /**
1516
+ * Declarative markers. `<UserButton/>` reads their props off the element tree and renders the
1517
+ * menu itself, so these never render anything. Their public prop types come from the signatures
1518
+ * declared on the exported object below.
1519
+ *
1520
+ * `Custom` and `Header` take arbitrary nodes on purpose: an app adopting this component in place
1521
+ * of its own user menu has to be able to bring its own UI, or it won't adopt it. That is
1522
+ * composition, not the style injection FR-BRAND-1 guards against — a consumer renders their own
1523
+ * subtree in a slot; they still cannot restyle the SDK's own chrome.
1524
+ */
1525
+ function MenuItems() {
1526
+ return null;
1527
+ }
1528
+ function Action() {
1529
+ return null;
1530
+ }
1531
+ function Link() {
1532
+ return null;
1533
+ }
1534
+ function Custom() {
1535
+ return null;
1536
+ }
1537
+ function Header() {
1538
+ return null;
1539
+ }
1540
+ /**
1541
+ * Read the declarative children into a flat item list. A custom entry naming a built-in
1542
+ * *moves* it; unnamed built-ins keep their default order at the end. This is the whole reason
1543
+ * the API is declarative rather than a config array: it composes without letting a consumer
1544
+ * inject arbitrary markup or styles into the menu (FR-BRAND-1).
1545
+ */
1546
+ function resolveItems(children, builtIns) {
1547
+ const supplied = [];
1548
+ const placed = /* @__PURE__ */ new Set();
1549
+ Children.forEach(children, (child) => {
1550
+ if (!isValidElement(child) || child.type !== MenuItems) return;
1551
+ Children.forEach(child.props.children, (item, index) => {
1552
+ if (!isValidElement(item)) return;
1553
+ if (item.type === Action) {
1554
+ const props = item.props;
1555
+ if (isBuiltIn(props.label)) {
1556
+ const builtIn = builtIns.get(props.label);
1557
+ if (builtIn) {
1558
+ supplied.push(builtIn);
1559
+ placed.add(props.label);
1560
+ }
1561
+ return;
1562
+ }
1563
+ supplied.push({
1564
+ key: `action-${index}`,
1565
+ label: props.label,
1566
+ labelIcon: props.labelIcon,
1567
+ onClick: props.onClick
1568
+ });
1569
+ return;
1570
+ }
1571
+ if (item.type === Link) {
1572
+ const props = item.props;
1573
+ supplied.push({
1574
+ key: `link-${index}`,
1575
+ label: props.label,
1576
+ labelIcon: props.labelIcon,
1577
+ href: props.href
1578
+ });
1579
+ return;
1580
+ }
1581
+ if (item.type === Custom) supplied.push({
1582
+ key: `custom-${index}`,
1583
+ custom: item.props.children
1584
+ });
1585
+ });
1586
+ });
1587
+ const remaining = [...builtIns.entries()].filter(([name]) => !placed.has(name)).map(([, item]) => item);
1588
+ return [...supplied, ...remaining];
1589
+ }
1590
+ /** The consumer's replacement for the identity block, if they supplied one. */
1591
+ function findHeader(children) {
1592
+ let header;
1593
+ Children.forEach(children, (child) => {
1594
+ if (isValidElement(child) && child.type === Header) header = child.props.children;
1595
+ });
1596
+ return header;
1597
+ }
1598
+ function UserButtonRoot(props) {
1599
+ const labels = {
1600
+ ...DEFAULT_LABELS,
1601
+ ...props.labels
1602
+ };
1603
+ const { isLoaded, isSignedIn, signOut } = useAuth();
1604
+ const user = useUser();
1605
+ const [open, setOpen] = useState(props.defaultOpen ?? false);
1606
+ const [focused, setFocused] = useState(0);
1607
+ const [side, setSide] = useState(props.side && props.side !== "auto" ? props.side : "bottom");
1608
+ const triggerRef = useRef(null);
1609
+ const menuRef = useRef(null);
1610
+ const itemRefs = useRef([]);
1611
+ const menuId = useId();
1612
+ const align = props.align ?? "end";
1613
+ const close = useCallback((returnFocus) => {
1614
+ setOpen(false);
1615
+ if (returnFocus) triggerRef.current?.focus();
1616
+ }, []);
1617
+ useEffect(() => {
1618
+ if (!open) return;
1619
+ const onKeyDown = (event) => {
1620
+ if (event.key === "Escape") close(true);
1621
+ };
1622
+ const onPointerDown = (event) => {
1623
+ const root = triggerRef.current?.closest(".polyx-userbutton");
1624
+ if (root && !root.contains(event.target)) close(false);
1625
+ };
1626
+ document.addEventListener("keydown", onKeyDown);
1627
+ document.addEventListener("mousedown", onPointerDown);
1628
+ return () => {
1629
+ document.removeEventListener("keydown", onKeyDown);
1630
+ document.removeEventListener("mousedown", onPointerDown);
1631
+ };
1632
+ }, [open, close]);
1633
+ useEffect(() => {
1634
+ if (open) itemRefs.current[focused]?.focus();
1635
+ }, [open, focused]);
1636
+ /**
1637
+ * Choose the side once the menu exists and can be measured. `useLayoutEffect` runs before
1638
+ * paint, so the correction never shows as a jump — the menu is only ever painted where it
1639
+ * belongs. Re-measured on resize and on scroll, since either can invalidate the choice.
1640
+ */
1641
+ useLayoutEffect(() => {
1642
+ if (!open) return;
1643
+ const place = () => {
1644
+ const trigger = triggerRef.current?.getBoundingClientRect();
1645
+ const menu = menuRef.current?.getBoundingClientRect();
1646
+ if (!trigger || !menu) return;
1647
+ setSide(resolveSide(props.side ?? "auto", trigger, {
1648
+ width: menu.width,
1649
+ height: menu.height
1650
+ }, {
1651
+ width: window.innerWidth,
1652
+ height: window.innerHeight
1653
+ }));
1654
+ };
1655
+ place();
1656
+ window.addEventListener("resize", place);
1657
+ window.addEventListener("scroll", place, true);
1658
+ return () => {
1659
+ window.removeEventListener("resize", place);
1660
+ window.removeEventListener("scroll", place, true);
1661
+ };
1662
+ }, [open, props.side]);
1663
+ if (!isLoaded) return props.fallback ?? null;
1664
+ if (!isSignedIn || !user) return null;
1665
+ const name = user.displayName ?? user.email ?? "";
1666
+ const builtIns = /* @__PURE__ */ new Map();
1667
+ if (props.manageAccountUrl) builtIns.set("manageAccount", {
1668
+ key: "manageAccount",
1669
+ label: labels.manageAccount,
1670
+ labelIcon: /* @__PURE__ */ jsx(GearIcon, {}),
1671
+ href: props.manageAccountUrl
1672
+ });
1673
+ builtIns.set("signOut", {
1674
+ key: "signOut",
1675
+ label: labels.signOut,
1676
+ labelIcon: /* @__PURE__ */ jsx(ExitIcon, {}),
1677
+ onClick: () => void signOut({ redirectTo: props.afterSignOutUrl })
1678
+ });
1679
+ if (props.showSignOutEverywhere) builtIns.set("signOutEverywhere", {
1680
+ key: "signOutEverywhere",
1681
+ label: labels.signOutEverywhere,
1682
+ labelIcon: /* @__PURE__ */ jsx(ExitIcon, {}),
1683
+ onClick: () => void signOut({
1684
+ scope: "everywhere",
1685
+ redirectTo: props.afterSignOutUrl
1686
+ })
1687
+ });
1688
+ const items = resolveItems(props.children, builtIns);
1689
+ const customHeader = findHeader(props.children);
1690
+ const navigable = items.map((item, index) => ({
1691
+ item,
1692
+ index
1693
+ })).filter(({ item }) => !item.custom);
1694
+ const onMenuKeyDown = (event) => {
1695
+ if (navigable.length === 0) return;
1696
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
1697
+ event.preventDefault();
1698
+ const next = (navigable.findIndex(({ index }) => index === focused) + (event.key === "ArrowDown" ? 1 : -1) + navigable.length) % navigable.length;
1699
+ setFocused(navigable[next].index);
1700
+ };
1701
+ return /* @__PURE__ */ jsxs("div", {
1702
+ className: [
1703
+ "polyx-signin",
1704
+ "polyx-userbutton",
1705
+ props.className
1706
+ ].filter(Boolean).join(" "),
1707
+ "data-polyx-userbutton": open ? "open" : "closed",
1708
+ children: [/* @__PURE__ */ jsx("button", {
1709
+ ref: triggerRef,
1710
+ type: "button",
1711
+ className: "polyx-userbutton__trigger",
1712
+ "aria-haspopup": "menu",
1713
+ "aria-expanded": open,
1714
+ "aria-controls": open ? menuId : void 0,
1715
+ "aria-label": labels.trigger,
1716
+ onClick: () => {
1717
+ setFocused(0);
1718
+ setOpen((current) => !current);
1719
+ },
1720
+ children: props.trigger ?? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(UserAvatar, {
1721
+ src: user.avatarUrl,
1722
+ name,
1723
+ size: props.avatarSize
1724
+ }), props.showName ? /* @__PURE__ */ jsx("span", {
1725
+ className: "polyx-userbutton__trigger-name",
1726
+ children: name
1727
+ }) : null] })
1728
+ }), open ? /* @__PURE__ */ jsxs("div", {
1729
+ ref: menuRef,
1730
+ id: menuId,
1731
+ role: "menu",
1732
+ className: "polyx-userbutton__menu",
1733
+ "data-polyx-side": side,
1734
+ "data-polyx-align": align,
1735
+ onKeyDown: onMenuKeyDown,
1736
+ children: [customHeader ?? /* @__PURE__ */ jsxs("div", {
1737
+ className: "polyx-userbutton__identity",
1738
+ children: [/* @__PURE__ */ jsx(UserAvatar, {
1739
+ src: user.avatarUrl,
1740
+ name,
1741
+ size: 36
1742
+ }), /* @__PURE__ */ jsxs("div", {
1743
+ className: "polyx-userbutton__identity-text",
1744
+ children: [user.displayName ? /* @__PURE__ */ jsx("span", {
1745
+ className: "polyx-userbutton__name",
1746
+ children: user.displayName
1747
+ }) : null, user.email ? /* @__PURE__ */ jsx("span", {
1748
+ className: "polyx-userbutton__email",
1749
+ children: user.email
1750
+ }) : null]
1751
+ })]
1752
+ }), /* @__PURE__ */ jsx("ul", {
1753
+ className: "polyx-userbutton__items",
1754
+ children: items.map((item, index) => /* @__PURE__ */ jsx("li", { children: item.custom ? /* @__PURE__ */ jsx("div", {
1755
+ className: "polyx-userbutton__custom",
1756
+ children: item.custom
1757
+ }) : item.href ? /* @__PURE__ */ jsxs("a", {
1758
+ ref: (node) => {
1759
+ itemRefs.current[index] = node;
1760
+ },
1761
+ role: "menuitem",
1762
+ tabIndex: index === focused ? 0 : -1,
1763
+ className: "polyx-userbutton__item",
1764
+ href: item.href,
1765
+ onClick: () => close(false),
1766
+ children: [item.labelIcon, item.label]
1767
+ }) : /* @__PURE__ */ jsxs("button", {
1768
+ ref: (node) => {
1769
+ itemRefs.current[index] = node;
1770
+ },
1771
+ role: "menuitem",
1772
+ tabIndex: index === focused ? 0 : -1,
1773
+ type: "button",
1774
+ className: "polyx-userbutton__item",
1775
+ onClick: () => {
1776
+ close(false);
1777
+ item.onClick?.();
1778
+ },
1779
+ children: [item.labelIcon, item.label]
1780
+ }) }, item.key))
1781
+ })]
1782
+ }) : null]
1783
+ });
1784
+ }
1785
+ function GearIcon() {
1786
+ return /* @__PURE__ */ jsxs("svg", {
1787
+ className: "polyx-userbutton__icon",
1788
+ viewBox: "0 0 16 16",
1789
+ fill: "none",
1790
+ "aria-hidden": "true",
1791
+ focusable: "false",
1792
+ children: [/* @__PURE__ */ jsx("circle", {
1793
+ cx: "8",
1794
+ cy: "8",
1795
+ r: "2.25",
1796
+ stroke: "currentColor",
1797
+ strokeWidth: "1.3"
1798
+ }), /* @__PURE__ */ jsx("path", {
1799
+ d: "M8 1.75v1.5M8 12.75v1.5M14.25 8h-1.5M3.25 8h-1.5M12.42 3.58l-1.06 1.06M4.64 11.36l-1.06 1.06M12.42 12.42l-1.06-1.06M4.64 4.64L3.58 3.58",
1800
+ stroke: "currentColor",
1801
+ strokeWidth: "1.3",
1802
+ strokeLinecap: "round"
1803
+ })]
1804
+ });
1805
+ }
1806
+ function ExitIcon() {
1807
+ return /* @__PURE__ */ jsx("svg", {
1808
+ className: "polyx-userbutton__icon",
1809
+ viewBox: "0 0 16 16",
1810
+ fill: "none",
1811
+ "aria-hidden": "true",
1812
+ focusable: "false",
1813
+ children: /* @__PURE__ */ jsx("path", {
1814
+ d: "M6 2.75H3.75a1 1 0 0 0-1 1v8.5a1 1 0 0 0 1 1H6M10.5 10.5 13 8l-2.5-2.5M13 8H6",
1815
+ stroke: "currentColor",
1816
+ strokeWidth: "1.3",
1817
+ strokeLinecap: "round",
1818
+ strokeLinejoin: "round"
1819
+ })
1820
+ });
1821
+ }
1822
+ /**
1823
+ * The signed-in user surface: avatar trigger + a menu with their identity and sign-out
1824
+ * (FR-SIGNIN-6). Reads only the hooks, so it behaves identically under either custody model
1825
+ * — an httpOnly BFF session or an in-memory SPA one — and needs no provider in a
1826
+ * `@poly-x/next` app.
1827
+ *
1828
+ * Custom entries compose declaratively:
1829
+ *
1830
+ * ```tsx
1831
+ * <UserButton manageAccountUrl="/account">
1832
+ * <UserButton.MenuItems>
1833
+ * <UserButton.Action label="signOut" /> // reposition a built-in
1834
+ * <UserButton.Link label="Docs" href="/docs" />
1835
+ * </UserButton.MenuItems>
1836
+ * </UserButton>
1837
+ * ```
1838
+ */
1839
+ const UserButton = Object.assign(UserButtonRoot, {
1840
+ MenuItems,
1841
+ Action,
1842
+ Link,
1843
+ Custom,
1844
+ Header
1845
+ });
1846
+ //#endregion
375
1847
  //#region src/index.ts
376
1848
  /**
377
1849
  * @poly-x/react — PolyX SDK for React SPAs. Sign-in UI + orchestration land in
@@ -380,4 +1852,4 @@ function AuthCallback({ children = null }) {
380
1852
  const PACKAGE_NAME = "@poly-x/react";
381
1853
  const version = "0.0.0";
382
1854
  //#endregion
383
- export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, SessionStoragePkceStore, SignIn, WebLocksLock, WindowBrowserBridge, createLoginController, useAuth, useAuthCallback, useSession, useUser, version };
1855
+ export { AuthCallback, BroadcastAuthChannel, BroadcastChannelSessionChannel, ForgotPassword, PACKAGE_NAME, PolyXContext, PolyXProvider, PopupCancelledError, Protected, ResetPassword, SessionStoragePkceStore, SignIn, UserAvatar, UserButton, WebLocksLock, WindowBrowserBridge, createLoginController, initialsFrom, resolveSide, useAuth, useAuthCallback, useSession, useUser, version };