Magic-link sign in UI and APIs

This commit is contained in:
adilallo
2026-04-06 16:37:15 -06:00
parent 331ed40234
commit 7218947df3
74 changed files with 1582 additions and 363 deletions
@@ -0,0 +1,138 @@
"use client";
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
import { LoginView } from "./Login.view";
import type { LoginProps } from "./Login.types";
const LoginContainer = memo<LoginProps>(
({
isOpen,
onClose,
children,
belowCard,
className = "",
ariaLabel,
ariaLabelledBy,
usePortal = true,
}) => {
const dialogRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const previousActiveElementRef = useRef<HTMLElement | null>(null);
const [portalReady, setPortalReady] = useState(() => !usePortal);
// Defer enabling the portal until after the layout commit so we dont sync-setState
// inside the effect (eslint react-hooks/set-state-in-effect) while still mounting
// before the next paint, avoiding a flash of underlying layout.
useLayoutEffect(() => {
if (!usePortal) return;
const id = requestAnimationFrame(() => {
setPortalReady(true);
});
return () => cancelAnimationFrame(id);
}, [usePortal]);
useEffect(() => {
if (!isOpen) return;
if (usePortal && !portalReady) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, portalReady, onClose, usePortal]);
useEffect(() => {
if (!isOpen) return;
if (usePortal && !portalReady) return;
previousActiveElementRef.current = document.activeElement as HTMLElement;
document.body.style.overflow = "hidden";
if (dialogRef.current) {
const dialog = dialogRef.current;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusInitial = () => {
const emailField = dialog.querySelector<HTMLInputElement>(
'input[type="email"]:not([disabled])',
);
if (emailField) {
emailField.focus();
return;
}
const focusableElements = dialog.querySelectorAll(focusableSelector);
const firstElement = focusableElements[0] as HTMLElement;
if (firstElement) {
firstElement.focus();
} else {
dialog.setAttribute("tabindex", "-1");
dialog.focus();
}
};
requestAnimationFrame(() => {
requestAnimationFrame(focusInitial);
});
}
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !dialogRef.current) return;
const focusableElements = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as NodeListOf<HTMLElement>;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
};
document.addEventListener("keydown", handleTab);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", handleTab);
previousActiveElementRef.current?.focus();
};
}, [isOpen, portalReady, usePortal]);
return (
<LoginView
isOpen={isOpen}
onClose={onClose}
belowCard={belowCard}
className={className}
ariaLabel={ariaLabel}
ariaLabelledBy={ariaLabelledBy}
dialogRef={dialogRef}
backdropRef={backdropRef}
portalReady={portalReady}
usePortal={usePortal}
>
{children}
</LoginView>
);
},
);
LoginContainer.displayName = "Login";
export default LoginContainer;
@@ -0,0 +1,31 @@
export interface LoginProps {
isOpen: boolean;
onClose: () => void;
children?: React.ReactNode;
/** Rendered below the dialog card (e.g. “Back to home”) on the dimmed backdrop */
belowCard?: React.ReactNode;
className?: string;
ariaLabel?: string;
ariaLabelledBy?: string;
/**
* When false, render the overlay in the React tree instead of `document.body`.
* Use on the dedicated `/login` page so the shell (and heading) mount on first paint
* without waiting for a portal gate (more reliable across engines).
*/
usePortal?: boolean;
}
export interface LoginViewProps {
isOpen: boolean;
onClose: () => void;
children?: React.ReactNode;
belowCard?: React.ReactNode;
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
dialogRef: React.RefObject<HTMLDivElement | null>;
backdropRef: React.RefObject<HTMLDivElement | null>;
/** False until client mount — avoids SSR/client HTML mismatch for createPortal. */
portalReady: boolean;
usePortal: boolean;
}
@@ -0,0 +1,57 @@
"use client";
import { createPortal } from "react-dom";
import ModalHeader from "../../utility/ModalHeader";
import type { LoginViewProps } from "./Login.types";
export function LoginView({
isOpen,
onClose,
children,
belowCard,
className,
ariaLabel,
ariaLabelledBy,
dialogRef,
backdropRef,
portalReady,
usePortal,
}: LoginViewProps) {
if (!isOpen) return null;
if (usePortal && !portalReady) return null;
const content = (
<div
ref={backdropRef}
className="fixed inset-0 z-[9998] flex flex-col items-center justify-center gap-6 overflow-y-auto bg-[var(--color-surface-inverse-brand-primary)] px-4 py-8"
onClick={onClose}
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={`flex min-h-0 max-h-[90vh] w-full max-w-[560px] shrink-0 flex-col overflow-hidden rounded-[var(--radius-500,20px)] bg-[var(--color-surface-default-primary)] shadow-[0px_0px_48px_0px_rgba(0,0,0,0.1)] z-[9999] ${className}`}
onClick={(e) => e.stopPropagation()}
>
<ModalHeader onClose={onClose} onMoreOptions={onClose} />
<div className="scrollbar-design flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto px-6 pb-8 pt-0">
{children}
</div>
</div>
{belowCard ? (
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
{belowCard}
</div>
) : null}
</div>
);
if (usePortal) {
return createPortal(content, document.body);
}
return content;
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import Link from "next/link";
import { useCallback, useId, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import Button from "../../buttons/Button";
import TextInput from "../../controls/TextInput";
import ContentLockup from "../../type/ContentLockup";
import { requestMagicLink } from "../../../../lib/create/api";
import { safeInternalPath } from "../../../../lib/safeInternalPath";
/** Mail icon for login modal (inline SVG; same pattern as InfoMessageBox ExclamationIconInline). */
function MailIconInline() {
return (
<svg
width={22}
height={22}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
aria-hidden
data-name="Asset / Icon / mail"
>
<path
fill="#000000"
d="M1.5 8.67v8.58a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V8.67l-8.928 5.493a3 3 0 0 1-3.144 0L1.5 8.67Z"
/>
<path
fill="#000000"
d="M22.5 6.908V6.75A2.25 2.25 0 0 0 20.25 4.5h-16.5A2.25 2.25 0 0 0 1.5 6.75v.158l9.714 5.978a1.5 1.5 0 0 0 1.572 0L22.5 6.908Z"
/>
</svg>
);
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function LoginForm() {
const t = useTranslation("pages.login");
const tFooter = useTranslation("footer");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const formAlertId = useId();
const emailErrorId = useId();
const [email, setEmail] = useState("");
const [submitting, setSubmitting] = useState(false);
const [emailError, setEmailError] = useState("");
const [formError, setFormError] = useState("");
const [sent, setSent] = useState(false);
const nextParam = searchParams.get("next");
const errorParam = searchParams.get("error");
/** Drop `error` from the URL so URL-driven messages dont linger after a new attempt. */
const stripErrorQuery = useCallback(() => {
if (!searchParams.get("error")) return;
const params = new URLSearchParams(searchParams.toString());
params.delete("error");
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false });
}, [pathname, router, searchParams]);
const sendLink = useCallback(async () => {
stripErrorQuery();
setEmailError("");
setFormError("");
const trimmed = email.trim().toLowerCase();
if (!EMAIL_PATTERN.test(trimmed)) {
setEmailError(t("errors.emailInvalid"));
return;
}
setSubmitting(true);
try {
const nextPath = safeInternalPath(nextParam);
const result = await requestMagicLink(trimmed, nextPath);
if (result.ok === false) {
if (result.retryAfterMs != null && result.retryAfterMs > 0) {
const seconds = Math.ceil(result.retryAfterMs / 1000);
setFormError(
t("errors.rateLimited").replace("{seconds}", String(seconds)),
);
} else {
setFormError(result.error || t("errors.generic"));
}
return;
}
setEmail(trimmed);
setSent(true);
} catch {
setFormError(t("errors.network"));
} finally {
setSubmitting(false);
}
}, [email, nextParam, stripErrorQuery, t]);
const urlErrorMessage =
errorParam === "expired_link"
? t("errors.expiredLink")
: errorParam === "invalid_link" || errorParam === "server"
? errorParam === "server"
? t("errors.serverError")
: t("errors.invalidLink")
: "";
return (
<div className="flex flex-col gap-6 pt-2">
<div className="flex flex-col gap-3">
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-inverse-brand-primary)]">
<MailIconInline />
</div>
<ContentLockup
titleId="login-modal-heading"
title={sent ? t("successTitle") : t("title")}
description={sent ? t("successBody") : t("subtitle")}
variant="login"
alignment="left"
/>
</div>
{urlErrorMessage ? (
<p
role="alert"
aria-live="polite"
className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
>
{urlErrorMessage}
</p>
) : null}
{formError ? (
<p
id={formAlertId}
role="alert"
aria-live="polite"
className="font-inter text-[14px] leading-[20px] text-[var(--color-border-default-utility-negative)]"
>
{formError}
</p>
) : null}
{!sent ? (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
void sendLink();
}}
noValidate
>
<TextInput
label={t("emailLabel")}
placeholder={t("emailPlaceholder")}
type="email"
name="email"
autoComplete="email"
inputMode="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
stripErrorQuery();
}}
disabled={submitting}
error={Boolean(emailError)}
showHelpIcon
/>
{emailError ? (
<p
id={emailErrorId}
role="alert"
aria-live="polite"
className="font-inter text-[14px] text-[var(--color-border-default-utility-negative)]"
>
{emailError}
</p>
) : null}
<Button
type="submit"
size="large"
buttonType="filled"
palette="default"
disabled={submitting}
className="w-full !justify-center text-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-012)]"
>
{t("sendMagicLink")}
</Button>
<p className="text-center font-inter text-[14px] leading-[20px] text-[var(--color-content-default-tertiary)]">
{t("legalPrefix")}
<Link
href="#"
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
>
{tFooter("legal.termsOfService")}
</Link>
{t("legalAnd")}
<Link
href="#"
className="text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2"
>
{tFooter("legal.privacyPolicy")}
</Link>
{t("legalSuffix")}
</p>
</form>
) : null}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./Login.container";
export type { LoginProps } from "./Login.types";