@poly-x/react 0.1.0-alpha.0 → 0.1.0-alpha.10

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