@reblu/site-next 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.d.ts CHANGED
@@ -12,6 +12,9 @@ interface LoginFormCopy {
12
12
  magicLinkSubmitCta: string;
13
13
  magicLinkSuccessTitle: string;
14
14
  magicLinkSuccessMessage: string;
15
+ /** Countdown line. `{seconds}` is replaced with the seconds remaining. */
16
+ magicLinkCooldownMessage: string;
17
+ magicLinkResendCta: string;
15
18
  mfaTitle: string;
16
19
  mfaMessage: string;
17
20
  mfaCodeLabel: string;
@@ -30,6 +33,14 @@ interface LoginFormProps {
30
33
  copy?: Partial<LoginFormCopy>;
31
34
  className?: string;
32
35
  defaultMethod?: 'magic-link' | 'password';
36
+ /**
37
+ * Seconds the form refuses to request another magic link after a successful
38
+ * request. Mirrors Supabase Auth's per-project "minimum interval per user",
39
+ * which silently drops the second link — see `MAGIC_LINK_COOLDOWN_SECONDS`
40
+ * for where the authoritative value lives and why this is a mirror. Override
41
+ * only for a SITE whose Supabase project uses a different interval.
42
+ */
43
+ cooldownSeconds?: number;
33
44
  onPasswordSubmitted?: (email: string) => void;
34
45
  onMagicLinkSubmitted?: (email: string) => void;
35
46
  onMfaSubmitted?: () => void;
@@ -59,7 +70,76 @@ interface AuthCallbackProps {
59
70
  onError?: (state: 'expired' | 'network' | 'malformed', error?: Error) => void;
60
71
  className?: string;
61
72
  }
62
- declare function AuthCallback({ exchangeUrl, loginUrl, copy, onSuccess, onError, className, }: AuthCallbackProps): react_jsx_runtime.JSX.Element;
73
+ declare function AuthCallback({ exchangeUrl, loginUrl, copy, onSuccess, onError, className, }: AuthCallbackProps): react_jsx_runtime.JSX.Element | null;
74
+
75
+ /**
76
+ * How long the request form refuses to ask for another magic link (REB-762).
77
+ *
78
+ * ## This number is not ours
79
+ *
80
+ * The throttle that actually fires is Supabase Auth's per-project **"Minimum
81
+ * interval per user"** (GoTrue `SMTP_MAX_FREQUENCY`; dashboard →
82
+ * Authentication → Emails → SMTP settings). A second magic link requested
83
+ * inside that window is **dropped before any email is sent**, silently — our
84
+ * route still answers 200, so nothing on the wire says a wait is needed.
85
+ *
86
+ * Measured on the Dermamedicin project on 25-Sep-2026: **60 seconds**. The
87
+ * LOCAL tenant stack sets `max_frequency = "1s"`
88
+ * (`packages/tenant-db/supabase/config.toml`, `[auth.email]`), so the
89
+ * authoritative value already differs per environment.
90
+ *
91
+ * ## Why it is mirrored rather than read
92
+ *
93
+ * It cannot be read at runtime from where it is needed. It lives in the
94
+ * tenant's Supabase project config, reachable only through the Management API
95
+ * with a PAT that must never reach a SITE, and our provisioning never writes it
96
+ * (`patchAuthConfig` sets `site_url`, the redirect allow-list, SMTP relay and
97
+ * templates — not the interval), so we do not own a copy to read either.
98
+ *
99
+ * So this is a **mirror of somebody else's setting, declared exactly once**.
100
+ * The two CAN diverge: an operator changing the interval on a Supabase project
101
+ * does not change this constant. When that happens, override it for that SITE
102
+ * through `LoginForm`'s `cooldownSeconds` prop — do not add a second literal.
103
+ *
104
+ * Divergence is not symmetric: a mirror that is too SHORT re-opens the bug (the
105
+ * user retries into a silent drop), while one that is too LONG only makes an
106
+ * honest wait a little longer than it had to be.
107
+ */
108
+ declare const MAGIC_LINK_COOLDOWN_SECONDS = 60;
109
+
110
+ /**
111
+ * "A magic link was just requested" — the single fact `LoginForm` and
112
+ * `AuthCallback` must share (REB-763).
113
+ *
114
+ * ## Why an event and not a prop
115
+ *
116
+ * A SITE can land on `/auth/callback` with an expired link and re-request from
117
+ * the form rendered on that same page. `AuthCallback` then holds a verdict
118
+ * ("Enlace caducado") about a link the user has already replaced, and the
119
+ * expired notice is the louder of the two messages on screen.
120
+ *
121
+ * Both components are published SDK surface and a SITE may render **only one**
122
+ * of them, so neither may require the other. Lifting the state into the page
123
+ * would make every consuming SITE write the wiring (and the existing ones would
124
+ * keep the bug until they did). A DOM event on `window` couples them at zero
125
+ * API cost: rendering one alone is a dispatch nobody listens to, or a listener
126
+ * nobody fires. No prop is added to `AuthCallback`, so an already-published
127
+ * SITE page picks the fix up by upgrading the package alone.
128
+ *
129
+ * `window` rather than a module-scoped emitter on purpose: the `./auth` entry
130
+ * ships both ESM and CJS builds, and a consumer that reaches the two components
131
+ * through different formats would get two module instances — and a listener
132
+ * registered on the wrong one. There is exactly one `window`.
133
+ */
134
+ /** Public event name — stable surface; a SITE may dispatch or observe it. */
135
+ declare const MAGIC_LINK_REQUESTED_EVENT = "reblu:magic-link-requested";
136
+ /** Announce that a magic-link request just succeeded. No-op during SSR. */
137
+ declare function emitMagicLinkRequested(): void;
138
+ /**
139
+ * Observe magic-link requests. Returns an unsubscribe function, so it plugs
140
+ * straight into a `useEffect` cleanup. No-op during SSR.
141
+ */
142
+ declare function onMagicLinkRequested(listener: () => void): () => void;
63
143
 
64
144
  /**
65
145
  * Maps the raw `?error=` query param that Supabase appends when a magic link is
@@ -271,4 +351,4 @@ interface AuthConfirmProps {
271
351
  */
272
352
  declare function AuthConfirm(props: AuthConfirmProps): react_jsx_runtime.JSX.Element;
273
353
 
274
- export { AuthCallback, type AuthCallbackCopy, type AuthCallbackProps, AuthConfirm, type AuthConfirmCopy, type AuthConfirmProps, AuthLogo, type AuthLogoCopy, type AuthLogoProps, type LoginErrorCopy, type LoginErrorInfo, LoginForm, type LoginFormCopy, type LoginFormProps, ResetPasswordConfirm, type ResetPasswordConfirmCopy, type ResetPasswordConfirmProps, ResetPasswordRequest, type ResetPasswordRequestCopy, type ResetPasswordRequestProps, SetPassword, type SetPasswordCopy, type SetPasswordProps, mapLoginError };
354
+ export { AuthCallback, type AuthCallbackCopy, type AuthCallbackProps, AuthConfirm, type AuthConfirmCopy, type AuthConfirmProps, AuthLogo, type AuthLogoCopy, type AuthLogoProps, type LoginErrorCopy, type LoginErrorInfo, LoginForm, type LoginFormCopy, type LoginFormProps, MAGIC_LINK_COOLDOWN_SECONDS, MAGIC_LINK_REQUESTED_EVENT, ResetPasswordConfirm, type ResetPasswordConfirmCopy, type ResetPasswordConfirmProps, ResetPasswordRequest, type ResetPasswordRequestCopy, type ResetPasswordRequestProps, SetPassword, type SetPasswordCopy, type SetPasswordProps, emitMagicLinkRequested, mapLoginError, onMagicLinkRequested };
package/dist/auth.js CHANGED
@@ -16,6 +16,27 @@ function isSafeRedirectUrl(url) {
16
16
  return ["http", "https"].includes(scheme) || clean.startsWith("/");
17
17
  }
18
18
 
19
+ // src/auth/magic-link-cooldown.ts
20
+ var MAGIC_LINK_COOLDOWN_SECONDS = 60;
21
+
22
+ // src/auth/magic-link-events.ts
23
+ var MAGIC_LINK_REQUESTED_EVENT = "reblu:magic-link-requested";
24
+ function emitMagicLinkRequested() {
25
+ if (typeof window === "undefined") return;
26
+ window.dispatchEvent(new CustomEvent(MAGIC_LINK_REQUESTED_EVENT));
27
+ }
28
+ function onMagicLinkRequested(listener) {
29
+ if (typeof window === "undefined") return () => {
30
+ };
31
+ const handler = () => {
32
+ listener();
33
+ };
34
+ window.addEventListener(MAGIC_LINK_REQUESTED_EVENT, handler);
35
+ return () => {
36
+ window.removeEventListener(MAGIC_LINK_REQUESTED_EVENT, handler);
37
+ };
38
+ }
39
+
19
40
  // src/auth/login-form.tsx
20
41
  import { jsx, jsxs } from "react/jsx-runtime";
21
42
  var DEFAULT_COPY = {
@@ -28,8 +49,15 @@ var DEFAULT_COPY = {
28
49
  passwordSubmitCta: "Iniciar sesi\xF3n",
29
50
  forgotPasswordCta: "\xBFOlvidaste tu contrase\xF1a?",
30
51
  magicLinkSubmitCta: "Enviar enlace de acceso",
31
- magicLinkSuccessTitle: "\xA1Enlace enviado!",
32
- magicLinkSuccessMessage: "Revisa tu correo para acceder.",
52
+ magicLinkSuccessTitle: "Revisa tu correo",
53
+ // True in every branch, and leaks nothing: the request path answers an
54
+ // identical 200 whether or not the account exists, and whether or not an
55
+ // email actually went out (REB-762). "¡Enlace enviado!" asserted a send that
56
+ // may never have happened — the user then waited for something that was not
57
+ // coming.
58
+ magicLinkSuccessMessage: "Si la cuenta existe, recibir\xE1s un enlace en unos minutos. Si ya lo has pedido hace poco, espera antes de volver a intentarlo.",
59
+ magicLinkCooldownMessage: "Podr\xE1s pedir otro enlace en {seconds} s.",
60
+ magicLinkResendCta: "Pedir otro enlace",
33
61
  mfaTitle: "Verificaci\xF3n en dos pasos",
34
62
  mfaMessage: "Introduce el c\xF3digo de 6 d\xEDgitos de tu aplicaci\xF3n autenticadora.",
35
63
  mfaCodeLabel: "C\xF3digo de verificaci\xF3n",
@@ -53,6 +81,7 @@ function LoginForm(props) {
53
81
  copy,
54
82
  className,
55
83
  defaultMethod = "password",
84
+ cooldownSeconds = MAGIC_LINK_COOLDOWN_SECONDS,
56
85
  onPasswordSubmitted,
57
86
  onMagicLinkSubmitted,
58
87
  onMfaSubmitted
@@ -65,12 +94,37 @@ function LoginForm(props) {
65
94
  const [password, setPassword] = React.useState("");
66
95
  const [mfa, setMfa] = React.useState(null);
67
96
  const [mfaCode, setMfaCode] = React.useState("");
97
+ const [cooldownLeft, setCooldownLeft] = React.useState(0);
98
+ const successHeadingRef = React.useRef(null);
99
+ const magicEmailRef = React.useRef(null);
100
+ const returningFromSuccessRef = React.useRef(false);
101
+ const isCoolingDown = cooldownLeft > 0;
102
+ React.useEffect(() => {
103
+ if (!isCoolingDown) return;
104
+ const id = setInterval(() => {
105
+ setCooldownLeft((s) => Math.max(0, s - 1));
106
+ }, 1e3);
107
+ return () => {
108
+ clearInterval(id);
109
+ };
110
+ }, [isCoolingDown]);
111
+ React.useEffect(() => {
112
+ if (state === "magic-link-success") successHeadingRef.current?.focus();
113
+ if (state === "idle" && returningFromSuccessRef.current) {
114
+ returningFromSuccessRef.current = false;
115
+ magicEmailRef.current?.focus();
116
+ }
117
+ }, [state]);
68
118
  const resetTransientState = React.useCallback(() => {
69
119
  setState("idle");
70
120
  setError(null);
71
121
  setMfa(null);
72
122
  setMfaCode("");
73
123
  }, []);
124
+ const handleResend = () => {
125
+ returningFromSuccessRef.current = true;
126
+ resetTransientState();
127
+ };
74
128
  const handleTabChange = (next) => {
75
129
  if (next !== "magic-link" && next !== "password") return;
76
130
  setMethod(next);
@@ -95,6 +149,8 @@ function LoginForm(props) {
95
149
  });
96
150
  if (res.ok) {
97
151
  setState("magic-link-success");
152
+ setCooldownLeft(cooldownSeconds);
153
+ emitMagicLinkRequested();
98
154
  onMagicLinkSubmitted?.(trimmed);
99
155
  return;
100
156
  }
@@ -237,9 +293,18 @@ function LoginForm(props) {
237
293
  ] });
238
294
  }
239
295
  if (state === "magic-link-success") {
240
- return /* @__PURE__ */ jsxs("div", { className: cn("space-y-2 text-center", className), children: [
241
- /* @__PURE__ */ jsx("h2", { className: "text-xl font-semibold", children: c.magicLinkSuccessTitle }),
242
- /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: c.magicLinkSuccessMessage })
296
+ return /* @__PURE__ */ jsxs("div", { className: cn("space-y-3 text-center", className), children: [
297
+ /* @__PURE__ */ jsx("h2", { className: "text-xl font-semibold", ref: successHeadingRef, tabIndex: -1, children: c.magicLinkSuccessTitle }),
298
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: c.magicLinkSuccessMessage }),
299
+ /* @__PURE__ */ jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: cooldownLeft > 0 ? "" : c.magicLinkResendCta }),
300
+ cooldownLeft > 0 ? (
301
+ // A ticking number is VISUAL. Every text mutation inside a live
302
+ // region is a separate announcement, so marking this one would queue
303
+ // ~60 polite interruptions; the heading above already took focus and
304
+ // spoke the new state once. The only transition worth announcing is
305
+ // the end of the wait, which the region above carries.
306
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: c.magicLinkCooldownMessage.replaceAll("{seconds}", String(cooldownLeft)) })
307
+ ) : /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: handleResend, children: c.magicLinkResendCta })
243
308
  ] });
