Explorer
KNOW-PAT-116

Web UI/UX - Auth Modal OAuth + Email : divider or, error/success states, mode toggle

Domaine
web-uiux
Type
pattern
Priorité
P1

Parent : [[INDEX-WEB-UIUX]]

Web UI/UX - Auth Modal OAuth + Email : divider "or", error/success states, mode toggle

Problème

Un formulaire d'authentification mal conçu pousse les utilisateurs à abandonner. Trop de champs, pas de feedback visuel, ou OAuth mal intégré = taux de conversion faible.

Solution

Modal d'authentification avec 3 sections : OAuth rapide (GitHub/Google), divider visuel "ou", formulaire email/password avec validation, et toggle login/signup.

type Mode = 'login' | 'signup'

export function AuthModal({ onClose }: { onClose: () => void }) {
  const [mode, setMode] = useState<Mode>('login')
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [success, setSuccess] = useState<string | null>(null)
  const [loading, setLoading] = useState(false)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError(null)
    setSuccess(null)
    setLoading(true)

    if (mode === 'login') {
      const err = await signInWithEmail(email, password)
      if (err) setError(err)
      else onClose()
    } else {
      const err = await signUpWithEmail(email, password)
      if (err) setError(err)
      else setSuccess('Email de confirmation envoyé ! Vérifiez votre boîte mail.')
    }
    setLoading(false)
  }

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center"
      style={{ background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }}
      onClick={onClose}
    >
      <div className="w-full max-w-sm mx-4 rounded-[6px] overflow-hidden"
        style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
        onClick={(e) => e.stopPropagation()}
      >
        {/* Header avec logo */}
        <div className="p-5 pb-0 flex items-center justify-between">
          <div className="flex items-center gap-2 mb-1">
            <div className="w-6 h-6 rounded-[3px] flex items-center justify-center text-white font-black text-[10px]"
              style={{ background: 'var(--accent)' }}>F</div>
            <span className="font-bold text-sm">BRAND<span style={{ color: 'var(--accent)' }}>NAME</span></span>
          </div>
          <button onClick={onClose}>✕</button>
        </div>

        <div className="p-5 flex flex-col gap-4">
          {/* ── OAuth Buttons ── */}
          <div className="flex flex-col gap-2">
            <button onClick={() => signInWithProvider('github')}
              className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-[4px] text-sm font-semibold"
              style={{ background: '#24292e', color: '#fff', border: '1px solid #30363d' }}>
              <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0..."/></svg>
              Continuer avec GitHub
            </button>
            <button onClick={() => signInWithProvider('google')}
              className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-[4px] text-sm font-semibold"
              style={{ background: '#fff', color: '#333', border: '1px solid #ddd' }}>
              <svg width="18" height="18" viewBox="0 0 24 24">...</svg>
              Continuer avec Google
            </button>
          </div>

          {/* ── Divider "ou" ── */}
          <div className="flex items-center gap-3">
            <div className="flex-1 h-px" style={{ background: 'var(--border)' }} />
            <span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: 'var(--text-3)' }}>ou</span>
            <div className="flex-1 h-px" style={{ background: 'var(--border)' }} />
          </div>

          {/* ── Email Form ── */}
          <form onSubmit={handleSubmit} className="flex flex-col gap-3">
            <input type="email" placeholder="email@exemple.com" required
              className="w-full px-3 py-2.5 rounded-[4px] text-sm outline-none"
              style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }}
              onFocus={(e) => (e.currentTarget.style.borderColor = 'var(--accent)')}
              onBlur={(e) => (e.currentTarget.style.borderColor = 'var(--border)')}
            />
            <input type="password" placeholder="Mot de passe (min. 6 caractères)" required minLength={6}
              className="w-full px-3 py-2.5 rounded-[4px] text-sm outline-none"
              style={{ background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--text)' }}
              onFocus={(e) => (e.currentTarget.style.borderColor = 'var(--accent)')}
              onBlur={(e) => (e.currentTarget.style.borderColor = 'var(--border)')}
            />

            {/* Error state */}
            {error && (
              <div className="text-xs px-3 py-2 rounded-[4px]"
                style={{ background: 'rgba(229,25,26,0.08)', border: '1px solid rgba(229,25,26,0.2)', color: '#fca5a5' }}>
                {error}
              </div>
            )}
            {/* Success state */}
            {success && (
              <div className="text-xs px-3 py-2 rounded-[4px]"
                style={{ background: 'rgba(0,255,136,0.08)', border: '1px solid rgba(0,255,136,0.2)', color: '#4ade80' }}>
                {success}
              </div>
            )}

            <button type="submit" disabled={loading} className="btn-primary w-full">
              {loading ? '...' : mode === 'login' ? 'Se connecter' : 'Créer le compte'}
            </button>
          </form>

          {/* Toggle login/signup */}
          <p className="text-center text-xs" style={{ color: 'var(--text-3)' }}>
            {mode === 'login' ? (
              <>Pas encore de compte ? <button onClick={() => { setMode('signup'); setError(null) }} className="font-bold" style={{ color: 'var(--accent)' }}>Inscription</button></>
            ) : (
              <>Déjà un compte ? <button onClick={() => { setMode('login'); setError(null) }} className="font-bold" style={{ color: 'var(--accent)' }}>Connexion</button></>
            )}
          </p>
        </div>
      </div>
    </div>
  )
}

Points clés

  1. OAuth en premier : l'utilisateur préfère cliquer 1 fois plutôt que taper un formulaire
  2. Divider "ou" : texte 10px uppercase tracking-wider, couleur muted → élégant
  3. Focus border accent : feedback visuel immédiat sur les inputs
  4. Error/success states : fond coloré faiblement opaque + texte contrasté
  5. Toggle mode : reset des erreurs au changement de mode
  6. Loading state : bouton disabled + spinner/texte "..."
  7. Pas de labels visibles : placeholders suffisants pour 2 champs simples

Pourquoi

  • OAuth first = taux de conversion +30-50%
  • Error state visuel évite la frustration (l'utilisateur sait immédiatement ce qui ne va pas)
  • Mode toggle évite 2 modals séparées

Quand l'utiliser

  • Connexion, inscription, onboarding
  • Toute authentification utilisateur

Quand NE PAS l'utiliser

  • SSO enterprise (pas besoin de formulaire email)
  • Magic link only (pas de password)

Références

  • ProjectAlpha/AuthModal.tsx L1-165