🚀 Oferta especial: 60% OFF no CrazyStack - Últimas vagas!Garantir vaga →
Voltar ao Curso
MÓDULO 2
AULA 4

Sistema de Autenticação

Implementação completa de autenticação com magic links, middleware de proteção de rotas e integração com a API do Restaurantix.

50 min
Segurança
Magic Links
🔐 Autenticação Moderna

O Restaurantix usa magic links para autenticação - uma abordagem moderna, segura e sem senhas que melhora a experiência do usuário.

Como funciona

  • 1. Email: Usuário informa o email
  • 2. Link: Sistema envia magic link
  • 3. Clique: Usuário clica no link
  • 4. Token: Sistema valida o token
  • 5. Login: Usuário é autenticado

Vantagens

  • Segurança: Sem senhas para hackear
  • UX: Processo simples e rápido
  • Mobile: Funciona bem em dispositivos
  • Recuperação: Não precisa resetar senha
  • Phishing: Mais difícil de falsificar
Passo 1: Cliente da API

src/lib/api.ts

import { env } from './env'

class ApiClient {
  private baseURL: string
  private token: string | null = null

  constructor() {
    this.baseURL = env.NEXT_PUBLIC_API_URL
    
    // Recuperar token do localStorage no cliente
    if (typeof window !== 'undefined') {
      this.token = localStorage.getItem('auth-token')
    }
  }

  setToken(token: string) {
    this.token = token
    if (typeof window !== 'undefined') {
      localStorage.setItem('auth-token', token)
    }
  }

