login no frontend
This commit is contained in:
parent
92f00ab0a1
commit
829ef310a5
13 changed files with 484 additions and 36 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
120
frontend/src/contexts/AuthContext.tsx
Normal file
120
frontend/src/contexts/AuthContext.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
115
frontend/src/pages/LoginPage.tsx
Normal file
115
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
frontend/src/pages/RegisterPage.tsx
Normal file
130
frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
|
|
|||
38
frontend/src/services/authService.ts
Normal file
38
frontend/src/services/authService.ts
Normal 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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
Loading…
Reference in a new issue