Implementação completa de autenticação com magic links, middleware de proteção de rotas e integração com a API do Restaurantix.
O Restaurantix usa magic links para autenticação - uma abordagem moderna, segura e sem senhas que melhora a experiência do usuário.
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()
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 }
npm install zustand npm install @types/js-cookie js-cookie
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, }), } ) )
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 } }
'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> ) }
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> ) }
'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> ) }
export default function AuthLayout({ children, }: { children: React.ReactNode }) { return ( <div className="min-h-screen bg-gray-50"> {children} </div> ) }
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).*)', ], }
Implemente o sistema de autenticação completo:
npm install zustand react-hook-form @hookform/resolvers/zod zod npm install js-cookie @types/js-cookie
Teste todo o processo de autenticação:
Adicione funcionalidade de logout: