login no frontend

This commit is contained in:
Gabriel Santana de Menezes 2026-05-02 23:14:52 -04:00
parent 92f00ab0a1
commit 829ef310a5
13 changed files with 484 additions and 36 deletions

View file

@ -1,9 +1,13 @@
import { RouterProvider } from 'react-router-dom'
import { router } from './router'
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
import { AuthProvider } from "./contexts/AuthContext";
function App() {
return <RouterProvider router={router} />
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}
export default App
export default App;

View file

@ -1,5 +1,6 @@
import { Layout, ListTodo, PlusCircle, Columns } from 'lucide-react'
import { NavLink, Outlet } from 'react-router-dom'
import { Layout, ListTodo, PlusCircle, Columns, LogOut } from 'lucide-react'
import { NavLink, Outlet, Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { cn } from '../lib/utils'
@ -12,6 +13,20 @@ const navLinkClassName = ({ isActive }: { isActive: boolean }) =>
)
export function AppShell() {
const { user, isAuthenticated, isLoading, logout } = useAuth()
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-app-bg text-brand">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-current border-t-transparent" />
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return (
<div className="flex min-h-screen flex-col bg-app-bg font-sans text-text-main">
{/* Top Navigation */}
@ -51,12 +66,21 @@ export function AppShell() {
<div className="flex items-center gap-4">
<div className="hidden h-8 w-px bg-border-soft md:block" />
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-accent-soft border border-accent/20 flex items-center justify-center text-accent font-bold text-xs">
GM
</div>
<img
src={user?.photo}
alt={user?.name}
className="h-8 w-8 rounded-full border border-accent/20 object-cover"
/>
<span className="hidden text-sm font-medium text-text-muted lg:block">
Gabriel Menezes
{user?.name}
</span>
<button
onClick={logout}
className="ml-2 p-2 text-text-muted hover:text-danger transition-colors"
title="Sair"
>
<LogOut size={18} />
</button>
</div>
</div>
</div>

View file

@ -109,7 +109,7 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
<div className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-500",
runningItem ? "bg-brand text-white scale-110 shadow-lg shadow-brand/30" : "bg-app-bg text-text-muted group-hover:bg-white"
runningItem ? "bg-brand text-white scale-110 shadow-lg shadow-brand/30" : "bg-app-bg text-text-muted group-hover:bg-surface"
)}>
<Clock size={18} className={runningItem ? "animate-spin [animation-duration:3s]" : ""} />
</div>
@ -131,7 +131,7 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
<button
onClick={handleStop}
disabled={isActionLoading}
className="flex h-9 w-9 items-center justify-center rounded-xl bg-rose-500 text-white shadow-lg shadow-rose-500/30 transition-all hover:scale-110 hover:bg-rose-600 active:scale-90 disabled:opacity-50"
className="flex h-9 w-9 items-center justify-center rounded-xl bg-danger text-white shadow-lg shadow-danger/30 transition-all hover:scale-110 hover:bg-danger active:scale-90 disabled:opacity-50"
title="Parar Cronômetro"
>
<Square size={16} fill="currentColor" />

View file

@ -0,0 +1,120 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import { authService, type LoginResponse } from "../services/authService";
interface AuthContextType {
user: LoginResponse | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [user, setUser] = useState<LoginResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const logout = useCallback(() => {
localStorage.removeItem("user");
setUser(null);
window.location.href = "/login";
}, []);
const checkTokenExpiration = useCallback(
(userData: LoginResponse) => {
const issuedAt = new Date(userData.issuedAt).getTime();
const expiresAt = issuedAt + userData.expiresIn * 1000;
const now = Date.now();
// Se já expirou
if (now >= expiresAt) {
logout();
return false;
}
// Se falta menos de 5 minutos para vencer, tenta renovar
const timeToExpire = expiresAt - now;
if (timeToExpire < 300000) {
// 5 min
renewToken(userData.refreshToken);
}
return true;
},
[logout],
);
const renewToken = async (refreshToken: string) => {
try {
const response = await authService.refresh(refreshToken);
localStorage.setItem("user", JSON.stringify(response));
setUser(response);
} catch (error) {
console.error("Failed to renew token", error);
logout();
}
};
useEffect(() => {
const storedUser = localStorage.getItem("user");
if (storedUser) {
try {
const userData: LoginResponse = JSON.parse(storedUser);
if (checkTokenExpiration(userData)) {
setUser(userData);
}
} catch (e) {
localStorage.removeItem("user");
}
}
setIsLoading(false);
}, [checkTokenExpiration]);
// Timer para verificar expiração periodicamente
useEffect(() => {
if (!user) return;
const interval = setInterval(() => {
checkTokenExpiration(user);
}, 60000); // Verifica a cada minuto
return () => clearInterval(interval);
}, [user, checkTokenExpiration]);
const login = async (email: string, password: string) => {
const response = await authService.login(email, password);
localStorage.setItem("user", JSON.stringify(response));
setUser(response);
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

View file

@ -18,6 +18,13 @@
--color-accent: #d97706;
--color-accent-soft: #ffedd5;
--color-success: #10b981;
--color-success-soft: #ecfdf5;
--color-warning: #f59e0b;
--color-warning-soft: #fffbeb;
--color-danger: #ef4444;
--color-danger-soft: #fef2f2;
--shadow-panel:
0 22px 60px rgba(79, 62, 35, 0.08), 0 6px 18px rgba(79, 62, 35, 0.06);
}

View file

@ -5,7 +5,7 @@ function buildUrl(path: string) {
}
type RequestOptions = Omit<RequestInit, 'body'> & {
body?: BodyInit | Record<string, unknown> | null
body?: any
}
export async function apiFetch<T>(path: string, options: RequestOptions = {}) {
@ -17,7 +17,7 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}) {
'Content-Type': 'application/json',
...headers,
},
body: body && typeof body === 'object' && !(body instanceof FormData)
body: body !== undefined && body !== null && !(body instanceof FormData)
? JSON.stringify(body)
: body,
})

