@reblu/site-next 1.6.0 → 1.7.1
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/CHANGELOG.md +464 -0
- package/README.md +21 -0
- package/dist/auth.cjs +89 -7
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +82 -2
- package/dist/auth.d.ts +82 -2
- package/dist/auth.js +84 -6
- package/dist/auth.js.map +1 -1
- package/package.json +6 -5
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: "
|
|
32
|
-
|
|
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-
|
|
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
|
-
|
|
1132
|
+
emitMagicLinkRequested,
|
|
1133
|
+
mapLoginError,
|
|
1134
|
+
onMagicLinkRequested
|
|
1057
1135
|
};
|
|
1058
1136
|
//# sourceMappingURL=auth.js.map
|