refactor(auth): share the auth landing and extract AuthField
CI / check (push) Successful in 36s
CI / tests-imaging-browsers (push) Successful in 1m36s
CI / tests-integration (push) Successful in 1m52s
CI / tests-unit (push) Successful in 1m57s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 4m46s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m40s
CI / check (push) Successful in 36s
CI / tests-imaging-browsers (push) Successful in 1m36s
CI / tests-integration (push) Successful in 1m52s
CI / tests-unit (push) Successful in 1m57s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 4m46s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m40s
The login and register pages were near-identical after the previous cleanup, and both forms repeated the same labelled, icon-prefixed input seven times. Move the data loading and page shell into auth-landing.tsx (loadAuthLanding + AuthLandingView) and the input markup into a single AuthField, so the pages become thin compositions and every field shares one focus, icon and aria implementation. The numbered section headers collapse into AuthSectionHeader. No behaviour change.
This commit is contained in:
1 parent
c6e5e0b1a9
commit
aef0939ee9
6 files changed
+381
-338
No files matched your search
@@ -1,24 +1,14 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AuthAlert } from "@/components/auth/auth-alert";
|
||||
import {
|
||||
AuthPageFrame,
|
||||
AuthUsersCards,
|
||||
} from "@/components/auth/auth-page-frame";
|
||||
import { AuthPromoCard } from "@/components/auth/auth-promo-card";
|
||||
AuthLandingView,
|
||||
loadAuthLanding,
|
||||
} from "@/components/auth/auth-landing";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { safeRedirectPath } from "@/lib/auth/safe-redirect";
|
||||
import { cached } from "@/lib/cache";
|
||||
|
||||
import { db, User } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { cachedOnlineCount } from "@/lib/services/public-counters";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.login");
|
||||
@@ -43,13 +33,6 @@ export default async function LoginPage({
|
||||
}>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.login");
|
||||
const [hotelName, cfg, logo] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
captchaConfig(),
|
||||
siteSettings.get("cms_logo", ""),
|
||||
]);
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
const sp = await searchParams;
|
||||
const redirectTo = safeRedirectPath(sp.from);
|
||||
|
||||
@@ -58,81 +41,44 @@ export default async function LoginPage({
|
||||
const session = await auth();
|
||||
if (session?.user?.id) redirect(redirectTo);
|
||||
|
||||
const [online, recentUsers, latestUsers] = await Promise.all([
|
||||
cachedOnlineCount().catch(() => 0),
|
||||
cached(
|
||||
"auth_online_users",
|
||||
10_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(8),
|
||||
{ staleMs: 30000 },
|
||||
).catch(() => []),
|
||||
cached(
|
||||
"auth_latest_users",
|
||||
30_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8),
|
||||
{ staleMs: 60000 },
|
||||
).catch(() => []),
|
||||
]);
|
||||
const data = await loadAuthLanding();
|
||||
|
||||
// `/login?registered=1` is where the sign-up form lands when it could not
|
||||
// auto sign-in (e-mail verification still pending). Without this notice the
|
||||
// visitor would only see an empty login form and no sign their account
|
||||
// exists.
|
||||
// exists. `?reset=1` comes from a successful password reset; saying so
|
||||
// matters because a silent form reads like the reset failed.
|
||||
const notice =
|
||||
sp.registered === "1" ? (
|
||||
<AuthAlert variant="success" className="mb-4">
|
||||
{t("registeredSuccess")}
|
||||
</AuthAlert>
|
||||
) : sp.reset === "1" ? (
|
||||
// `?reset=1` comes from a successful password reset; saying so matters
|
||||
// because the visitor just changed their password and a silent form
|
||||
// reads like the reset failed.
|
||||
<AuthAlert variant="success" className="mb-4">
|
||||
{t("passwordChanged")}
|
||||
</AuthAlert>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<AuthPageFrame
|
||||
hotelName={hotelName}
|
||||
logo={logo}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
notice={notice}
|
||||
<AuthLandingView
|
||||
data={data}
|
||||
copy={{
|
||||
title: t("title"),
|
||||
subtitle: t("subtitle"),
|
||||
notice,
|
||||
promoTitle: t("welcomeBack"),
|
||||
promoSubtitle: t("welcomeBackSub", { hotelName: data.hotelName }),
|
||||
usersOnlineLabel: t("usersOnline", { count: data.online }),
|
||||
whoIsOnlineLabel: t("whoIsOnline"),
|
||||
newestCitizensLabel: t("newestCitizens"),
|
||||
}}
|
||||
form={
|
||||
<LoginForm
|
||||
redirectTo={redirectTo}
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
captcha={data.captcha}
|
||||
nonce={data.nonce}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AuthPromoCard
|
||||
onlineLabel={t("usersOnline", { count: online })}
|
||||
title={t("welcomeBack")}
|
||||
subtitle={t("welcomeBackSub", { hotelName })}
|
||||
/>
|
||||
|
||||
<AuthUsersCards
|
||||
recentUsers={recentUsers}
|
||||
latestUsers={latestUsers}
|
||||
recentTitle={t("whoIsOnline")}
|
||||
latestTitle={t("newestCitizens")}
|
||||
/>
|
||||
</AuthPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
AuthPageFrame,
|
||||
AuthUsersCards,
|
||||
} from "@/components/auth/auth-page-frame";
|
||||
import { AuthPromoCard } from "@/components/auth/auth-promo-card";
|
||||
AuthLandingView,
|
||||
loadAuthLanding,
|
||||
} from "@/components/auth/auth-landing";
|
||||
import { RegisterForm } from "@/components/auth/register-form";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cached } from "@/lib/cache";
|
||||
|
||||
import { db, User } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { cachedOnlineCount } from "@/lib/services/public-counters";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("pages.register");
|
||||
@@ -30,77 +20,38 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const tpr = await getTranslations("pages.register");
|
||||
const [hotelName, cfg, logo] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
captchaConfig(),
|
||||
siteSettings.get("cms_logo", ""),
|
||||
]);
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
const t = await getTranslations("pages.register");
|
||||
|
||||
// An account already exists in this browser — there is nothing to sign up.
|
||||
const session = await auth();
|
||||
if (session?.user?.id) redirect("/me");
|
||||
|
||||
const [online, recentUsers, latestUsers] = await Promise.all([
|
||||
cachedOnlineCount().catch(() => 0),
|
||||
cached(
|
||||
"auth_online_users",
|
||||
10_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(8),
|
||||
{ staleMs: 30000 },
|
||||
).catch(() => []),
|
||||
cached(
|
||||
"auth_latest_users",
|
||||
30_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8),
|
||||
{ staleMs: 60000 },
|
||||
).catch(() => []),
|
||||
]);
|
||||
const data = await loadAuthLanding();
|
||||
|
||||
return (
|
||||
<AuthPageFrame
|
||||
hotelName={hotelName}
|
||||
logo={logo}
|
||||
title={tpr("title")}
|
||||
subtitle={tpr("subtitle")}
|
||||
<AuthLandingView
|
||||
data={data}
|
||||
copy={{
|
||||
title: t("title"),
|
||||
subtitle: t("subtitle"),
|
||||
promoTitle: (
|
||||
<>
|
||||
{t("title")} —{" "}
|
||||
<span className="gradient-text">{data.hotelName}</span>
|
||||
</>
|
||||
),
|
||||
promoSubtitle: t("subtitle"),
|
||||
usersOnlineLabel: t("usersOnline", { count: data.online }),
|
||||
whoIsOnlineLabel: t("whoIsOnline"),
|
||||
newestCitizensLabel: t("newestCitizens"),
|
||||
}}
|
||||
form={
|
||||
<RegisterForm
|
||||
hotelName={hotelName}
|
||||
captcha={{
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
}}
|
||||
nonce={nonce}
|
||||
hotelName={data.hotelName}
|
||||
captcha={data.captcha}
|
||||
nonce={data.nonce}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AuthPromoCard
|
||||
onlineLabel={tpr("usersOnline", { count: online })}
|
||||
title={
|
||||
<>
|
||||
{tpr("title")} — <span className="gradient-text">{hotelName}</span>
|
||||
</>
|
||||
}
|
||||
subtitle={tpr("subtitle")}
|
||||
/>
|
||||
|
||||
<AuthUsersCards
|
||||
recentUsers={recentUsers}
|
||||
latestUsers={latestUsers}
|
||||
recentTitle={tpr("whoIsOnline")}
|
||||
latestTitle={tpr("newestCitizens")}
|
||||
/>
|
||||
</AuthPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { CSSProperties, ReactNode, Ref } from "react";
|
||||
|
||||
const ICON_CLASS =
|
||||
"pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2";
|
||||
|
||||
export interface AuthFieldProps {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
type?: string;
|
||||
/** Controlled value; omit both `value` and `onChange` for an uncontrolled field. */
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
inputMode?:
|
||||
| "none"
|
||||
| "text"
|
||||
| "tel"
|
||||
| "url"
|
||||
| "email"
|
||||
| "numeric"
|
||||
| "decimal"
|
||||
| "search";
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Marks the field with `aria-invalid` and a danger border. */
|
||||
invalid?: boolean;
|
||||
/** id of the element describing this field (e.g. an error banner). */
|
||||
describedBy?: string;
|
||||
iconColor?: string;
|
||||
inputStyle?: CSSProperties;
|
||||
/** Absolutely positioned control inside the input, e.g. `PasswordToggle`. */
|
||||
trailing?: ReactNode;
|
||||
/** Content rendered below the field: strength meter, policy hints, mismatch. */
|
||||
children?: ReactNode;
|
||||
/** Extra classes on the wrapping `space-y` container. */
|
||||
className?: string;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One labelled, icon-prefixed auth input. Login and register repeat this markup
|
||||
* seven times; centralising it keeps the icon, focus and `aria-describedby`
|
||||
* behaviour identical everywhere.
|
||||
*/
|
||||
export function AuthField({
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
icon: Icon,
|
||||
type = "text",
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
inputMode,
|
||||
required,
|
||||
disabled,
|
||||
invalid,
|
||||
describedBy,
|
||||
iconColor = "var(--color-text-muted)",
|
||||
inputStyle,
|
||||
trailing,
|
||||
children,
|
||||
className = "",
|
||||
inputRef,
|
||||
}: AuthFieldProps) {
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
<label htmlFor={id} className="auth-label">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={ICON_CLASS}
|
||||
style={{ color: iconColor }}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={`auth-input has-icon ${trailing ? "has-action" : ""}`}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{trailing}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
AuthPageFrame,
|
||||
AuthUsersCards,
|
||||
type PublicUser,
|
||||
} from "@/components/auth/auth-page-frame";
|
||||
import { AuthPromoCard } from "@/components/auth/auth-promo-card";
|
||||
import type { CaptchaPublicConfig } from "@/components/auth/captcha-widget";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { cachedOnlineCount } from "@/lib/services/public-counters";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export interface AuthLandingData {
|
||||
hotelName: string;
|
||||
logo: string | null;
|
||||
nonce?: string;
|
||||
/** Ready to spread onto a form's `captcha` prop (secrets never included). */
|
||||
captcha: CaptchaPublicConfig;
|
||||
online: number;
|
||||
recentUsers: PublicUser[];
|
||||
latestUsers: PublicUser[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything `/login` and `/register` need around their form: hotel identity,
|
||||
* captcha config, the CSP nonce and the "who is online" / "newest citizens"
|
||||
* rails. The rosters are memoised, so both pages share one cache entry. Kept in
|
||||
* one place because the two landing pages are otherwise identical.
|
||||
*/
|
||||
export async function loadAuthLanding(): Promise<AuthLandingData> {
|
||||
const [hotelName, cfg, logo] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
captchaConfig(),
|
||||
siteSettings.get("cms_logo", ""),
|
||||
]);
|
||||
const captcha: CaptchaPublicConfig = {
|
||||
provider: cfg.provider,
|
||||
siteKey: cfg.siteKey || undefined,
|
||||
field: cfg.field || undefined,
|
||||
};
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
const [online, recentUsers, latestUsers] = await Promise.all([
|
||||
cachedOnlineCount().catch(() => 0),
|
||||
cached(
|
||||
"auth_online_users",
|
||||
10_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(8),
|
||||
{ staleMs: 30000 },
|
||||
).catch(() => []),
|
||||
cached(
|
||||
"auth_latest_users",
|
||||
30_000,
|
||||
() =>
|
||||
db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8),
|
||||
{ staleMs: 60000 },
|
||||
).catch(() => []),
|
||||
]);
|
||||
|
||||
return { hotelName, captcha, logo, nonce, online, recentUsers, latestUsers };
|
||||
}
|
||||
|
||||
/** Locale-resolved strings the landing frame needs, keyed to the page. */
|
||||
export interface AuthLandingCopy {
|
||||
title: string;
|
||||
subtitle: ReactNode;
|
||||
/** Optional success notice rendered above the form (e.g. "registered"). */
|
||||
notice?: ReactNode;
|
||||
promoTitle: ReactNode;
|
||||
promoSubtitle: ReactNode;
|
||||
usersOnlineLabel: string;
|
||||
whoIsOnlineLabel: string;
|
||||
newestCitizensLabel: string;
|
||||
}
|
||||
|
||||
interface AuthLandingViewProps {
|
||||
data: AuthLandingData;
|
||||
copy: AuthLandingCopy;
|
||||
form: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational shell shared by the auth landing pages: top bar, promo panel
|
||||
* and user rails around a caller-supplied form. All data and copy come from the
|
||||
* page so this component stays free of queries and translations.
|
||||
*/
|
||||
export function AuthLandingView({ data, copy, form }: AuthLandingViewProps) {
|
||||
return (
|
||||
<AuthPageFrame
|
||||
hotelName={data.hotelName}
|
||||
logo={data.logo}
|
||||
title={copy.title}
|
||||
subtitle={copy.subtitle}
|
||||
notice={copy.notice}
|
||||
form={form}
|
||||
>
|
||||
<AuthPromoCard
|
||||
onlineLabel={copy.usersOnlineLabel}
|
||||
title={copy.promoTitle}
|
||||
subtitle={copy.promoSubtitle}
|
||||
/>
|
||||
<AuthUsersCards
|
||||
recentUsers={data.recentUsers}
|
||||
latestUsers={data.latestUsers}
|
||||
recentTitle={copy.whoIsOnlineLabel}
|
||||
latestTitle={copy.newestCitizensLabel}
|
||||
/>
|
||||
</AuthPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import { AuthAlert } from "@/components/auth/auth-alert";
|
||||
import { AuthField } from "@/components/auth/auth-field";
|
||||
import {
|
||||
type CaptchaPublicConfig,
|
||||
CaptchaWidget,
|
||||
@@ -145,98 +146,69 @@ export function LoginForm({
|
||||
aria-label={t("signIn")}
|
||||
className={`relative flex flex-col ${compact ? "gap-3" : "gap-4"}`}
|
||||
>
|
||||
<div className={`space-y-1.5 ${lockCredentials}`}>
|
||||
<label htmlFor={`${fieldId}-username`} className="auth-label">
|
||||
{t("username")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<UserRound
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
<AuthField
|
||||
id={`${fieldId}-username`}
|
||||
name="username"
|
||||
type="text"
|
||||
label={t("username")}
|
||||
icon={UserRound}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onChange={setUsername}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
disabled={needs2fa || pending}
|
||||
required
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||
className="auth-input has-icon"
|
||||
invalid={!!error}
|
||||
describedBy={error ? `${fieldId}-error` : undefined}
|
||||
className={lockCredentials}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-1.5 ${lockCredentials}`}>
|
||||
<label htmlFor={`${fieldId}-password`} className="auth-label">
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
<AuthField
|
||||
id={`${fieldId}-password`}
|
||||
name="password"
|
||||
label={t("password")}
|
||||
icon={Lock}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={setPassword}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa || pending}
|
||||
required
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||
className="auth-input has-icon has-action"
|
||||
/>
|
||||
invalid={!!error}
|
||||
describedBy={error ? `${fieldId}-error` : undefined}
|
||||
className={lockCredentials}
|
||||
trailing={
|
||||
<PasswordToggle
|
||||
show={showPassword}
|
||||
onToggle={() => setShowPassword((v) => !v)}
|
||||
labelShow={t("showPassword")}
|
||||
labelHide={t("hidePassword")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{needs2fa ? (
|
||||
<div className="animate-scale-in space-y-1.5">
|
||||
<label htmlFor={`${fieldId}-2fa`} className="auth-label">
|
||||
{t("codePlaceholder")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<KeyRound
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
/>
|
||||
<input
|
||||
ref={codeRef}
|
||||
<AuthField
|
||||
id={`${fieldId}-2fa`}
|
||||
name="code"
|
||||
type="text"
|
||||
label={t("codePlaceholder")}
|
||||
icon={KeyRound}
|
||||
iconColor="var(--color-primary)"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onChange={setCode}
|
||||
placeholder={t("codePlaceholder")}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
disabled={pending}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||
className="auth-input has-icon"
|
||||
style={{
|
||||
invalid={!!error}
|
||||
describedBy={error ? `${fieldId}-error` : undefined}
|
||||
className="animate-scale-in"
|
||||
inputRef={codeRef}
|
||||
inputStyle={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Check,
|
||||
KeyRound,
|
||||
Lock,
|
||||
type LucideIcon,
|
||||
Mail,
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
register,
|
||||
} from "@/actions/register";
|
||||
import { AuthAlert } from "@/components/auth/auth-alert";
|
||||
import { AuthField } from "@/components/auth/auth-field";
|
||||
import { PasswordToggle } from "@/components/auth/password-toggle";
|
||||
import Link from "@/components/link";
|
||||
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
||||
@@ -51,6 +53,51 @@ function passwordStrength(
|
||||
return { score: 5, label: t("strengthStrong"), color: "#16a34a" };
|
||||
}
|
||||
|
||||
/** The numbered section rule above each group of fields ("01 Account", ...). */
|
||||
function AuthSectionHeader({
|
||||
icon: Icon,
|
||||
label,
|
||||
step,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
step: string;
|
||||
}) {
|
||||
const accent = "var(--color-primary-readable, var(--color-primary))";
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" style={{ color: accent }} />
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] font-black uppercase tracking-widest"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="h-px flex-1"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden="true" className="number-mark text-lg">
|
||||
{step}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterForm({
|
||||
hotelName,
|
||||
captcha,
|
||||
@@ -205,156 +252,61 @@ export function RegisterForm({
|
||||
|
||||
{/* Username & Email */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<UserRound
|
||||
className="h-3.5 w-3.5"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
<AuthSectionHeader
|
||||
icon={UserRound}
|
||||
label={t("accountDetails")}
|
||||
step="01"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] font-black uppercase tracking-widest"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
{t("accountDetails")}
|
||||
</span>
|
||||
<span
|
||||
className="h-px flex-1"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden="true" className="number-mark text-lg">
|
||||
01
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="username" className="auth-label">
|
||||
{t("username")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<UserRound
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
<AuthField
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
label={t("username")}
|
||||
icon={UserRound}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
className="auth-input has-icon"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="mail" className="auth-label">
|
||||
{t("email")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
<AuthField
|
||||
id="mail"
|
||||
name="mail"
|
||||
label={t("email")}
|
||||
icon={Mail}
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
autoComplete="email"
|
||||
className="auth-input has-icon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password & Confirm */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
boxShadow:
|
||||
"inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<KeyRound
|
||||
className="h-3.5 w-3.5"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
<AuthSectionHeader
|
||||
icon={KeyRound}
|
||||
label={t("credentials")}
|
||||
step="02"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] font-black uppercase tracking-widest"
|
||||
style={{
|
||||
color: "var(--color-primary-readable, var(--color-primary))",
|
||||
}}
|
||||
>
|
||||
{t("credentials")}
|
||||
</span>
|
||||
<span
|
||||
className="h-px flex-1"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, color-mix(in srgb, var(--color-text-muted) 18%, transparent), transparent)",
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden="true" className="number-mark text-lg">
|
||||
02
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 stagger-children">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="password" className="auth-label">
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
<AuthField
|
||||
id="password"
|
||||
name="password"
|
||||
label={t("password")}
|
||||
icon={Lock}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="auth-input has-icon has-action"
|
||||
required
|
||||
/>
|
||||
trailing={
|
||||
<PasswordToggle
|
||||
show={showPassword}
|
||||
onToggle={() => setShowPassword((v) => !v)}
|
||||
labelShow={t("showPassword")}
|
||||
labelHide={t("hidePassword")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{password.length > 0 && (
|
||||
<div className="animate-fade-in-up space-y-1.5 pt-0.5">
|
||||
<div className="flex gap-1.5">
|
||||
@@ -410,35 +362,27 @@ export function RegisterForm({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="password_confirmation" className="auth-label">
|
||||
{t("confirmPassword")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<input
|
||||
</AuthField>
|
||||
<AuthField
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
label={t("confirmPassword")}
|
||||
icon={Lock}
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
placeholder={t("confirmPasswordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="auth-input has-icon has-action"
|
||||
required
|
||||
/>
|
||||
trailing={
|
||||
<PasswordToggle
|
||||
show={showConfirm}
|
||||
onToggle={() => setShowConfirm((v) => !v)}
|
||||
labelShow={t("showPassword")}
|
||||
labelHide={t("hidePassword")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{mismatch && (
|
||||
<p
|
||||
className="animate-fade-in-up text-xs font-bold"
|
||||
@@ -447,7 +391,7 @@ export function RegisterForm({
|
||||
{t("passwordsDontMatch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AuthField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user