Overview

This guide walks you through creating a complete sign-in flow from scratch using Wacht’s React SDK. You’ll learn how to build custom forms, handle different authentication strategies, manage loading states, and implement proper error handling.

Basic Sign-In Flow

1. Simple Email/Password Sign-In

Let’s start with a basic email and password sign-in form:
import { useSignin, useNavigation } from '@snipextt/wacht'
import { useState } from 'react'

function BasicSignInForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const { signIn, isLoading, error, clearError } = useSignin()
  const { navigate } = useNavigation()

  const handleSubmit = async (e) => {
    e.preventDefault()
    clearError()

    try {
      await signIn({
        strategy: 'plain_email',
        email,
        password
      })

      // Success - redirect to dashboard
      navigate('/dashboard')
    } catch (err) {
      // Error is automatically handled by the hook
      console.error('Sign-in failed:', err)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="signin-form">
      <h2>Sign In</h2>

      {error && (
        <div className="error-banner">
          <span>{error.message}</span>
          <button type="button" onClick={clearError}>×</button>
        </div>
      )}

      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="Enter your email"
          required
        />
      </div>

      <div className="form-group">
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="Enter your password"
          required
        />
      </div>

      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign In'}
      </button>
    </form>
  )
}

2. Enhanced Form with Validation

Add client-side validation and better UX:
import { useSignin, useNavigation } from '@snipextt/wacht'
import { useState, useEffect } from 'react'