  clearToken() {
    this.token = null
    if (typeof window !== 'undefined') {
      localStorage.removeItem('auth-token')
    }
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.baseURL}${endpoint}`
    
    const config: RequestInit = {
      headers: {
        'Content-Type': 'application/json',
        ...(this.token && { Authorization: `Bearer ${this.token}` }),
        ...options.headers,
      },
      ...options,
    }

    const response = await fetch(url, config)

    if (!response.ok) {
      const error = await response.json().catch(() => ({}))
      throw new Error(error.message || 'Erro na requisição')
    }

    return response.json()
  }

  // Métodos de autenticação
  async sendAuthLink(email: string) {
    return this.request('/auth-links/authenticate', {
      method: 'POST',
      body: JSON.stringify({ email }),
    })
  }

  async authenticateFromLink(code: string) {
    return this.request<{ token: string }>('/auth-links/authenticate', {
      method: 'PATCH',
      body: JSON.stringify({ code }),
    })
  }

  async getProfile() {
    return this.request('/me')
  }

  async signOut() {
    return this.request('/sign-out', { method: 'POST' })
  }

  // Métodos do dashboard
  async getOrders(params?: Record<string, string>) {
    const searchParams = params ? new URLSearchParams(params) : ''
    return this.request(`/orders?${searchParams}`)
  }

  async getMetrics() {
    return this.request('/metrics')
  }
}

export const api = new ApiClient()
Passo 2: Tipos TypeScript

src/types/auth.ts

export interface User {
  id: string
  name: string
  email: string
  phone: string | null
  role: 'manager' | 'customer'
  createdAt: string
  updatedAt: string
}

export interface Restaurant {
  id: string
  name: string
  description: string | null
  managerId: string
  createdAt: string
  updatedAt: string
}

export interface AuthState {
  user: User | null
  restaurant: Restaurant | null
  isLoading: boolean
  isAuthenticated: boolean
}

export interface LoginFormData {
  email: string
}

export interface AuthResponse {
  token: string
  user: User
  restaurant?: Restaurant
}

export interface AuthError {
  message: string
  code?: string
}
Passo 3: Store de Autenticação (Zustand)

Instalar Zustand

npm install zustand
npm install @types/js-cookie js-cookie

src/store/auth-store.ts

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { api } from '@/lib/api'
import type { AuthState, User, Restaurant } from '@/types/auth'

interface AuthStore extends AuthState {
  // Actions
  login: (email: string) => Promise<void>
  authenticateFromLink: (code: string) => Promise<void>
  logout: () => Promise<void>
  loadUser: () => Promise<void>
  setLoading: (loading: boolean) => void
}

export const useAuthStore = create<AuthStore>()(
  persist(
    (set, get) => ({
      // State
      user: null,
      restaurant: null,
      isLoading: false,
      isAuthenticated: false,

      // Actions
      setLoading: (loading: boolean) => {
        set({ isLoading: loading })
      },

      login: async (email: string) => {
        try {
          set({ isLoading: true })
          await api.sendAuthLink(email)
          // Não mudamos o estado aqui, apenas enviamos o link
        } catch (error) {
          console.error('Erro ao enviar magic link:', error)
          throw error
        } finally {
          set({ isLoading: false })
        }
      },

      // ✅ CORRIGIDO: Não usa mais tokens, apenas cookies
      authenticateFromLink: async (code: string, redirect: string) => {
        try {
          set({ isLoading: true })
          
          // ✅ CORREÇÃO: API usa cookies, não retorna token
          await api.authenticateFromLink(code, redirect)
          
          // ✅ CORREÇÃO: Buscar dados separadamente
          const [user, restaurant] = await Promise.all([
            api.getProfile(),
            api.getManagedRestaurant().catch(() => null) // Pode não ter restaurante
          ])
          
          set({
            user,
            restaurant,
            isAuthenticated: true,
            isLoading: false,
          })
        } catch (error) {
          console.error('Erro na autenticação:', error)
          set({ isLoading: false })
          throw error
        }
      },

      // ✅ CORRIGIDO: Não usa mais localStorage para tokens
      loadUser: async () => {
        try {
          set({ isLoading: true })
          
          // ✅ CORREÇÃO: Buscar dados separadamente
          const [user, restaurant] = await Promise.all([
            api.getProfile(),
            api.getManagedRestaurant().catch(() => null) // Pode não ter restaurante
          ])
          
          set({
            user,
            restaurant,
            isAuthenticated: true,
            isLoading: false,
          })
        } catch (error) {
          // Cookie inválido ou expirado
          set({
            user: null,
            restaurant: null,
            isAuthenticated: false,
            isLoading: false,
          })
        }
      },

      // ✅ CORRIGIDO: Não limpa mais tokens do localStorage
      logout: async () => {
        try {
          await api.signOut()
        } catch (error) {
          console.error('Erro ao fazer logout:', error)
        } finally {
          set({
            user: null,
            restaurant: null,
            isAuthenticated: false,
            isLoading: false,
          })
        }
      },
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({
        user: state.user,
        restaurant: state.restaurant,
        isAuthenticated: state.isAuthenticated,
      }),
    }
  )
)
Passo 4: Hook de Autenticação

src/hooks/use-auth.ts

import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuthStore } from '@/store/auth-store'

export function useAuth() {
  const router = useRouter()
  const {
    user,
    restaurant,
    isLoading,
    isAuthenticated,
    login,
    authenticateFromLink,
    logout,
    loadUser,
  } = useAuthStore()

  // ✅ CORRIGIDO: Carregar usuário sem verificar localStorage
  useEffect(() => {
    // Tenta carregar o usuário se não estiver carregado e não estiver carregando
    if (!user && !isLoading) {
      loadUser()
    }
  }, [user, isLoading, loadUser])

  const signIn = async (email: string) => {
    await login(email)
  }

  const signOut = async () => {
    await logout()
    router.push('/login')
  }

  // ✅ CORRIGIDO: Passa o redirect também
  const authenticateWithCode = async (code: string, redirect: string = '/dashboard') => {
    await authenticateFromLink(code, redirect)
    router.push(redirect)
  }

  return {
    user,
    restaurant,
    isLoading,
    isAuthenticated,
    signIn,
    signOut,
    authenticateWithCode,
  }
}

// Hook para proteger rotas
export function useRequireAuth() {
  const { isAuthenticated, isLoading } = useAuth()
  const router = useRouter()

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.push('/login')
    }
  }, [isAuthenticated, isLoading, router])

  return { isAuthenticated, isLoading }
}
Passo 5: Formulário de Login

src/components/forms/login-form.tsx

'use client'

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/components/ui/use-toast'
import { useAuth } from '@/hooks/use-auth'
import { Mail, Loader2 } from 'lucide-react'

const loginSchema = z.object({
  email: z.string().email('Email inválido'),
})

type LoginFormData = z.infer<typeof loginSchema>

export function LoginForm() {
  const [isSubmitted, setIsSubmitted] = useState(false)
  const { signIn, isLoading } = useAuth()
  const { toast } = useToast()

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
  })

  const onSubmit = async (data: LoginFormData) => {
    try {
      await signIn(data.email)
      setIsSubmitted(true)
      toast({
        title: 'Magic link enviado!',
        description: 'Verifique seu email e clique no link para entrar.',
      })
    } catch (error) {
      toast({
        title: 'Erro ao enviar magic link',
        description: 'Tente novamente em alguns instantes.',
        variant: 'destructive',
      })
    }
  }

  if (isSubmitted) {
    return (
      <Card className="w-full max-w-md mx-auto">
        <CardHeader className="text-center">
          <div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
            <Mail className="w-8 h-8 text-green-600" />
          </div>
          <CardTitle>Verifique seu email</CardTitle>
        </CardHeader>
        <CardContent className="text-center">
          <p className="text-muted-foreground mb-4">
            Enviamos um magic link para seu email. Clique no link para entrar 
            no dashboard do Restaurantix.
          </p>
          <Button
            variant="outline"
            onClick={() => setIsSubmitted(false)}
            className="w-full"
          >
            Enviar novamente
          </Button>
        </CardContent>
      </Card>
    )
  }

  return (
    <Card className="w-full max-w-md mx-auto">
      <CardHeader className="text-center">
        <CardTitle>Entrar no Restaurantix</CardTitle>
        <p className="text-muted-foreground">
          Digite seu email para receber um magic link
        </p>
      </CardHeader>
      <CardContent>
        <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="email">Email</Label>
            <Input
              id="email"
              type="email"
              placeholder="seu@email.com"
              {...register('email')}
              disabled={isLoading}
            />
            {errors.email && (
              <p className="text-sm text-destructive">
                {errors.email.message}
              </p>
            )}
          </div>

          <Button type="submit" className="w-full" disabled={isLoading}>
            {isLoading ? (
              <>
                <Loader2 className="w-4 h-4 mr-2 animate-spin" />
                Enviando...
              </>
            ) : (
              <>
                <Mail className="w-4 h-4 mr-2" />
                Enviar Magic Link
              </>
            )}
          </Button>
        </form>
      </CardContent>
    </Card>
  )
}
Passo 6: Páginas de Autenticação

src/app/(auth)/login/page.tsx

import { Metadata } from 'next'
import { LoginForm } from '@/components/forms/login-form'

export const metadata: Metadata = {
  title: 'Login | Restaurantix',
  description: 'Entre no dashboard do Restaurantix',
}

export default function LoginPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div className="text-center">
          <h1 className="text-3xl font-bold text-gray-900">
            Restaurantix
          </h1>
          <p className="mt-2 text-gray-600">
            Sistema de gestão para restaurantes
          </p>
        </div>
        <LoginForm />
      </div>
    </div>
  )
}

src/app/(auth)/auth-callback/page.tsx

'use client'

import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useAuth } from '@/hooks/use-auth'
import { Card, CardContent } from '@/components/ui/card'
import { Loader2 } from 'lucide-react'

export default function AuthCallbackPage() {
  const searchParams = useSearchParams()
  const { authenticateWithCode } = useAuth()

  useEffect(() => {
    const code = searchParams.get('code')
    
    if (code) {
      authenticateWithCode(code).catch((error) => {
        console.error('Erro na autenticação:', error)
        // Redirecionar para login com erro
        window.location.href = '/login?error=invalid_code'
      })
    } else {
      // Sem código, redirecionar para login
      window.location.href = '/login'
    }
  }, [searchParams, authenticateWithCode])

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <Card className="w-full max-w-md">
        <CardContent className="pt-6">
          <div className="text-center">
            <Loader2 className="w-8 h-8 animate-spin mx-auto mb-4" />
            <h2 className="text-lg font-semibold mb-2">
              Autenticando...
            </h2>
            <p className="text-muted-foreground">
              Aguarde enquanto validamos seu acesso.
            </p>
          </div>
        </CardContent>
      </Card>
    </div>
  )
}

src/app/(auth)/layout.tsx

export default function AuthLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="min-h-screen bg-gray-50">
      {children}
    </div>
  )
}
🛡️ Middleware de Proteção

src/middleware.ts

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value
  const { pathname } = request.nextUrl

  // Rotas públicas que não precisam de autenticação
  const publicRoutes = ['/login', '/auth-callback', '/']
  const isPublicRoute = publicRoutes.some(route => 
    pathname.startsWith(route)
  )

  // Se não tem token e está tentando acessar rota protegida
  if (!token && !isPublicRoute) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // Se tem token e está tentando acessar login
  if (token && pathname === '/login') {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}
🎯 Exercício Prático

Implemente o sistema de autenticação completo:

1. Configurar dependências

npm install zustand react-hook-form @hookform/resolvers/zod zod
npm install js-cookie @types/js-cookie

2. Testar o fluxo completo

Teste todo o processo de autenticação:

  • • Enviar magic link pelo formulário
  • • Verificar email no terminal da API
  • • Clicar no link e ser redirecionado
  • • Verificar se o usuário está logado

3. Implementar logout

Adicione funcionalidade de logout:

  • • Botão de logout no header
  • • Limpar token e estado
  • • Redirecionar para login
  • • Mostrar confirmação

💡 Dicas de segurança:

  • • Sempre valide tokens no servidor
  • • Use HTTPS em produção
  • • Implemente rate limiting para login
  • • Monitore tentativas de acesso suspeitas