Modal do primeiro acesso

This commit is contained in:
Gabriel Santana de Menezes 2026-05-03 17:21:21 -04:00
parent 829ef310a5
commit e99e1cd586
11 changed files with 528 additions and 4 deletions

View file

@ -1,4 +1,4 @@
import { Layout, ListTodo, PlusCircle, Columns, LogOut } from 'lucide-react' import { Layout, ListTodo, PlusCircle, Columns, LogOut, Settings } from 'lucide-react'
import { NavLink, Outlet, Navigate } from 'react-router-dom' import { NavLink, Outlet, Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@ -60,6 +60,12 @@ export function AppShell() {
<PlusCircle size={18} /> <PlusCircle size={18} />
Nova Tarefa Nova Tarefa
</NavLink> </NavLink>
{user?.organizations.some(org => org.roles.includes('OWNER') || org.roles.includes('ADMIN')) && (
<NavLink to="/admin" className={navLinkClassName}>
<Settings size={18} />
Gestão
</NavLink>
)}
</nav> </nav>
</div> </div>
@ -108,6 +114,12 @@ export function AppShell() {
<PlusCircle size={18} /> <PlusCircle size={18} />
Criar Criar
</NavLink> </NavLink>
{user?.organizations.some(org => org.roles.includes('OWNER') || org.roles.includes('ADMIN')) && (
<NavLink to="/admin" className={navLinkClassName}>
<Settings size={18} />
Gestão
</NavLink>
)}
</nav> </nav>
</footer> </footer>
</div> </div>

View file

@ -0,0 +1,63 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { PlusCircle, Search, Layout } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
interface OnboardingModalProps {
isOpen: boolean
}
export const OnboardingModal: React.FC<OnboardingModalProps> = ({ isOpen }) => {
const navigate = useNavigate()
if (!isOpen) return null
return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-app-bg/80 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="w-full max-w-lg bg-surface border border-border-soft rounded-[2.5rem] shadow-panel p-10 overflow-hidden relative"
>
{/* Decorations */}
<div className="absolute -top-20 -right-20 w-40 h-40 bg-brand/10 blur-[80px] rounded-full" />
<div className="absolute -bottom-20 -left-20 w-40 h-40 bg-accent/10 blur-[80px] rounded-full" />
<div className="relative z-10 text-center mb-10">
<div className="w-16 h-16 bg-brand text-white rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-brand/20 mx-auto">
<Layout size={32} />
</div>
<h2 className="text-3xl font-black text-text-main tracking-tight mb-3">Quase lá!</h2>
<p className="text-text-muted font-medium">Você ainda não faz parte de nenhuma organização. Como deseja começar?</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 relative z-10">
<button
onClick={() => navigate('/organizations/new')}
className="group p-6 bg-surface-muted/30 border border-border-soft rounded-[2rem] text-left hover:border-brand hover:bg-brand/5 transition-all active:scale-[0.98]"
>
<div className="w-12 h-12 bg-brand/10 text-brand rounded-xl flex items-center justify-center mb-4 group-hover:bg-brand group-hover:text-white transition-colors">
<PlusCircle size={24} />
</div>
<h3 className="font-bold text-text-main mb-1">Criar uma</h3>
<p className="text-sm text-text-muted">Crie seu próprio workspace e convide sua equipe.</p>
</button>
<button
onClick={() => navigate('/organizations/join')}
className="group p-6 bg-surface-muted/30 border border-border-soft rounded-[2rem] text-left hover:border-accent hover:bg-accent/5 transition-all active:scale-[0.98]"
>
<div className="w-12 h-12 bg-accent/10 text-accent rounded-xl flex items-center justify-center mb-4 group-hover:bg-accent group-hover:text-white transition-colors">
<Search size={24} />
</div>
<h3 className="font-bold text-text-main mb-1">Participar</h3>
<p className="text-sm text-text-muted">Busque por uma organização existente e peça para entrar.</p>
</button>
</div>
</motion.div>
</div>
</AnimatePresence>
)
}

View file

@ -11,8 +11,9 @@ interface AuthContextType {
user: LoginResponse | null; user: LoginResponse | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<LoginResponse>;
logout: () => void; logout: () => void;
refreshUser: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -94,6 +95,25 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const response = await authService.login(email, password); const response = await authService.login(email, password);
localStorage.setItem("user", JSON.stringify(response)); localStorage.setItem("user", JSON.stringify(response));
setUser(response); setUser(response);
return response;
};
const refreshUser = async () => {
try {
const response = await authService.me();
// We keep the old tokens since /me might not return new ones
// or we just update the profile part.
// But actually, let's merge the updated profile data into the stored user.
const storedUser = localStorage.getItem("user");
if (storedUser) {
const userData = JSON.parse(storedUser);
const updatedUser = { ...userData, ...response };
localStorage.setItem("user", JSON.stringify(updatedUser));
setUser(updatedUser);
}
} catch (error) {
console.error("Failed to refresh user profile", error);
}
}; };
return ( return (
@ -104,6 +124,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
isLoading, isLoading,
login, login,
logout, logout,
refreshUser,
}} }}
> >
{children} {children}