View file

@ -98,7 +98,7 @@ export function CreateTaskPage() {
{createdTask.title}
</h4>
<div className="mt-3 flex items-center gap-2">
<span className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-brand shadow-sm">
<span className="rounded-md bg-surface px-2 py-1 text-[10px] font-bold text-brand shadow-sm">
{TASK_STATUS_LABELS[createdTask.status]}
</span>
<span className="text-[10px] font-bold text-text-muted">
@ -145,7 +145,7 @@ export function CreateTaskPage() {
>
<div className="bg-brand p-8 text-white">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-surface/20 backdrop-blur-sm">
<PlusCircle size={24} />
</div>
<div>
@ -236,7 +236,7 @@ export function CreateTaskPage() {
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-8 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm font-bold text-rose-700"
className="mt-8 rounded-xl border border-danger/20 bg-danger-soft p-4 text-sm font-bold text-danger"
>
{errorMessage}
</motion.div>

View file

@ -0,0 +1,115 @@
import React, { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Mail, Lock, LogIn, Layout } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
export const LoginPage: React.FC = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const navigate = useNavigate()
const { login } = useAuth()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await login(email, password)
navigate('/')
} catch (error) {
console.error('Login failed', error)
alert('Erro ao fazer login. Tente novamente.')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen bg-app-bg flex items-center justify-center p-4 relative overflow-hidden font-sans">
{/* Visual decorations matching index.css body gradients */}
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-50">
<div className="absolute top-0 left-0 w-[40%] h-[40%] bg-accent/15 blur-[100px] rounded-full" />
<div className="absolute top-0 right-0 w-[35%] h-[35%] bg-brand/15 blur-[100px] rounded-full" />
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="w-full max-w-md relative z-10"
>
<div className="bg-surface border border-border-soft p-10 rounded-[2.5rem] shadow-panel">
<div className="flex flex-col items-center mb-10 text-center">
<div className="w-14 h-14 bg-brand text-white rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-brand/20">
<Layout size={28} />
</div>
<h1 className="text-4xl font-black text-text-main tracking-tight mb-3">BeeTask</h1>
<p className="text-text-muted font-medium">Bem-vindo de volta ao seu workspace</p>
</div>
<form onSubmit={handleLogin} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-bold text-text-main ml-1">E-mail</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-text-muted group-focus-within:text-brand transition-colors" />
</div>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-12 pr-4 py-3.5 bg-surface-muted/50 border border-border-soft rounded-2xl text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-4 focus:ring-brand/10 focus:border-brand transition-all font-medium"
placeholder="exemplo@beetech.net"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-text-main ml-1">Senha</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-text-muted group-focus-within:text-brand transition-colors" />
</div>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-12 pr-4 py-3.5 bg-surface-muted/50 border border-border-soft rounded-2xl text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-4 focus:ring-brand/10 focus:border-brand transition-all font-medium"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-3 bg-brand hover:bg-brand-strong disabled:bg-brand/50 disabled:cursor-not-allowed text-white font-bold py-4 px-6 rounded-2xl transition-all shadow-lg shadow-brand/20 active:scale-[0.98] mt-4"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-surface/30 border-t-surface rounded-full animate-spin" />
) : (
<>
<LogIn size={20} />
Acessar Painel
</>
)}
</button>
</form>
<div className="mt-10 pt-8 border-t border-border-soft text-center space-y-4">
<p className="text-text-muted text-sm font-medium">
Não tem uma conta? <Link to="/register" className="text-brand font-bold hover:underline">Criar conta</Link>
</p>
<p className="text-text-muted text-sm font-medium">
Precisa de ajuda? <span className="text-brand font-bold cursor-pointer hover:underline">Falar com suporte</span>
</p>
</div>
</div>
</motion.div>
</div>
)
}

