@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/README.md CHANGED
@@ -108,6 +108,27 @@ import { LoginForm, AuthLogo, mapLoginError } from '@reblu/site-next/auth'
108
108
  | `AuthLogo` | — | Dynamic tenant logo, `next-themes`-driven |
109
109
  | `mapLoginError` | — | Maps Supabase `?error=` to Spanish copy |
110
110
 
111
+ ### Recovering from an expired magic link (REB-762 / REB-763)
112
+
113
+ A SITE may render `LoginForm` on the same page as `AuthCallback`, so someone who
114
+ arrives with a dead link can ask for a new one without leaving. Two things make
115
+ that path work, and both are on by default — no wiring in the SITE:
116
+
117
+ - **A successful request clears the callback's verdict.** `LoginForm` dispatches
118
+ the `reblu:magic-link-requested` DOM event on `window`; `AuthCallback` listens
119
+ and drops a terminal verdict about the old link ("Enlace caducado", "Enlace
120
+ inválido", or a failed exchange), rendering nothing so the form's own message
121
+ is the only one on screen. Both components stay independently usable: alone,
122
+ one dispatches to nobody and the other hears nothing. `emitMagicLinkRequested`
123
+ and `onMagicLinkRequested` are exported if a SITE wants to join in.
124
+ - **A 60-second cooldown on the form.** Supabase Auth drops a second magic link
125
+ requested inside its per-project "minimum interval per user" *before any email
126
+ is sent*, while the request route still answers 200. The success panel holds
127
+ the form for `MAGIC_LINK_COOLDOWN_SECONDS` with a visible countdown, then
128
+ offers "Pedir otro enlace". The interval is the Supabase project's setting,
129
+ not ours — `MAGIC_LINK_COOLDOWN_SECONDS` mirrors it in one place, and a SITE
130
+ whose project uses a different value passes `cooldownSeconds`.
131
+
111
132
  `ResetPasswordConfirm`, `SetPassword`, and `AuthConfirm` read their own URL
112
133
  params internally via `useSearchParams()`. Next.js requires that hook's
113
134
  subtree to render inside a `<Suspense>` boundary — wrap the page/route that
package/dist/auth.cjs CHANGED
@@ -35,10 +35,14 @@ __export(auth_exports, {
35
35
  AuthConfirm: () => AuthConfirm,
36
36
  AuthLogo: () => AuthLogo,
37
37
  LoginForm: () => LoginForm,
38
+ MAGIC_LINK_COOLDOWN_SECONDS: () => MAGIC_LINK_COOLDOWN_SECONDS,
39
+ MAGIC_LINK_REQUESTED_EVENT: () => MAGIC_LINK_REQUESTED_EVENT,
38
40
  ResetPasswordConfirm: () => ResetPasswordConfirm,
39
41
  ResetPasswordRequest: () => ResetPasswordRequest,
40
42
  SetPassword: () => SetPassword,
41
- mapLoginError: () => mapLoginError
43
+ emitMagicLinkRequested: () => emitMagicLinkRequested,
44
+ mapLoginError: () => mapLoginError,
45
+ onMagicLinkRequested: () => onMagicLinkRequested
42
46
  });
43
47
  module.exports = __toCommonJS(auth_exports);
44
48
 
@@ -58,6 +62,27 @@ function isSafeRedirectUrl(url) {
58
62
  return ["http", "https"].includes(scheme) || clean.startsWith("/");
59
63
  }
60
64
 
65
+ // src/auth/magic-link-cooldown.ts
66
+ var MAGIC_LINK_COOLDOWN_SECONDS = 60;
67
+
68
+ // src/auth/magic-link-events.ts
69
+ var MAGIC_LINK_REQUESTED_EVENT = "reblu:magic-link-requested";
70
+ function emitMagicLinkRequested() {
71
+ if (typeof window === "undefined") return;
72
+ window.dispatchEvent(new CustomEvent(MAGIC_LINK_REQUESTED_EVENT));
73
+ }
74
+ function onMagicLinkRequested(listener) {
75
+ if (typeof window === "undefined") return () => {
76
+ };
77
+ const handler = () => {
78
+ listener();
79
+ };
80
+ window.addEventListener(MAGIC_LINK_REQUESTED_EVENT, handler);
81
+ return () => {
82
+ window.removeEventListener(MAGIC_LINK_REQUESTED_EVENT, handler);
83
+ };
84
+ }
85
+
61
86
  // src/auth/login-form.tsx
62
87
  var import_jsx_runtime = require("react/jsx-runtime");
63
88
  var DEFAULT_COPY = {
@@ -70,8 +95,15 @@ var DEFAULT_COPY = {
70
95
  passwordSubmitCta: "Iniciar sesi\xF3n",
71
96
  forgotPasswordCta: "\xBFOlvidaste tu contrase\xF1a?",
72
97
  magicLinkSubmitCta: "Enviar enlace de acceso",
73
- magicLinkSuccessTitle: "\xA1Enlace enviado!",
74
- magicLinkSuccessMessage: "Revisa tu correo para acceder.",
98
+ magicLinkSuccessTitle: "Revisa tu correo",
99
+ // True in every branch, and leaks nothing: the request path answers an
100
+ // identical 200 whether or not the account exists, and whether or not an
101
+ // email actually went out (REB-762). "¡Enlace enviado!" asserted a send that
102
+ // may never have happened — the user then waited for something that was not
103
+ // coming.
104
+ 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.",
105
+ magicLinkCooldownMessage: "Podr\xE1s pedir otro enlace en {seconds} s.",
106
+ magicLinkResendCta: "Pedir otro enlace",
75
107
  mfaTitle: "Verificaci\xF3n en dos pasos",
76
108
  mfaMessage: "Introduce el c\xF3digo de 6 d\xEDgitos de tu aplicaci\xF3n autenticadora.",
77
109
  mfaCodeLabel: "C\xF3digo de verificaci\xF3n",
@@ -95,6 +127,7 @@ function LoginForm(props) {
95
127
  copy,
96
128
  className,
97
129
  defaultMethod = "password",
130
+ cooldownSeconds = MAGIC_LINK_COOLDOWN_SECONDS,
98
131
  onPasswordSubmitted,
99
132
  onMagicLinkSubmitted,
100
133
  onMfaSubmitted
@@ -107,12 +140,37 @@ function LoginForm(props) {
107
140
  const [password, setPassword] = React.useState("");
108
141
  const [mfa, setMfa] = React.useState(null);
109
142
  const [mfaCode, setMfaCode] = React.useState("");
143
+ const [cooldownLeft, setCooldownLeft] = React.useState(0);
144
+ const successHeadingRef = React.useRef(null);
145
+ const magicEmailRef = React.useRef(null);
146
+ const returningFromSuccessRef = React.useRef(false);
147
+ const isCoolingDown = cooldownLeft > 0;
148
+ React.useEffect(() => {
149
+ if (!isCoolingDown) return;
150
+ const id = setInterval(() => {
151
+ setCooldownLeft((s) => Math.max(0, s - 1));
152
+ }, 1e3);
153
+ return () => {
154
+ clearInterval(id);
155
+ };
156
+ }, [isCoolingDown]);
157
+ React.useEffect(() => {
158
+ if (state === "magic-link-success") successHeadingRef.current?.focus();
159
+ if (state === "idle" && returningFromSuccessRef.current) {
160
+ returningFromSuccessRef.current = false;
161
+ magicEmailRef.current?.focus();
162
+ }
163
+ }, [state]);
110
164
  const resetTransientState = React.useCallback(() => {
111
165
  setState("idle");
112
166
  setError(null);
113
167
  setMfa(null);
114
168
  setMfaCode("");
115
169
  }, []);
170
+ const handleResend = () => {
171
+ returningFromSuccessRef.current = true;
172
+ resetTransientState();
173
+ };
116
174
  const handleTabChange = (next) => {
117
175
  if (next !== "magic-link" && next !== "password") return;
118
176
  setMethod(next);
@@ -137,6 +195,8 @@ function LoginForm(props) {
137
195
  });
138
196
  if (res.ok) {
139
197
  setState("magic-link-success");
198
+ setCooldownLeft(cooldownSeconds);
199
+ emitMagicLinkRequested();
140
200
  onMagicLinkSubmitted?.(trimmed);
141
201
  return;
142
202
  }
@@ -279,9 +339,18 @@ function LoginForm(props) {
279
339
  ] });
280
340
  }
281
341
  if (state === "magic-link-success") {
282
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: (0, import_site_ui.cn)("space-y-2 text-center", className), children: [
283
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { className: "text-xl font-semibold", children: c.magicLinkSuccessTitle }),
284
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "text-muted-foreground", children: c.magicLinkSuccessMessage })
342
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: (0, import_site_ui.cn)("space-y-3 text-center", className), children: [
343
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { className: "text-xl font-semibold", ref: successHeadingRef, tabIndex: -1, children: c.magicLinkSuccessTitle }),
344
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "text-muted-foreground", children: c.magicLinkSuccessMessage }),
345
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { role: "status", "aria-live": "polite", className: "sr-only", children: cooldownLeft > 0 ? "" : c.magicLinkResendCta }),
346
+ cooldownLeft > 0 ? (
347
+ // A ticking number is VISUAL. Every text mutation inside a live
348
+ // region is a separate announcement, so marking this one would queue
349
+ // ~60 polite interruptions; the heading above already took focus and
350
+ // spoke the new state once. The only transition worth announcing is
351
+ // the end of the wait, which the region above carries.
352
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "text-muted-foreground text-sm", children: c.magicLinkCooldownMessage.replaceAll("{seconds}", String(cooldownLeft)) })
353
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_site_ui.Button, { type: "button", variant: "outline", onClick: handleResend, children: c.magicLinkResendCta })
285
354
  ] });
