Zum Hauptinhalt springen
Version: latest

Login- & OTP-Formular-Muster

Ein zweistufiger Authentifizierungsflow — E-Mail/Passwort gefolgt von einer numerischen OTP-Eingabe — ist eine häufige Anforderung für sichere Webanwendungen. Dieses Muster verbindet die prokodo-UI-Komponenten Form, Input, InputOtp, Button und Loading zu einem vollständigen clientseitigen Flow mit Next.js Server Action.


Wann dieses Muster verwenden

  • Anmelde-Flows mit MFA/2FA via zeitbasiertem oder per E-Mail versendeten OTP
  • Beliebige Schritt-Formulare, bei denen der zweite Bildschirm vom Ergebnis des ersten abhängt

Schritt 1 — E-Mail & Passwort

components/auth/LoginForm.tsx
"use client"

import { useActionState } from "react"
import { Input } from "@prokodo/ui/input/client"
import { Button } from "@prokodo/ui/button/client"
import { Loading } from "@prokodo/ui/loading/client"
import "@prokodo/ui/input.css"
import "@prokodo/ui/button.css"
import "@prokodo/ui/loading.css"

type State = { step: "credentials" | "otp"; error?: string } | undefined

async function loginAction(_prev: State, formData: FormData): Promise<State> {
"use server"
const email = formData.get("email") as string
const password = formData.get("password") as string
const result = await verifyCredentials(email, password)
if (!result.ok) return { step: "credentials", error: result.error }
await sendOtp(email)
return { step: "otp" }
}

export function LoginForm() {
const [state, action, isPending] = useActionState(loginAction, undefined)

if (state?.step === "otp") return <OtpForm />

return (
<form action={action} style={{ display: "grid", gap: "1rem" }}>
<Input
name="email"
type="email"
label="E-Mail-Adresse"
required
autoComplete="username"
errorText={state?.error}
/>
<Input
name="password"
type="password"
label="Passwort"
required
autoComplete="current-password"
/>
<Button
title={isPending ? "Wird angemeldet…" : "Anmelden"}
type="submit"
variant="contained"
color="primary"
disabled={isPending}
fullWidth
loading={isPending}
/>
</form>
)
}

Schritt 2 — OTP-Verifizierung

components/auth/OtpForm.tsx
"use client"

import { useActionState } from "react"
import { InputOtp } from "@prokodo/ui/input-otp/client"
import { Button } from "@prokodo/ui/button/client"
import "@prokodo/ui/input-otp.css"
import "@prokodo/ui/button.css"

type OtpState = { error?: string } | undefined

async function verifyOtpAction(
_prev: OtpState,
formData: FormData,
): Promise<OtpState> {
"use server"
const code = formData.get("otp") as string
const result = await verifyOtp(code)
if (!result.ok) return { error: "Ungültiger oder abgelaufener Code" }
return undefined
}

export function OtpForm() {
const [state, action, isPending] = useActionState(verifyOtpAction, undefined)

return (
<form action={action} style={{ display: "grid", gap: "1rem" }}>
<p>
Gib den 6-stelligen Code ein, den wir an deine E-Mail gesendet haben.
</p>
<InputOtp name="otp" length={6} autoFocus errorText={state?.error} />
<Button
title="Bestätigen"
type="submit"
variant="contained"
color="primary"
disabled={isPending}
fullWidth
loading={isPending}
/>
</form>
)
}

Layout

app/(auth)/login/page.tsx
import { LoginForm } from "@/components/auth/LoginForm"

export default function LoginPage() {
return (
<main
style={{
display: "grid",
placeItems: "center",
minHeight: "100vh",
padding: "1rem",
}}
>
<div style={{ width: "100%", maxWidth: "400px" }}>
<h1>Anmelden</h1>
<LoginForm />
</div>
</main>
)
}

Barrierefreiheit

  • InputOtp steuert den Fokus automatisch, wenn eine Ziffer eingegeben wird.
  • Fehler sind über aria-describedby mit Eingaben verknüpft.
  • Loading innerhalb eines Button sollte mit aria-label="Wird angemeldet…" kombiniert werden.

Verwandte Komponenten

  • Form — Server-Action-Integration, Constraint-Validierung
  • Input — alle Input-Typen, Fehler- und Hinweis-Slots
  • InputOtp — stellenweise OTP-Eingabe mit Auto-Advance
  • Button — Lade- und deaktivierte Zustände
  • Loading — inline Spinner-Varianten

Weiterführende Informationen


👉 Login & OTP Formular in Storybook öffnen