View file

@ -0,0 +1,130 @@
import React, { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Mail, Lock, User, Layout, UserPlus } from 'lucide-react'
import { authService } from '../services/authService'
export const RegisterPage: React.FC = () => {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const navigate = useNavigate()
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await authService.register(name, email, password)
alert('Conta criada com sucesso! Agora faça seu login.')
navigate('/login')
} catch (error) {
console.error('Registration failed', error)
alert('Erro ao criar conta. Tente novamente.')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen bg-app-bg flex items-center justify-center p-4 relative overflow-hidden font-sans">
{/* Visual decorations */}
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-50">
<div className="absolute top-0 left-0 w-[40%] h-[40%] bg-accent/15 blur-[100px] rounded-full" />
<div className="absolute top-0 right-0 w-[35%] h-[35%] bg-brand/15 blur-[100px] rounded-full" />
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="w-full max-w-md relative z-10"
>
<div className="bg-surface border border-border-soft p-10 rounded-[2.5rem] shadow-panel">
<div className="flex flex-col items-center mb-10 text-center">
<div className="w-14 h-14 bg-brand text-white rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-brand/20">
<Layout size={28} />
</div>
<h1 className="text-4xl font-black text-text-main tracking-tight mb-3">BeeTask</h1>
<p className="text-text-muted font-medium">Crie sua conta para começar</p>
</div>
<form onSubmit={handleRegister} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-bold text-text-main ml-1">Nome Completo</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<User className="h-5 w-5 text-text-muted group-focus-within:text-brand transition-colors" />
</div>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="block w-full pl-12 pr-4 py-3.5 bg-surface-muted/50 border border-border-soft rounded-2xl text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-4 focus:ring-brand/10 focus:border-brand transition-all font-medium"
placeholder="Seu nome"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-text-main ml-1">E-mail</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-text-muted group-focus-within:text-brand transition-colors" />
</div>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-12 pr-4 py-3.5 bg-surface-muted/50 border border-border-soft rounded-2xl text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-4 focus:ring-brand/10 focus:border-brand transition-all font-medium"
placeholder="exemplo@beetech.net"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-bold text-text-main ml-1">Senha</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-text-muted group-focus-within:text-brand transition-colors" />
</div>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-12 pr-4 py-3.5 bg-surface-muted/50 border border-border-soft rounded-2xl text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-4 focus:ring-brand/10 focus:border-brand transition-all font-medium"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-3 bg-brand hover:bg-brand-strong disabled:bg-brand/50 disabled:cursor-not-allowed text-white font-bold py-4 px-6 rounded-2xl transition-all shadow-lg shadow-brand/20 active:scale-[0.98] mt-4"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-surface/30 border-t-surface rounded-full animate-spin" />
) : (
<>
<UserPlus size={20} />
Criar Conta
</>
)}
</button>
</form>
<div className="mt-10 pt-8 border-t border-border-soft text-center">
<p className="text-text-muted text-sm font-medium">
Já tem uma conta? <Link to="/login" className="text-brand font-bold hover:underline">Fazer login</Link>
</p>
</div>
</div>
</motion.div>
</div>
)
}