View file

@ -11,10 +11,18 @@ type RequestOptions = Omit<RequestInit, 'body'> & {
export async function apiFetch<T>(path: string, options: RequestOptions = {}) { export async function apiFetch<T>(path: string, options: RequestOptions = {}) {
const { body, headers, ...rest } = options const { body, headers, ...rest } = options
const storedUser = localStorage.getItem('user')
let authHeader = {}
if (storedUser) {
const userData = JSON.parse(storedUser)
authHeader = { 'Authorization': `Bearer ${userData.jwt}` }
}
const response = await fetch(buildUrl(path), { const response = await fetch(buildUrl(path), {
...rest, ...rest,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...authHeader,
...headers, ...headers,
}, },
body: body !== undefined && body !== null && !(body instanceof FormData) body: body !== undefined && body !== null && !(body instanceof FormData)

View file

@ -0,0 +1,88 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Layout, Building2, ArrowRight } from 'lucide-react'
import { organizationService } from '../services/organizationService'
import { useAuth } from '../contexts/AuthContext'
export const CreateOrganizationPage: React.FC = () => {
const [name, setName] = useState('')
const [isLoading, setIsLoading] = useState(false)
const navigate = useNavigate()
const { refreshUser } = useAuth()
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await organizationService.create(name)
await refreshUser()
alert('Organização criada com sucesso!')
navigate('/')
} catch (error) {
console.error('Failed to create organization', error)
alert('Erro ao criar organização.')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen bg-app-bg flex items-center justify-center p-4 relative overflow-hidden font-sans">
<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-brand/15 blur-[100px] rounded-full" />
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
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">
<Building2 size={28} />
</div>
<h1 className="text-3xl font-black text-text-main tracking-tight mb-3">Nova Organização</h1>
<p className="text-text-muted font-medium">Dê um nome ao seu novo workspace</p>
</div>
<form onSubmit={handleCreate} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-bold text-text-main ml-1">Nome da Organização</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Layout 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="Ex: Bee Tech"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || !name}
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]"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-surface/30 border-t-surface rounded-full animate-spin" />
) : (
<>
Criar Agora
<ArrowRight size={20} />
</>
)}
</button>
</form>
</div>
</motion.div>
</div>
)
}

View file

@ -0,0 +1,122 @@
import React, { useState } from 'react'
import { motion } from 'framer-motion'
import { Search, Building2, Send, CheckCircle2 } from 'lucide-react'
import { organizationService } from '../services/organizationService'
interface OrgResult {
id: number
name: string
}
export const JoinOrganizationPage: React.FC = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState<OrgResult[]>([])
const [isLoading, setIsLoading] = useState(false)
const [requestedId, setRequestedId] = useState<number | null>(null)
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
if (!query) return
setIsLoading(true)
try {
const data = await organizationService.search(query)
setResults(data)
} catch (error) {
console.error('Search failed', error)
} finally {
setIsLoading(false)
}
}
const handleJoinRequest = async (id: number) => {
try {
await organizationService.requestJoin(id)
setRequestedId(id)
alert('Solicitação enviada! Aguarde a aprovação do dono da organização.')
} catch (error) {
console.error('Join request failed', error)
alert(error instanceof Error ? error.message : 'Erro ao enviar solicitação.')
}
}
return (
<div className="min-h-screen bg-app-bg flex items-center justify-center p-4 relative overflow-hidden font-sans">
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-50">
<div className="absolute top-0 right-0 w-[40%] h-[40%] bg-accent/15 blur-[100px] rounded-full" />
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-2xl 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-accent text-white rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-accent/20">
<Search size={28} />
</div>
<h1 className="text-3xl font-black text-text-main tracking-tight mb-3">Buscar Organização</h1>
<p className="text-text-muted font-medium">Encontre uma organização pelo nome</p>
</div>
<form onSubmit={handleSearch} className="flex gap-3 mb-8">
<div className="relative flex-1 group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Building2 className="h-5 w-5 text-text-muted group-focus-within:text-accent transition-colors" />
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(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-accent/10 focus:border-accent transition-all font-medium"
placeholder="Nome da organização..."
/>
</div>
<button
type="submit"
disabled={isLoading}
className="bg-accent hover:bg-accent/90 text-white font-bold px-8 rounded-2xl transition-all shadow-lg shadow-accent/20 active:scale-[0.98] flex items-center gap-2"
>
{isLoading ? <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : 'Buscar'}
</button>
</form>
<div className="space-y-3">
{results.length > 0 ? (
results.map((org) => (
<div
key={org.id}
className="flex items-center justify-between p-5 bg-surface-muted/30 border border-border-soft rounded-2xl hover:border-accent/50 transition-all"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-accent/10 text-accent rounded-lg flex items-center justify-center">
<Building2 size={20} />
</div>
<span className="font-bold text-text-main">{org.name}</span>
</div>
{requestedId === org.id ? (
<div className="flex items-center gap-2 text-green-500 font-bold text-sm bg-green-500/10 px-4 py-2 rounded-xl">
<CheckCircle2 size={16} />
Solicitado
</div>
) : (
<button
onClick={() => handleJoinRequest(org.id)}
className="flex items-center gap-2 text-accent font-bold text-sm hover:bg-accent hover:text-white px-4 py-2 rounded-xl border border-accent/20 transition-all"
>
<Send size={16} />
Solicitar entrada
</button>
)}
</div>
))
) : query && !isLoading ? (
<p className="text-center text-text-muted py-10 font-medium">Nenhuma organização encontrada.</p>
) : null}
</div>
</div>
</motion.div>
</div>
)
}

