From e99e1cd586d733394fe58193e8c8c6f7f4a76831 Mon Sep 17 00:00:00 2001 From: Gabriel Santana de Menezes Date: Sun, 3 May 2026 17:21:21 -0400 Subject: [PATCH] Modal do primeiro acesso --- frontend/src/components/AppShell.tsx | 14 +- frontend/src/components/OnboardingModal.tsx | 63 ++++++++ frontend/src/contexts/AuthContext.tsx | 23 ++- frontend/src/lib/api.ts | 8 + frontend/src/pages/CreateOrganizationPage.tsx | 88 +++++++++++ frontend/src/pages/JoinOrganizationPage.tsx | 122 +++++++++++++++ frontend/src/pages/LoginPage.tsx | 11 +- frontend/src/pages/OrganizationAdminPage.tsx | 141 ++++++++++++++++++ frontend/src/router.tsx | 15 ++ frontend/src/services/authService.ts | 5 + frontend/src/services/organizationService.ts | 42 ++++++ 11 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/OnboardingModal.tsx create mode 100644 frontend/src/pages/CreateOrganizationPage.tsx create mode 100644 frontend/src/pages/JoinOrganizationPage.tsx create mode 100644 frontend/src/pages/OrganizationAdminPage.tsx create mode 100644 frontend/src/services/organizationService.ts diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index ae84348..967ccf1 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -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 { useAuth } from '../contexts/AuthContext' @@ -60,6 +60,12 @@ export function AppShell() { Nova Tarefa + {user?.organizations.some(org => org.roles.includes('OWNER') || org.roles.includes('ADMIN')) && ( + + + Gestão + + )} @@ -108,6 +114,12 @@ export function AppShell() { Criar + {user?.organizations.some(org => org.roles.includes('OWNER') || org.roles.includes('ADMIN')) && ( + + + Gestão + + )} diff --git a/frontend/src/components/OnboardingModal.tsx b/frontend/src/components/OnboardingModal.tsx new file mode 100644 index 0000000..35ff285 --- /dev/null +++ b/frontend/src/components/OnboardingModal.tsx @@ -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 = ({ isOpen }) => { + const navigate = useNavigate() + + if (!isOpen) return null + + return ( + +
+ + {/* Decorations */} +
+
+ +
+
+ +
+

Quase lá!

+

Você ainda não faz parte de nenhuma organização. Como deseja começar?

+
+ +
+ + + +
+ +
+ + ) +} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 55357d1..ba308dd 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -11,8 +11,9 @@ interface AuthContextType { user: LoginResponse | null; isAuthenticated: boolean; isLoading: boolean; - login: (email: string, password: string) => Promise; + login: (email: string, password: string) => Promise; logout: () => void; + refreshUser: () => Promise; } const AuthContext = createContext(undefined); @@ -94,6 +95,25 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ const response = await authService.login(email, password); localStorage.setItem("user", JSON.stringify(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 ( @@ -104,6 +124,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ isLoading, login, logout, + refreshUser, }} > {children} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d3b1b76..ca256c7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,10 +11,18 @@ type RequestOptions = Omit & { export async function apiFetch(path: string, options: RequestOptions = {}) { 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), { ...rest, headers: { 'Content-Type': 'application/json', + ...authHeader, ...headers, }, body: body !== undefined && body !== null && !(body instanceof FormData) diff --git a/frontend/src/pages/CreateOrganizationPage.tsx b/frontend/src/pages/CreateOrganizationPage.tsx new file mode 100644 index 0000000..3f5b305 --- /dev/null +++ b/frontend/src/pages/CreateOrganizationPage.tsx @@ -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 ( +
+
+
+
+ + +
+
+
+ +
+

Nova Organização

+

Dê um nome ao seu novo workspace

+
+ +
+
+ +
+
+ +
+ 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" + /> +
+
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/JoinOrganizationPage.tsx b/frontend/src/pages/JoinOrganizationPage.tsx new file mode 100644 index 0000000..dddf892 --- /dev/null +++ b/frontend/src/pages/JoinOrganizationPage.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(false) + const [requestedId, setRequestedId] = useState(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 ( +
+
+
+
+ + +
+
+
+ +
+

Buscar Organização

+

Encontre uma organização pelo nome

+
+ +
+
+
+ +
+ 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..." + /> +
+ +
+ +
+ {results.length > 0 ? ( + results.map((org) => ( +
+
+
+ +
+ {org.name} +
+ {requestedId === org.id ? ( +
+ + Solicitado +
+ ) : ( + + )} +
+ )) + ) : query && !isLoading ? ( +

Nenhuma organização encontrada.

+ ) : null} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index d79bb08..133e8eb 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -3,11 +3,13 @@ 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' +import { OnboardingModal } from '../components/OnboardingModal' export const LoginPage: React.FC = () => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [isLoading, setIsLoading] = useState(false) + const [showOnboarding, setShowOnboarding] = useState(false) const navigate = useNavigate() const { login } = useAuth() @@ -16,8 +18,12 @@ export const LoginPage: React.FC = () => { setIsLoading(true) try { - await login(email, password) - navigate('/') + const response = await login(email, password) + if (response.organizations.length === 0) { + setShowOnboarding(true) + } else { + navigate('/') + } } catch (error) { console.error('Login failed', error) alert('Erro ao fazer login. Tente novamente.') @@ -110,6 +116,7 @@ export const LoginPage: React.FC = () => {
+
) } diff --git a/frontend/src/pages/OrganizationAdminPage.tsx b/frontend/src/pages/OrganizationAdminPage.tsx new file mode 100644 index 0000000..9ff292e --- /dev/null +++ b/frontend/src/pages/OrganizationAdminPage.tsx @@ -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([]) + 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 ( +
+
+ +
+

Acesso Negado

+

Você não tem permissão para gerenciar nenhuma organização.

+
+ ) + } + + return ( +
+
+
+

+ Gestão da {orgToManage.name} +

+

+ Gerencie membros e solicitações de entrada +

+
+
+ +
+
+
+
+ +
+

Solicitações Pendentes

+ + {requests.length} novos + +
+
+ +
+ {isLoading ? ( +
+
+
+ ) : requests.length === 0 ? ( +
+ Nenhuma solicitação pendente no momento. +
+ ) : ( + requests.map((request) => ( + +
+ {request.name} +
+

{request.name}

+
+ + {request.email} +
+
+
+ +
+ + +
+
+ )) + )} +
+
+
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 8f39905..59ec700 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -6,6 +6,9 @@ import { TaskListPage } from "./pages/TaskListPage"; import { TaskBoardPage } from "./pages/TaskBoardPage"; import { LoginPage } from "./pages/LoginPage"; import { RegisterPage } from "./pages/RegisterPage"; +import { CreateOrganizationPage } from "./pages/CreateOrganizationPage"; +import { JoinOrganizationPage } from "./pages/JoinOrganizationPage"; +import { OrganizationAdminPage } from "./pages/OrganizationAdminPage"; export const router = createBrowserRouter([ { @@ -16,6 +19,14 @@ export const router = createBrowserRouter([ path: "/register", element: , }, + { + path: "/organizations/new", + element: , + }, + { + path: "/organizations/join", + element: , + }, { path: "/", element: , @@ -32,6 +43,10 @@ export const router = createBrowserRouter([ path: "board", element: , }, + { + path: "admin", + element: , + }, ], }, ]); diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index b1981e4..774df90 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -9,6 +9,7 @@ export type LoginResponse = { expiresIn: number // seconds issuedAt: string // ISO date organizations: { + id: number name: string roles: string[] }[] @@ -35,4 +36,8 @@ export const authService = { body: { refreshToken }, }) }, + + async me(): Promise { + return apiFetch('/auth/me') + } } diff --git a/frontend/src/services/organizationService.ts b/frontend/src/services/organizationService.ts new file mode 100644 index 0000000..7fe975b --- /dev/null +++ b/frontend/src/services/organizationService.ts @@ -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 => { + return apiFetch('/organizations', { + method: 'POST', + body: { name }, + }) + }, + + search: async (query: string): Promise => { + return apiFetch(`/organizations/search?q=${query}`) + }, + + requestJoin: async (id: number): Promise => { + return apiFetch(`/organizations/${id}/join`, { method: 'POST' }) + }, + + listPendingRequests: async (organizationId: number): Promise => { + return apiFetch(`/organizations/${organizationId}/requests`) + }, + + approveRequest: async (organizationId: number, userId: number): Promise => { + return apiFetch(`/organizations/${organizationId}/requests/${userId}/approve`, { method: 'PATCH' }) + }, + + rejectRequest: async (organizationId: number, userId: number): Promise => { + return apiFetch(`/organizations/${organizationId}/requests/${userId}/reject`, { method: 'PATCH' }) + } +}