From a00a5c6a532bb80425d6748706e227c91a875397 Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 29 May 2026 17:48:24 +0000 Subject: [PATCH] feat(auth): endpoint /auth/me, cockpits renomeados e menu de logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/auth/me retorna perfil real do ERP (vw_representantes) - Contrato UserProfile adicionado ao shared api-interface - Hook useCurrentUser() no frontend consome o endpoint - Cockpit rafael → rep, sandra → supervisor (pastas e componentes) - Topbar exibe iniciais do usuário e dropdown com nome, role e "Sair" - Logout limpa token e recarrega para voltar ao DevLogin Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/api/src/app/auth/auth.controller.ts | 34 ++++++++ apps/api/src/app/auth/auth.module.ts | 3 +- .../cockpits/{rafael => rep}/CatalogPage.tsx | 0 .../{rafael => rep}/ClientDetailPage.tsx | 0 .../cockpits/{rafael => rep}/ClientsPage.tsx | 0 .../cockpits/{rafael => rep}/NewOrderPage.tsx | 0 .../{rafael => rep}/OrderDetailPage.tsx | 0 .../cockpits/{rafael => rep}/OrdersPage.tsx | 0 .../RafaelPainel.tsx => rep/RepPainel.tsx} | 6 +- .../ApprovalQueuePage.tsx | 0 .../SupervisorPainel.tsx} | 13 ++- apps/web/src/components/dev/DevLogin.tsx | 6 +- apps/web/src/components/layout/Topbar.tsx | 79 +++++++++++++++---- apps/web/src/lib/queries/auth.ts | 18 +++++ apps/web/src/lib/router.tsx | 22 +++--- .../api-interface/src/lib/auth.contract.ts | 8 ++ 16 files changed, 156 insertions(+), 33 deletions(-) create mode 100644 apps/api/src/app/auth/auth.controller.ts rename apps/web/src/cockpits/{rafael => rep}/CatalogPage.tsx (100%) rename apps/web/src/cockpits/{rafael => rep}/ClientDetailPage.tsx (100%) rename apps/web/src/cockpits/{rafael => rep}/ClientsPage.tsx (100%) rename apps/web/src/cockpits/{rafael => rep}/NewOrderPage.tsx (100%) rename apps/web/src/cockpits/{rafael => rep}/OrderDetailPage.tsx (100%) rename apps/web/src/cockpits/{rafael => rep}/OrdersPage.tsx (100%) rename apps/web/src/cockpits/{rafael/RafaelPainel.tsx => rep/RepPainel.tsx} (98%) rename apps/web/src/cockpits/{sandra => supervisor}/ApprovalQueuePage.tsx (100%) rename apps/web/src/cockpits/{sandra/SandraPainel.tsx => supervisor/SupervisorPainel.tsx} (96%) create mode 100644 apps/web/src/lib/queries/auth.ts diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts new file mode 100644 index 0000000..00d9114 --- /dev/null +++ b/apps/api/src/app/auth/auth.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import type { UserProfile } from '@sar/api-interface'; +import type { WorkspaceClsStore } from '../workspace/workspace.types'; +import type { PrismaClient } from '@prisma/client'; + +@Controller({ path: 'auth' }) +export class AuthController { + constructor(private readonly cls: ClsService) {} + + @Get('me') + async me(): Promise { + const prisma = this.cls.get('prisma') as PrismaClient; + const userId = this.cls.get('userId') ?? ''; + const role = this.cls.get('role') ?? 'rep'; + const idEmpresa = this.cls.get('idEmpresa'); + + const rows = await prisma.$queryRaw<{ codigo: number; nome: string }[]>` + SELECT codigo, nome + FROM sar.vw_representantes + WHERE codigo = ${parseInt(userId, 10)} + AND id_empresa = ${idEmpresa} + LIMIT 1 + `; + + const row = rows[0]; + return { + codVendedor: row?.codigo ?? parseInt(userId, 10), + nome: row?.nome ?? userId, + role, + idEmpresa, + }; + } +} diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index aa8a21a..7450a92 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { WorkspaceModule } from '../workspace/workspace.module'; import { JwtAuthGuard } from './jwt-auth.guard'; import { DevAuthController } from './dev-auth.controller'; +import { AuthController } from './auth.controller'; @Module({ imports: [WorkspaceModule], - controllers: [DevAuthController], + controllers: [DevAuthController, AuthController], providers: [JwtAuthGuard], exports: [JwtAuthGuard], }) diff --git a/apps/web/src/cockpits/rafael/CatalogPage.tsx b/apps/web/src/cockpits/rep/CatalogPage.tsx similarity index 100% rename from apps/web/src/cockpits/rafael/CatalogPage.tsx rename to apps/web/src/cockpits/rep/CatalogPage.tsx diff --git a/apps/web/src/cockpits/rafael/ClientDetailPage.tsx b/apps/web/src/cockpits/rep/ClientDetailPage.tsx similarity index 100% rename from apps/web/src/cockpits/rafael/ClientDetailPage.tsx rename to apps/web/src/cockpits/rep/ClientDetailPage.tsx diff --git a/apps/web/src/cockpits/rafael/ClientsPage.tsx b/apps/web/src/cockpits/rep/ClientsPage.tsx similarity index 100% rename from apps/web/src/cockpits/rafael/ClientsPage.tsx rename to apps/web/src/cockpits/rep/ClientsPage.tsx diff --git a/apps/web/src/cockpits/rafael/NewOrderPage.tsx b/apps/web/src/cockpits/rep/NewOrderPage.tsx similarity index 100% rename from apps/web/src/cockpits/rafael/NewOrderPage.tsx rename to apps/web/src/cockpits/rep/NewOrderPage.tsx diff --git a/apps/web/src/cockpits/rafael/OrderDetailPage.tsx b/apps/web/src/cockpits/rep/OrderDetailPage.tsx similarity index 100% rename from apps/web/src/cockpits/rafael/OrderDetailPage.tsx rename to apps/web/src/cockpits/rep/OrderDetailPage.tsx diff --git a/apps/web/src/cockpits/rafael/OrdersPage.tsx b/apps/web/src/cockpits/rep/OrdersPage.tsx similarity index 100% rename from apps/web/src/cockpits/rafael/OrdersPage.tsx rename to apps/web/src/cockpits/rep/OrdersPage.tsx diff --git a/apps/web/src/cockpits/rafael/RafaelPainel.tsx b/apps/web/src/cockpits/rep/RepPainel.tsx similarity index 98% rename from apps/web/src/cockpits/rafael/RafaelPainel.tsx rename to apps/web/src/cockpits/rep/RepPainel.tsx index 34731ae..35810b5 100644 --- a/apps/web/src/cockpits/rafael/RafaelPainel.tsx +++ b/apps/web/src/cockpits/rep/RepPainel.tsx @@ -9,6 +9,7 @@ import { Link } from '@tanstack/react-router'; import type { PedidoSummary } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface'; import { useRepDashboard } from '../../lib/queries/dashboard'; +import { useCurrentUser } from '../../lib/queries/auth'; const { Title, Text } = Typography; @@ -37,8 +38,9 @@ function today(): string { }); } -export function RafaelPainel() { +export function RepPainel() { const { data, isLoading } = useRepDashboard(); + const { data: user } = useCurrentUser(); if (isLoading || !data) { return ( @@ -66,7 +68,7 @@ export function RafaelPainel() { {/* Saudação */} - {greeting()}, Rafael + {greeting()}, {user?.nome?.split(' ')[0] ?? '...'} {today()} diff --git a/apps/web/src/cockpits/sandra/ApprovalQueuePage.tsx b/apps/web/src/cockpits/supervisor/ApprovalQueuePage.tsx similarity index 100% rename from apps/web/src/cockpits/sandra/ApprovalQueuePage.tsx rename to apps/web/src/cockpits/supervisor/ApprovalQueuePage.tsx diff --git a/apps/web/src/cockpits/sandra/SandraPainel.tsx b/apps/web/src/cockpits/supervisor/SupervisorPainel.tsx similarity index 96% rename from apps/web/src/cockpits/sandra/SandraPainel.tsx rename to apps/web/src/cockpits/supervisor/SupervisorPainel.tsx index 2bac414..e3f62af 100644 --- a/apps/web/src/cockpits/sandra/SandraPainel.tsx +++ b/apps/web/src/cockpits/supervisor/SupervisorPainel.tsx @@ -9,6 +9,7 @@ import { import { Link } from '@tanstack/react-router'; import type { PedidoSummary } from '@sar/api-interface'; import { useSupervisorDashboard } from '../../lib/queries/dashboard'; +import { useCurrentUser } from '../../lib/queries/auth'; const { Title, Text } = Typography; @@ -26,6 +27,13 @@ function delta(current: number, previous: number): { label: string; positive: bo return { label: `${pct >= 0 ? '+' : ''}${pct}% vs semana passada`, positive: pct >= 0 }; } +function greeting(): string { + const h = new Date().getHours(); + if (h < 12) return 'Bom dia'; + if (h < 18) return 'Boa tarde'; + return 'Boa noite'; +} + function today(): string { return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); } @@ -72,8 +80,9 @@ const queueColumns: TableColumnsType = [ }, ]; -export function SandraPainel() { +export function SupervisorPainel() { const { data, isLoading } = useSupervisorDashboard(); + const { data: user } = useCurrentUser(); if (isLoading || !data) { return ( @@ -100,7 +109,7 @@ export function SandraPainel() { {/* Saudação */} - Bom dia, Sandra + {greeting()}, {user?.nome?.split(' ')[0] ?? '...'} {today()} diff --git a/apps/web/src/components/dev/DevLogin.tsx b/apps/web/src/components/dev/DevLogin.tsx index 14152a8..f6a26f8 100644 --- a/apps/web/src/components/dev/DevLogin.tsx +++ b/apps/web/src/components/dev/DevLogin.tsx @@ -12,9 +12,9 @@ type DevUser = { key: string; userId: string; role: string; label: string }; // userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1) // Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado. const DEV_USERS: DevUser[] = [ - { key: 'rep-29', userId: '29', role: 'rep', label: 'PAVEI COMERCIO (cod 29)' }, - { key: 'sup-29', userId: '29', role: 'supervisor', label: 'PAVEI — Supervisor (cod 29)' }, - { key: 'mgr-29', userId: '29', role: 'manager', label: 'PAVEI — Gerente (cod 29)' }, + { key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' }, + { key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' }, + { key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' }, ]; export function DevLogin({ onLogin }: { onLogin: () => void }) { diff --git a/apps/web/src/components/layout/Topbar.tsx b/apps/web/src/components/layout/Topbar.tsx index 2df30b0..249f2b3 100644 --- a/apps/web/src/components/layout/Topbar.tsx +++ b/apps/web/src/components/layout/Topbar.tsx @@ -1,9 +1,16 @@ -import { Avatar, Badge, Button, Flex, Input } from 'antd'; +import { Avatar, Badge, Button, Dropdown, Flex, Input, Typography } from 'antd'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons'; +import { + faBell, + faMagnifyingGlass, + faBars, + faRightFromBracket, +} from '@fortawesome/free-solid-svg-icons'; import { brandTokens } from '../../lib/theme'; import { FoundationStatus } from './FoundationStatus'; import { usePendingCount } from '../../lib/queries/notifications'; +import { useCurrentUser } from '../../lib/queries/auth'; +import { authStore } from '../../lib/auth-store'; interface TopbarProps { onToggleSidebar?: () => void; @@ -14,9 +21,51 @@ interface TopbarProps { * Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita. * Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav). */ +function logout() { + authStore.clear(); + window.location.reload(); +} + export function Topbar({ onToggleSidebar }: TopbarProps) { const { data: pendingData } = usePendingCount(); const pendingCount = pendingData?.count ?? 0; + const { data: user } = useCurrentUser(); + const initials = user?.nome + ? user.nome + .split(' ') + .slice(0, 2) + .map((w) => w[0]) + .join('') + .toUpperCase() + : '?'; + + const userMenuItems = [ + { + key: 'profile', + label: ( + + + {user?.nome?.trim() ?? '—'} + + + {user?.role ?? ''} + + + ), + disabled: true, + }, + { type: 'divider' as const }, + { + key: 'logout', + danger: true, + icon: , + label: 'Sair', + onClick: logout, + }, + ]; return ( - {/* Centro: search (Sandra/Daniel/Alice) */} + {/* Centro: search (Supervisor/Admin) */} - - R - + + + {initials} + + ); diff --git a/apps/web/src/lib/queries/auth.ts b/apps/web/src/lib/queries/auth.ts new file mode 100644 index 0000000..1e69072 --- /dev/null +++ b/apps/web/src/lib/queries/auth.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { UserProfileSchema, type UserProfile } from '@sar/api-interface'; +import { apiFetch } from '../api-client'; + +export const AUTH_KEYS = { + me: ['auth', 'me'] as const, +}; + +export function useCurrentUser() { + return useQuery({ + queryKey: AUTH_KEYS.me, + queryFn: async () => { + const res = await apiFetch('/auth/me'); + return UserProfileSchema.parse(res); + }, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 8d8485b..06faf38 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -7,15 +7,15 @@ import { } from '@tanstack/react-router'; import { Typography } from 'antd'; import { AppShell } from '../components/layout/AppShell'; -import { RafaelPainel } from '../cockpits/rafael/RafaelPainel'; -import { ClientsPage } from '../cockpits/rafael/ClientsPage'; -import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage'; -import { OrdersPage } from '../cockpits/rafael/OrdersPage'; -import { OrderDetailPage } from '../cockpits/rafael/OrderDetailPage'; -import { NewOrderPage } from '../cockpits/rafael/NewOrderPage'; -import { CatalogPage } from '../cockpits/rafael/CatalogPage'; -import { ApprovalQueuePage } from '../cockpits/sandra/ApprovalQueuePage'; -import { SandraPainel } from '../cockpits/sandra/SandraPainel'; +import { RepPainel } from '../cockpits/rep/RepPainel'; +import { ClientsPage } from '../cockpits/rep/ClientsPage'; +import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage'; +import { OrdersPage } from '../cockpits/rep/OrdersPage'; +import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage'; +import { NewOrderPage } from '../cockpits/rep/NewOrderPage'; +import { CatalogPage } from '../cockpits/rep/CatalogPage'; +import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage'; +import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel'; import { authStore } from './auth-store'; function getRoleFromToken(): string { @@ -31,7 +31,7 @@ function getRoleFromToken(): string { function HomeRoute() { const role = getRoleFromToken(); - return role === 'supervisor' || role === 'manager' ? : ; + return role === 'supervisor' || role === 'manager' ? : ; } function NotFoundPage() { @@ -65,7 +65,7 @@ const indexRoute = createRoute({ const rafaelRoute = createRoute({ getParentRoute: () => rootRoute, path: '/rep', - component: RafaelPainel, + component: RepPainel, }); const clientesRoute = createRoute({ diff --git a/libs/shared/api-interface/src/lib/auth.contract.ts b/libs/shared/api-interface/src/lib/auth.contract.ts index fce6994..f27b434 100644 --- a/libs/shared/api-interface/src/lib/auth.contract.ts +++ b/libs/shared/api-interface/src/lib/auth.contract.ts @@ -19,6 +19,14 @@ export const AuthTokenResponseSchema = z.object({ expiresIn: z.number().int().positive(), }); +export const UserProfileSchema = z.object({ + codVendedor: z.number().int(), + nome: z.string(), + role: JwtRoleSchema, + idEmpresa: z.number().int(), +}); + export type DevTokenRequest = z.infer; export type AuthTokenResponse = z.infer; export type JwtRole = z.infer; +export type UserProfile = z.infer;