286
355
  }
287
356
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: (0, import_site_ui.cn)("space-y-6", className), children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_tabs.Tabs, { value: method, onValueChange: handleTabChange, children: [
@@ -341,6 +410,7 @@ function LoginForm(props) {
341
410
  import_site_ui.Input,
342
411
  {
343
412
  id: "login-email-magic",
413
+ ref: magicEmailRef,
344
414
  type: "email",
345
415
  autoComplete: "email",
346
416
  placeholder: c.emailPlaceholder,
@@ -361,6 +431,7 @@ function LoginForm(props) {
361
431
  var React2 = __toESM(require("react"), 1);
362
432
  var import_site_ui2 = require("@reblu/site-ui");
363
433
  var import_jsx_runtime2 = require("react/jsx-runtime");
434
+ var TERMINAL_FAILURE_STATES = ["expired", "network", "malformed"];
364
435
  var DEFAULT_COPY2 = {
365
436
  loadingTitle: "Autenticando...",
366
437
  loadingMessage: "Estamos verificando tu identidad.",
@@ -472,7 +543,14 @@ function AuthCallback({
472
543
  if (state === "network") retryRef.current?.focus();
473
544
  if (state === "expired" || state === "malformed") linkRef.current?.focus();
474
545
  }, [state]);
546
+ React2.useEffect(
547
+ () => onMagicLinkRequested(() => {
548
+ setState((current) => TERMINAL_FAILURE_STATES.includes(current) ? "stale" : current);
549
+ }),
550
+ []
551
+ );
475
552
  const isLoading = state === "mounting" || state === "parsing" || state === "exchanging";
553
+ if (state === "stale") return null;
476
554
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("main", { className: (0, import_site_ui2.cn)("flex min-h-screen items-center justify-center", className), children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { "aria-live": "polite", className: "w-full max-w-md space-y-4 p-6 text-center", children: [
477
555
  isLoading && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { children: [
478
556
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { className: "text-xl font-semibold", children: c.loadingTitle }),
@@ -1093,9 +1171,13 @@ function AuthConfirm(props) {
1093
1171
  AuthConfirm,
1094
1172
  AuthLogo,
1095
1173
  LoginForm,
1174
+ MAGIC_LINK_COOLDOWN_SECONDS,
1175
+ MAGIC_LINK_REQUESTED_EVENT,
1096
1176
  ResetPasswordConfirm,
1097
1177
  ResetPasswordRequest,
1098
1178
  SetPassword,
1099
- mapLoginError
1179
+ emitMagicLinkRequested,
1180
+ mapLoginError,
1181
+ onMagicLinkRequested
1100
1182
  });
1101
1183
  //# sourceMappingURL=auth.cjs.map