Fix hosted auth UX: autofocus, OTP paste, and email persistence across tabs

Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
Devin AI 2026-07-14 02:17:02 +00:00
parent ce956a0fd2
commit d5f6e571ae
4 changed files with 38 additions and 10 deletions

View File

@ -92,6 +92,11 @@ function HostedAuthPageInner(props: {
const projectFromHook = app.useProject();
const project: AuthProject = props.mockProject ?? projectFromHook;
// Lifted so the typed email survives switching between the "Email" (magic link) and
// "Email & Password" (credential) tabs — the tab content is unmounted when inactive,
// so keeping the email here (instead of inside each form) both persists and shares it.
const [email, setEmail] = useState("");
if (props.automaticRedirect && user != null && props.mockProject == null) {
return (
<Suspense fallback={<HostedAuthLoading fullPage={props.fullPage} />}>
@ -165,16 +170,16 @@ function HostedAuthPageInner(props: {
<TabsTrigger value="password" className={authTabsTriggerClassName}>Email & Password</TabsTrigger>
</TabsList>
<TabsContent value="magic-link" className="focus-visible:outline-none focus-visible:ring-0">
<MagicLinkSignIn />
<MagicLinkSignIn email={email} onEmailChange={setEmail} />
</TabsContent>
<TabsContent value="password" className="focus-visible:outline-none focus-visible:ring-0">
{props.type === "sign-up" ? <CredentialSignUp noPasswordRepeat={props.noPasswordRepeat} /> : <CredentialSignIn />}
{props.type === "sign-up" ? <CredentialSignUp noPasswordRepeat={props.noPasswordRepeat} email={email} onEmailChange={setEmail} /> : <CredentialSignIn email={email} onEmailChange={setEmail} />}
</TabsContent>
</Tabs>
) : project.config.credentialEnabled ? (
props.type === "sign-up" ? <CredentialSignUp noPasswordRepeat={props.noPasswordRepeat} /> : <CredentialSignIn />
props.type === "sign-up" ? <CredentialSignUp noPasswordRepeat={props.noPasswordRepeat} email={email} onEmailChange={setEmail} /> : <CredentialSignIn email={email} onEmailChange={setEmail} />
) : project.config.magicLinkEnabled ? (
<MagicLinkSignIn />
<MagicLinkSignIn email={email} onEmailChange={setEmail} />
) : !(hasOAuthProviders || hasPasskey) ? (
<p className="py-4 text-center text-sm text-destructive">No authentication method enabled.</p>
) : null}

View File

@ -7,9 +7,13 @@ import { Button, Input, Label, PasswordInput } from "~/components/ui";
import { FormWarningText } from "../supporting/form-elements";
import { isValidEmail } from "../supporting/utils";
export function CredentialSignIn() {
export function CredentialSignIn(props: {
email: string,
onEmailChange: (email: string) => void,
}) {
const app = useStackApp();
const [email, setEmail] = useState("");
const email = props.email;
const setEmail = props.onEmailChange;
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
@ -52,6 +56,7 @@ export function CredentialSignIn() {
id="email"
type="email"
autoComplete="email"
autoFocus
className="h-10 rounded-xl border-border bg-background"
value={email}
onChange={(event) => {

View File

@ -10,9 +10,12 @@ import { isValidEmail } from "../supporting/utils";
export function CredentialSignUp(props: {
noPasswordRepeat?: boolean,
email: string,
onEmailChange: (email: string) => void,
}) {
const app = useStackApp();
const [email, setEmail] = useState("");
const email = props.email;
const setEmail = props.onEmailChange;
const [password, setPassword] = useState("");
const [passwordRepeat, setPasswordRepeat] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
@ -67,6 +70,7 @@ export function CredentialSignUp(props: {
id="email"
type="email"
autoComplete="email"
autoFocus
className="h-10 rounded-xl border-border bg-background"
value={email}
onChange={(event) => {

View File

@ -1,7 +1,7 @@
import { KnownErrors } from "@hexclave/shared";
import { useStackApp } from "@hexclave/react";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Button,
@ -24,6 +24,13 @@ function MagicLinkOtp(props: {
const [otp, setOtp] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const otpInputRef = useRef<HTMLInputElement | null>(null);
// Focus the (visually hidden) OTP input on mount so the user can type or paste the
// code immediately without having to click on the boxes first.
useEffect(() => {
otpInputRef.current?.focus();
}, []);
useEffect(() => {
if (otp.length !== 6 || submitting) {
@ -56,9 +63,11 @@ function MagicLinkOtp(props: {
<form className="mb-4 flex w-full flex-col items-center">
<Typography className="mb-4 text-center text-sm text-muted-foreground">Enter the code from your email</Typography>
<InputOTP
ref={otpInputRef}
maxLength={6}
type="text"
inputMode="text"
autoComplete="one-time-code"
pattern="^[a-zA-Z0-9]+$"
value={otp}
onChange={(value) => setOtp(value.toUpperCase())}
@ -79,9 +88,13 @@ function MagicLinkOtp(props: {
);
}
export function MagicLinkSignIn() {
export function MagicLinkSignIn(props: {
email: string,
onEmailChange: (email: string) => void,
}) {
const app = useStackApp();
const [email, setEmail] = useState("");
const email = props.email;
const setEmail = props.onEmailChange;
const [emailError, setEmailError] = useState<string | null>(null);
const [nonce, setNonce] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@ -130,6 +143,7 @@ export function MagicLinkSignIn() {
id="email"
type="email"
autoComplete="email"
autoFocus
className="h-10 rounded-xl border-border bg-background"
value={email}
onChange={(event) => {