function EnhancedSignInForm() {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  })
  const [formErrors, setFormErrors] = useState({})
  const [showPassword, setShowPassword] = useState(false)
  const { signIn, isLoading, error, clearError } = useSignin()
  const { navigate } = useNavigation()

  // Clear server errors when user starts typing
  useEffect(() => {
    if (error && (formData.email || formData.password)) {
      clearError()
    }
  }, [formData.email, formData.password, error, clearError])

  const validateForm = () => {
    const errors = {}

    if (!formData.email) {
      errors.email = 'Email is required'
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      errors.email = 'Please enter a valid email'
    }

    if (!formData.password) {
      errors.password = 'Password is required'
    } else if (formData.password.length < 6) {
      errors.password = 'Password must be at least 6 characters'
    }

    setFormErrors(errors)
    return Object.keys(errors).length === 0
  }

  const handleInputChange = (field) => (e) => {
    setFormData(prev => ({
      ...prev,
      [field]: e.target.value
    }))

    // Clear field error when user starts typing
    if (formErrors[field]) {
      setFormErrors(prev => ({
        ...prev,
        [field]: ''
      }))
    }
  }

  const handleSubmit = async (e) => {
    e.preventDefault()

    if (!validateForm()) return

    try {
      await signIn({
        strategy: 'plain_email',
        email: formData.email,
        password: formData.password
      })

      navigate('/dashboard')
    } catch (err) {
      console.error('Sign-in failed:', err)
    }
  }

  return (
    <div className="signin-container">
      <form onSubmit={handleSubmit} className="signin-form">
        <div className="form-header">
          <h2>Welcome Back</h2>
          <p>Sign in to your account</p>
        </div>

        {error && (
          <div className="error-banner">
            <div className="error-content">
              <span className="error-icon">⚠️</span>
              <span>{error.message}</span>
            </div>
            <button type="button" onClick={clearError}>×</button>
          </div>
        )}

        <div className="form-group">
          <label htmlFor="email">Email Address</label>
          <input
            id="email"
            type="email"
            value={formData.email}
            onChange={handleInputChange('email')}
            placeholder="Enter your email"
            className={formErrors.email ? 'error' : ''}
            aria-invalid={!!formErrors.email}
            aria-describedby={formErrors.email ? 'email-error' : undefined}
          />
          {formErrors.email && (
            <span id="email-error" className="field-error">
              {formErrors.email}
            </span>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="password">Password</label>
          <div className="password-input">
            <input
              id="password"
              type={showPassword ? 'text' : 'password'}
              value={formData.password}
              onChange={handleInputChange('password')}
              placeholder="Enter your password"
              className={formErrors.password ? 'error' : ''}
              aria-invalid={!!formErrors.password}
              aria-describedby={formErrors.password ? 'password-error' : undefined}
            />
            <button
              type="button"
              className="password-toggle"
              onClick={() => setShowPassword(!showPassword)}
              aria-label={showPassword ? 'Hide password' : 'Show password'}
            >
              {showPassword ? '👁️' : '👁️‍🗨️'}
            </button>
          </div>
          {formErrors.password && (
            <span id="password-error" className="field-error">
              {formErrors.password}
            </span>
          )}
        </div>

        <div className="form-options">
          <label className="remember-me">
            <input type="checkbox" />
            <span>Remember me</span>
          </label>
          <a href="/auth/forgot-password" className="forgot-password">
            Forgot password?
          </a>
        </div>

        <button type="submit" disabled={isLoading} className="submit-button">
          {isLoading ? (
            <>
              <span className="spinner"></span>
              Signing in...
            </>
          ) : (
            'Sign In'
          )}
        </button>

        <div className="form-footer">
          <p>
            Don't have an account?{' '}
            <a href="/auth/signup">Sign up</a>
          </p>
        </div>
      </form>
    </div>
  )
}

Multi-Strategy Sign-In Flow

Support Multiple Authentication Methods

import { useSignin, useDeployment } from '@snipextt/wacht'
import { useState } from 'react'

function MultiStrategySignIn() {
  const [activeStrategy, setActiveStrategy] = useState('plain_email')
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [phoneNumber, setPhoneNumber] = useState('')
  const { signIn, signInWithOAuth, isLoading, error, clearError } = useSignin()
  const { authStrategies, socialConnections } = useDeployment()

  const handleEmailSignIn = async (e) => {
    e.preventDefault()
    await signIn({
      strategy: 'plain_email',
      email,
      password
    })
  }

  const handleMagicLink = async (e) => {
    e.preventDefault()
    await signIn({
      strategy: 'magic_link',
      email
    })
  }

  const handlePhoneSignIn = async (e) => {
    e.preventDefault()
    await signIn({
      strategy: 'phone_sms',
      phoneNumber
    })
  }

  const handleOAuthSignIn = (provider) => {
    signInWithOAuth(provider)
  }

  return (
    <div className="multi-strategy-signin">
      <h2>Sign In</h2>

      {error && (
        <div className="error-banner">
          {error.message}
          <button onClick={clearError}>×</button>
        </div>
      )}

      {/* Strategy Selector */}
      <div className="strategy-tabs">
        {authStrategies.includes('plain_email') && (
          <button
            type="button"
            className={activeStrategy === 'plain_email' ? 'active' : ''}
            onClick={() => setActiveStrategy('plain_email')}
          >
            Email & Password
          </button>
        )}
        {authStrategies.includes('magic_link') && (
          <button
            type="button"
            className={activeStrategy === 'magic_link' ? 'active' : ''}
            onClick={() => setActiveStrategy('magic_link')}
          >
            Magic Link
          </button>
        )}
        {authStrategies.includes('phone_sms') && (
          <button
            type="button"
            className={activeStrategy === 'phone_sms' ? 'active' : ''}
            onClick={() => setActiveStrategy('phone_sms')}
          >
            Phone Number
          </button>
        )}
      </div>

      {/* Email & Password Form */}
      {activeStrategy === 'plain_email' && (
        <form onSubmit={handleEmailSignIn} className="strategy-form">
          <div className="form-group">
            <label>Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="Enter your email"
              required
            />
          </div>
          <div className="form-group">
            <label>Password</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              placeholder="Enter your password"
              required
            />
          </div>
          <button type="submit" disabled={isLoading}>
            {isLoading ? 'Signing in...' : 'Sign In'}
          </button>
        </form>
      )}

      {/* Magic Link Form */}
      {activeStrategy === 'magic_link' && (
        <form onSubmit={handleMagicLink} className="strategy-form">
          <div className="form-group">
            <label>Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="Enter your email"
              required
            />
          </div>
          <button type="submit" disabled={isLoading}>
            {isLoading ? 'Sending Magic Link...' : 'Send Magic Link'}
          </button>
          <p className="strategy-help">
            We'll send you a secure link to sign in instantly.
          </p>
        </form>
      )}

      {/* Phone SMS Form */}
      {activeStrategy === 'phone_sms' && (
        <form onSubmit={handlePhoneSignIn} className="strategy-form">
          <div className="form-group">
            <label>Phone Number</label>
            <input
              type="tel"
              value={phoneNumber}
              onChange={(e) => setPhoneNumber(e.target.value)}
              placeholder="+1 (555) 123-4567"
              required
            />
          </div>
          <button type="submit" disabled={isLoading}>
            {isLoading ? 'Sending Code...' : 'Send Verification Code'}
          </button>
          <p className="strategy-help">
            We'll send you a verification code via SMS.
          </p>
        </form>
      )}

      {/* Social Sign-On */}
      {socialConnections.length > 0 && (
        <div className="social-signin">
          <div className="divider">
            <span>Or continue with</span>
          </div>
          <div className="social-buttons">
            {socialConnections.map(connection => (
              <button
                key={connection.provider}
                type="button"
                onClick={() => handleOAuthSignIn(connection.provider)}
                className={`social-button social-${connection.provider}`}
                disabled={isLoading}
              >
                <img
                  src={`/icons/${connection.provider}.svg`}
                  alt={connection.provider}
                />
                {connection.provider}
              </button>
            ))}
          </div>
        </div>
      )}
    </div>
  )
}

Advanced Sign-In Features

Remember Me Functionality

import { useSignin } from '@snipextt/wacht'
import { useState } from 'react'

function SignInWithRememberMe() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [rememberMe, setRememberMe] = useState(false)
  const { signIn, isLoading, error } = useSignin()

  const handleSubmit = async (e) => {
    e.preventDefault()

    await signIn({
      strategy: 'plain_email',
      email,
      password,
      // Remember me affects session duration
      sessionDuration: rememberMe ? '30d' : '1d'
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />

      <label className="remember-me-checkbox">
        <input
          type="checkbox"
          checked={rememberMe}
          onChange={(e) => setRememberMe(e.target.checked)}
        />
        Keep me signed in for 30 days
      </label>

      <button type="submit" disabled={isLoading}>
        Sign In
      </button>
    </form>
  )
}

Sign-In with Redirect Handling

import { useSignin, useNavigation } from '@snipextt/wacht'
import { useState, useEffect } from 'react'

function SignInWithRedirect() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const { signIn, isLoading, error } = useSignin()
  const { navigate } = useNavigation()

  // Get intended destination from URL or storage
  const [redirectTo, setRedirectTo] = useState('/')

  useEffect(() => {
    // Check for redirect parameter in URL
    const urlParams = new URLSearchParams(window.location.search)
    const redirect = urlParams.get('redirect')

    // Or check localStorage for intended route
    const savedRedirect = localStorage.getItem('intended_route')

    if (redirect) {
      setRedirectTo(decodeURIComponent(redirect))
    } else if (savedRedirect) {
      setRedirectTo(savedRedirect)
      localStorage.removeItem('intended_route')
    }
  }, [])

  const handleSubmit = async (e) => {
    e.preventDefault()

    try {
      await signIn({
        strategy: 'plain_email',
        email,
        password
      })

      // Redirect to intended destination
      navigate(redirectTo)
    } catch (err) {
      console.error('Sign-in failed:', err)
    }
  }

  return (
    <div className="signin-with-redirect">
      <h2>Sign In</h2>
      {redirectTo !== '/' && (
        <p className="redirect-notice">
          You'll be redirected to: <strong>{redirectTo}</strong>
        </p>
      )}

      <form onSubmit={handleSubmit}>
        {/* Form fields */}
        <button type="submit" disabled={isLoading}>
          Sign In
        </button>
      </form>
    </div>
  )
}

Progressive Enhancement

import { useSignin, useDeployment } from '@snipextt/wacht'
import { useState, useEffect } from 'react'

function ProgressiveSignIn() {
  const [currentStep, setCurrentStep] = useState('identifier')
  const [identifier, setIdentifier] = useState('')
  const [password, setPassword] = useState('')
  const [identifierType, setIdentifierType] = useState('')
  const { signIn, isLoading, error } = useSignin()
  const { deployment } = useDeployment()

  // Detect if identifier is email or username
  useEffect(() => {
    if (identifier.includes('@')) {
      setIdentifierType('email')
    } else if (identifier.length > 0) {
      setIdentifierType('username')
    } else {
      setIdentifierType('')
    }
  }, [identifier])

  const handleIdentifierSubmit = async (e) => {
    e.preventDefault()

    // Check if magic link is available for this identifier
    if (identifierType === 'email' && deployment.authStrategies.includes('magic_link')) {
      // Show both password and magic link options
      setCurrentStep('auth_method')
    } else {
      // Go straight to password
      setCurrentStep('password')
    }
  }

  const handlePasswordSignIn = async () => {
    const strategy = identifierType === 'email' ? 'plain_email' : 'username'

    await signIn({
      strategy,
      [identifierType]: identifier,
      password
    })
  }

  const handleMagicLink = async () => {
    await signIn({
      strategy: 'magic_link',
      email: identifier
    })
  }

  return (
    <div className="progressive-signin">
      {currentStep === 'identifier' && (
        <form onSubmit={handleIdentifierSubmit}>
          <h2>Welcome</h2>
          <div className="form-group">
            <label>Email or Username</label>
            <input
              type="text"
              value={identifier}
              onChange={(e) => setIdentifier(e.target.value)}
              placeholder="Enter your email or username"
              required
            />
          </div>
          <button type="submit" disabled={!identifier}>
            Continue
          </button>
        </form>
      )}

      {currentStep === 'auth_method' && (
        <div className="auth-method-selection">
          <h2>How would you like to sign in?</h2>
          <p>Signing in as: <strong>{identifier}</strong></p>

          <div className="method-options">
            <button
              type="button"
              onClick={() => setCurrentStep('password')}
              className="method-button"
            >
              <span className="icon">🔑</span>
              <div>
                <strong>Use Password</strong>
                <p>Sign in with your password</p>
              </div>
            </button>

            <button
              type="button"
              onClick={handleMagicLink}
              className="method-button"
              disabled={isLoading}
            >
              <span className="icon"></span>
              <div>
                <strong>Magic Link</strong>
                <p>Get a secure link via email</p>
              </div>
            </button>
          </div>
        </div>
      )}

      {currentStep === 'password' && (
        <form onSubmit={(e) => { e.preventDefault(); handlePasswordSignIn(); }}>
          <h2>Enter Password</h2>
          <p>Signing in as: <strong>{identifier}</strong></p>

          <div className="form-group">
            <label>Password</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              placeholder="Enter your password"
              autoFocus
              required
            />
          </div>

          <button type="submit" disabled={isLoading}>
            {isLoading ? 'Signing in...' : 'Sign In'}
          </button>

          <button
            type="button"
            onClick={() => setCurrentStep('identifier')}
            className="back-button"
          >
            ← Back
          </button>
        </form>
      )}
    </div>
  )
}

Error Handling Patterns

Comprehensive Error Display

import { useSignin } from '@snipextt/wacht'

function ErrorAwareSignIn() {
  const { signIn, error, clearError, isLoading } = useSignin()

  const getErrorMessage = (error) => {
    switch (error.code) {
      case 'INVALID_CREDENTIALS':
        return {
          title: 'Invalid Credentials',
          message: 'The email or password you entered is incorrect.',
          action: 'Try again or reset your password'
        }
      case 'ACCOUNT_LOCKED':
        return {
          title: 'Account Locked',
          message: 'Your account has been temporarily locked due to multiple failed attempts.',
          action: 'Try again in a few minutes or contact support'
        }
      case 'EMAIL_NOT_VERIFIED':
        return {
          title: 'Email Not Verified',
          message: 'Please verify your email address before signing in.',
          action: 'Check your email for a verification link'
        }
      case 'RATE_LIMIT_EXCEEDED':
        return {
          title: 'Too Many Attempts',
          message: 'You\'ve made too many sign-in attempts.',
          action: 'Please wait a moment before trying again'
        }
      default:
        return {
          title: 'Sign-In Error',
          message: error.message || 'Something went wrong.',
          action: 'Please try again'
        }
    }
  }

  if (error) {
    const errorInfo = getErrorMessage(error)

    return (
      <div className="error-state">
        <div className="error-icon">⚠️</div>
        <h3>{errorInfo.title}</h3>
        <p>{errorInfo.message}</p>
        <p className="error-action">{errorInfo.action}</p>

        <div className="error-actions">
          <button onClick={clearError}>
            Try Again
          </button>
          {error.code === 'INVALID_CREDENTIALS' && (
            <a href="/auth/forgot-password">
              Reset Password
            </a>
          )}
          {error.code === 'EMAIL_NOT_VERIFIED' && (
            <button onClick={() => sendVerificationEmail()}>
              Resend Verification
            </button>
          )}
        </div>
      </div>
    )
  }

  // Regular sign-in form when no error
  return <SignInForm />
}

Testing Your Sign-In Flow

Component Testing

import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { DeploymentProvider } from '@snipextt/wacht'
import SignInForm from './SignInForm'

const TestWrapper = ({ children }) => (
  <DeploymentProvider publicKey="pk_test_key">
    {children}
  </DeploymentProvider>
)

test('should sign in with valid credentials', async () => {
  render(<SignInForm />, { wrapper: TestWrapper })

  fireEvent.change(screen.getByLabelText(/email/i), {
    target: { value: 'test@example.com' }
  })
  fireEvent.change(screen.getByLabelText(/password/i), {
    target: { value: 'password123' }
  })

  fireEvent.click(screen.getByRole('button', { name: /sign in/i }))

  await waitFor(() => {
    expect(mockNavigate).toHaveBeenCalledWith('/dashboard')
  })
})

test('should display error for invalid credentials', async () => {
  render(<SignInForm />, { wrapper: TestWrapper })

  // Simulate sign-in with invalid credentials
  fireEvent.change(screen.getByLabelText(/email/i), {
    target: { value: 'wrong@example.com' }
  })
  fireEvent.change(screen.getByLabelText(/password/i), {
    target: { value: 'wrongpassword' }
  })

  fireEvent.click(screen.getByRole('button', { name: /sign in/i }))

  await waitFor(() => {
    expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument()
  })
})

Next Steps