View file

@ -3,11 +3,13 @@ import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Mail, Lock, LogIn, Layout } from 'lucide-react' import { Mail, Lock, LogIn, Layout } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { OnboardingModal } from '../components/OnboardingModal'
export const LoginPage: React.FC = () => { export const LoginPage: React.FC = () => {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const { login } = useAuth() const { login } = useAuth()
@ -16,8 +18,12 @@ export const LoginPage: React.FC = () => {
setIsLoading(true) setIsLoading(true)
try { try {
await login(email, password) const response = await login(email, password)
if (response.organizations.length === 0) {
setShowOnboarding(true)
} else {
navigate('/') navigate('/')
}
} catch (error) { } catch (error) {
console.error('Login failed', error) console.error('Login failed', error)
alert('Erro ao fazer login. Tente novamente.') alert('Erro ao fazer login. Tente novamente.')
@ -110,6 +116,7 @@ export const LoginPage: React.FC = () => {
</div> </div>
</div> </div>
</motion.div> </motion.div>
<OnboardingModal isOpen={showOnboarding} />
</div> </div>
) )
} }

View file

@ -0,0 +1,141 @@
import React, { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { Users, Check, X, ShieldAlert, Mail } from 'lucide-react'
import { organizationService, type JoinRequest } from '../services/organizationService'
import { useAuth } from '../contexts/AuthContext'
export const OrganizationAdminPage: React.FC = () => {
const { user } = useAuth()
const [requests, setRequests] = useState<JoinRequest[]>([])
const [isLoading, setIsLoading] = useState(true)
// For this simplified version, we manage the first organization where the user is OWNER
const orgToManage = user?.organizations.find(org => org.roles.includes('OWNER') || org.roles.includes('ADMIN'))
useEffect(() => {
if (orgToManage) {
loadRequests()
}
}, [orgToManage])
const loadRequests = async () => {
if (!orgToManage) return
try {
const data = await organizationService.listPendingRequests(orgToManage.id)
setRequests(data)
} catch (error) {
console.error('Failed to load requests', error)
} finally {
setIsLoading(false)
}
}
const handleAction = async (userId: number, approved: boolean) => {
if (!orgToManage) return
try {
if (approved) {
await organizationService.approveRequest(orgToManage.id, userId)
} else {
await organizationService.rejectRequest(orgToManage.id, userId)
}
setRequests(prev => prev.filter(r => r.userId !== userId))
} catch (error) {
console.error('Action failed', error)
alert('Erro ao processar solicitação.')
}
}
if (!orgToManage) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="w-16 h-16 bg-danger/10 text-danger rounded-2xl flex items-center justify-center mb-6">
<ShieldAlert size={32} />
</div>
<h2 className="text-2xl font-black text-text-main mb-2">Acesso Negado</h2>
<p className="text-text-muted max-w-xs">Você não tem permissão para gerenciar nenhuma organização.</p>
</div>
)
}
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-4xl font-black tracking-tight text-text-main">
Gestão da <span className="text-brand">{orgToManage.name}</span>
</h1>
<p className="mt-2 text-text-muted font-medium">
Gerencie membros e solicitações de entrada
</p>
</div>
</div>
<div className="bg-surface border border-border-soft rounded-[2.5rem] shadow-panel overflow-hidden">
<div className="p-8 border-b border-border-soft bg-surface-muted/30">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-brand/10 text-brand rounded-xl flex items-center justify-center">
<Users size={20} />
</div>
<h2 className="text-xl font-bold text-text-main">Solicitações Pendentes</h2>
<span className="ml-auto bg-brand text-white text-xs font-bold px-3 py-1 rounded-full">
{requests.length} novos
</span>
</div>
</div>
<div className="divide-y divide-border-soft">
{isLoading ? (
<div className="p-20 flex justify-center">
<div className="w-8 h-8 border-4 border-brand/30 border-t-brand rounded-full animate-spin" />
</div>
) : requests.length === 0 ? (
<div className="p-20 text-center text-text-muted font-medium">
Nenhuma solicitação pendente no momento.
</div>
) : (
requests.map((request) => (
<motion.div
key={request.userId}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="p-6 flex flex-col sm:flex-row items-center justify-between gap-6 hover:bg-surface-muted/20 transition-colors"
>
<div className="flex items-center gap-4">
<img
src={request.photo || `https://ui-avatars.com/api/?name=${request.name}&background=random`}
alt={request.name}
className="w-12 h-12 rounded-full border border-border-soft object-cover"
/>
<div>
<h3 className="font-bold text-text-main text-lg">{request.name}</h3>
<div className="flex items-center gap-2 text-text-muted text-sm font-medium">
<Mail size={14} />
{request.email}
</div>
</div>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<button
onClick={() => handleAction(request.userId, false)}
className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl border border-border-soft text-text-muted font-bold hover:bg-danger/10 hover:text-danger hover:border-danger/20 transition-all"
>
<X size={18} />
Recusar
</button>
<button
onClick={() => handleAction(request.userId, true)}
className="flex-1 sm:flex-none flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl bg-brand text-white font-bold hover:bg-brand-strong shadow-lg shadow-brand/20 transition-all active:scale-[0.98]"
>
<Check size={18} />
Aprovar
</button>
</div>
</motion.div>
))
)}
</div>
</div>
</div>
)
}