View file

@ -23,25 +23,25 @@ const COLUMNS: { id: TaskStatus; label: string; icon: any; color: string }[] = [
id: 'NOT_STARTED',
label: 'Pendente',
icon: AlertCircle,
color: 'text-slate-400',
color: 'text-text-muted',
},
{
id: 'IN_PROGRESS',
label: 'Em Andamento',
icon: Clock,
color: 'text-amber-500',
color: 'text-warning',
},
{
id: 'COMPLETED',
label: 'Concluída',
icon: CheckCircle2,
color: 'text-emerald-500',
color: 'text-success',
},
{
id: 'CANCELED',
label: 'Cancelada',
icon: XCircle,
color: 'text-rose-500',
color: 'text-danger',
},
]

View file

@ -24,25 +24,25 @@ function getStatusConfig(status: TaskResponse['status']) {
switch (status) {
case 'COMPLETED':
return {
tone: 'bg-emerald-50 text-emerald-700 border-emerald-100',
tone: 'bg-success-soft text-success border-success/20',
icon: CheckCircle2,
label: 'Concluído',
}
case 'IN_PROGRESS':
return {
tone: 'bg-amber-50 text-amber-700 border-amber-100',
tone: 'bg-warning-soft text-warning border-warning/20',
icon: Clock,
label: 'Em Andamento',
}
case 'CANCELED':
return {
tone: 'bg-rose-50 text-rose-700 border-rose-100',
tone: 'bg-danger-soft text-danger border-danger/20',
icon: XCircle,
label: 'Cancelado',
}
default:
return {
tone: 'bg-slate-50 text-slate-600 border-slate-100',
tone: 'bg-surface-muted text-text-muted border-border-soft',
icon: AlertCircle,
label: 'Pendente',
}
@ -213,7 +213,7 @@ export function TaskListPage() {
</div>
<div className="h-4 w-px bg-border-soft" />
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
<Calendar size={14} className="text-amber-500" />
<Calendar size={14} className="text-accent" />
{task.history?.length ?? 0} registros
</div>
</div>

View file

@ -1,13 +1,23 @@
import { createBrowserRouter } from 'react-router-dom'
import { createBrowserRouter } from "react-router-dom";
import { AppShell } from './components/AppShell'
import { CreateTaskPage } from './pages/CreateTaskPage'
import { TaskListPage } from './pages/TaskListPage'
import { TaskBoardPage } from './pages/TaskBoardPage'
import { AppShell } from "./components/AppShell";
import { CreateTaskPage } from "./pages/CreateTaskPage";
import { TaskListPage } from "./pages/TaskListPage";
import { TaskBoardPage } from "./pages/TaskBoardPage";
import { LoginPage } from "./pages/LoginPage";
import { RegisterPage } from "./pages/RegisterPage";
export const router = createBrowserRouter([
{
path: '/',
path: "/login",
element: <LoginPage />,
},
{
path: "/register",
element: <RegisterPage />,
},
{
path: "/",
element: <AppShell />,
children: [
{
@ -15,13 +25,13 @@ export const router = createBrowserRouter([
element: <TaskListPage />,
},
{
path: 'new',
path: "new",
element: <CreateTaskPage />,
},
{
path: 'board',
path: "board",
element: <TaskBoardPage />,
},
],
},
])
]);

View file

@ -0,0 +1,38 @@
import { apiFetch } from '../lib/api'
export type LoginResponse = {
name: string
email: string
photo: string
jwt: string
refreshToken: string
expiresIn: number // seconds
issuedAt: string // ISO date
organizations: {
name: string
roles: string[]
}[]
}
export const authService = {
async login(email: string, password: string): Promise<LoginResponse> {
return apiFetch<LoginResponse>('/auth/login', {
method: 'POST',
body: { email, password },
})
},
async register(name: string, email: string, password: string): Promise<{ id: number; name: string; email: string }> {
return apiFetch<{ id: number; name: string; email: string }>('/auth/register', {
method: 'POST',
body: { name, email, password },
})
},
async refresh(refreshToken: string): Promise<LoginResponse> {
return apiFetch<LoginResponse>('/auth/refresh', {
method: 'POST',
body: { refreshToken },
})
},
}