244
309
  }
245
310
  return /* @__PURE__ */ jsx("div", { className: cn("space-y-6", className), children: /* @__PURE__ */ jsxs(Tabs, { value: method, onValueChange: handleTabChange, children: [
@@ -299,6 +364,7 @@ function LoginForm(props) {
299
364
  Input,
300
365
  {
301
366
  id: "login-email-magic",
367
+ ref: magicEmailRef,
302
368
  type: "email",
303
369
  autoComplete: "email",
304
370
  placeholder: c.emailPlaceholder,
@@ -319,6 +385,7 @@ function LoginForm(props) {
319
385
  import * as React2 from "react";
320
386
  import { Button as Button2, cn as cn2 } from "@reblu/site-ui";
321
387
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
388
+ var TERMINAL_FAILURE_STATES = ["expired", "network", "malformed"];
322
389
  var DEFAULT_COPY2 = {
323
390
  loadingTitle: "Autenticando...",
324
391
  loadingMessage: "Estamos verificando tu identidad.",
@@ -430,7 +497,14 @@ function AuthCallback({
430
497
  if (state === "network") retryRef.current?.focus();
431
498
  if (state === "expired" || state === "malformed") linkRef.current?.focus();
432
499
  }, [state]);
500
+ React2.useEffect(
501
+ () => onMagicLinkRequested(() => {
502
+ setState((current) => TERMINAL_FAILURE_STATES.includes(current) ? "stale" : current);
503
+ }),
504
+ []
505
+ );
433
506
  const isLoading = state === "mounting" || state === "parsing" || state === "exchanging";
507
+ if (state === "stale") return null;
434
508
  return /* @__PURE__ */ jsx2("main", { className: cn2("flex min-h-screen items-center justify-center", className), children: /* @__PURE__ */ jsxs2("div", { "aria-live": "polite", className: "w-full max-w-md space-y-4 p-6 text-center", children: [
435
509
  isLoading && /* @__PURE__ */ jsxs2("section", { children: [
436
510
  /* @__PURE__ */ jsx2("h2", { className: "text-xl font-semibold", children: c.loadingTitle }),
@@ -1050,9 +1124,13 @@ export {
1050
1124
  AuthConfirm,
1051
1125
  AuthLogo,
1052
1126
  LoginForm,
1127
+ MAGIC_LINK_COOLDOWN_SECONDS,
1128
+ MAGIC_LINK_REQUESTED_EVENT,
1053
1129
  ResetPasswordConfirm,
1054
1130
  ResetPasswordRequest,
1055
1131
  SetPassword,
1056
- mapLoginError
1132
+ emitMagicLinkRequested,
1133
+ mapLoginError,
1134
+ onMagicLinkRequested
1057
1135
  };
1058
1136
  //# sourceMappingURL=auth.js.map