View file

@ -6,6 +6,9 @@ import { TaskListPage } from "./pages/TaskListPage";
import { TaskBoardPage } from "./pages/TaskBoardPage"; import { TaskBoardPage } from "./pages/TaskBoardPage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { RegisterPage } from "./pages/RegisterPage"; import { RegisterPage } from "./pages/RegisterPage";
import { CreateOrganizationPage } from "./pages/CreateOrganizationPage";
import { JoinOrganizationPage } from "./pages/JoinOrganizationPage";
import { OrganizationAdminPage } from "./pages/OrganizationAdminPage";
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@ -16,6 +19,14 @@ export const router = createBrowserRouter([
path: "/register", path: "/register",
element: <RegisterPage />, element: <RegisterPage />,
}, },
{
path: "/organizations/new",
element: <CreateOrganizationPage />,
},
{
path: "/organizations/join",
element: <JoinOrganizationPage />,
},
{ {
path: "/", path: "/",
element: <AppShell />, element: <AppShell />,
@ -32,6 +43,10 @@ export const router = createBrowserRouter([
path: "board", path: "board",
element: <TaskBoardPage />, element: <TaskBoardPage />,
}, },
{
path: "admin",
element: <OrganizationAdminPage />,
},
], ],
}, },
]); ]);

View file

@ -9,6 +9,7 @@ export type LoginResponse = {
expiresIn: number // seconds expiresIn: number // seconds
issuedAt: string // ISO date issuedAt: string // ISO date
organizations: { organizations: {
id: number
name: string name: string
roles: string[] roles: string[]
}[] }[]
@ -35,4 +36,8 @@ export const authService = {
body: { refreshToken }, body: { refreshToken },
}) })
}, },
async me(): Promise<LoginResponse> {
return apiFetch<LoginResponse>('/auth/me')
}
} }

View file

@ -0,0 +1,42 @@
import { apiFetch } from '../lib/api'
export interface Organization {
id: number
name: string
}
export interface JoinRequest {
userId: number
name: string
email: string
photo?: string
}
export const organizationService = {
create: async (name: string): Promise<Organization> => {
return apiFetch('/organizations', {
method: 'POST',
body: { name },
})
},
search: async (query: string): Promise<Organization[]> => {
return apiFetch<Organization[]>(`/organizations/search?q=${query}`)
},
requestJoin: async (id: number): Promise<void> => {
return apiFetch(`/organizations/${id}/join`, { method: 'POST' })
},
listPendingRequests: async (organizationId: number): Promise<JoinRequest[]> => {
return apiFetch<JoinRequest[]>(`/organizations/${organizationId}/requests`)
},
approveRequest: async (organizationId: number, userId: number): Promise<void> => {
return apiFetch(`/organizations/${organizationId}/requests/${userId}/approve`, { method: 'PATCH' })
},
rejectRequest: async (organizationId: number, userId: number): Promise<void> => {
return apiFetch(`/organizations/${organizationId}/requests/${userId}/reject`, { method: 'PATCH' })
}
}