feat(auth): endpoint /auth/me, cockpits renomeados e menu de logout

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 17:48:24 +00:00
parent 20b0793227
commit a00a5c6a53
16 changed files with 156 additions and 33 deletions

View File

@@ -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<WorkspaceClsStore>) {}
@Get('me')
async me(): Promise<UserProfile> {
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,
};
}
}

View File

@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { WorkspaceModule } from '../workspace/workspace.module'; import { WorkspaceModule } from '../workspace/workspace.module';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
import { DevAuthController } from './dev-auth.controller'; import { DevAuthController } from './dev-auth.controller';
import { AuthController } from './auth.controller';
@Module({ @Module({
imports: [WorkspaceModule], imports: [WorkspaceModule],
controllers: [DevAuthController], controllers: [DevAuthController, AuthController],
providers: [JwtAuthGuard], providers: [JwtAuthGuard],
exports: [JwtAuthGuard], exports: [JwtAuthGuard],
}) })

View File

@@ -9,6 +9,7 @@ import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface'; import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard'; import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -37,8 +38,9 @@ function today(): string {
}); });
} }
export function RafaelPainel() { export function RepPainel() {
const { data, isLoading } = useRepDashboard(); const { data, isLoading } = useRepDashboard();
const { data: user } = useCurrentUser();
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -66,7 +68,7 @@ export function RafaelPainel() {
{/* Saudação */} {/* Saudação */}
<Flex vertical gap={4}> <Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}> <Title level={2} style={{ margin: 0 }}>
{greeting()}, Rafael {greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()} {today()}

View File

@@ -9,6 +9,7 @@ import {
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface'; import type { PedidoSummary } from '@sar/api-interface';
import { useSupervisorDashboard } from '../../lib/queries/dashboard'; import { useSupervisorDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
const { Title, Text } = Typography; 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 }; 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 { function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
} }
@@ -72,8 +80,9 @@ const queueColumns: TableColumnsType<PedidoSummary> = [
}, },
]; ];
export function SandraPainel() { export function SupervisorPainel() {
const { data, isLoading } = useSupervisorDashboard(); const { data, isLoading } = useSupervisorDashboard();
const { data: user } = useCurrentUser();
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -100,7 +109,7 @@ export function SandraPainel() {
{/* Saudação */} {/* Saudação */}
<Flex vertical gap={4}> <Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}> <Title level={2} style={{ margin: 0 }}>
Bom dia, Sandra {greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()} {today()}

View File

@@ -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) // 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. // Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado.
const DEV_USERS: DevUser[] = [ const DEV_USERS: DevUser[] = [
{ key: 'rep-29', userId: '29', role: 'rep', label: 'PAVEI COMERCIO (cod 29)' }, { key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' },
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'PAVEI — Supervisor (cod 29)' }, { key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' },
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'PAVEI — Gerente (cod 29)' }, { key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' },
]; ];
export function DevLogin({ onLogin }: { onLogin: () => void }) { export function DevLogin({ onLogin }: { onLogin: () => void }) {

View File

@@ -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 { 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 { brandTokens } from '../../lib/theme';
import { FoundationStatus } from './FoundationStatus'; import { FoundationStatus } from './FoundationStatus';
import { usePendingCount } from '../../lib/queries/notifications'; import { usePendingCount } from '../../lib/queries/notifications';
import { useCurrentUser } from '../../lib/queries/auth';
import { authStore } from '../../lib/auth-store';
interface TopbarProps { interface TopbarProps {
onToggleSidebar?: () => void; onToggleSidebar?: () => void;
@@ -14,9 +21,51 @@ interface TopbarProps {
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita. * Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav). * Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
*/ */
function logout() {
authStore.clear();
window.location.reload();
}
export function Topbar({ onToggleSidebar }: TopbarProps) { export function Topbar({ onToggleSidebar }: TopbarProps) {
const { data: pendingData } = usePendingCount(); const { data: pendingData } = usePendingCount();
const pendingCount = pendingData?.count ?? 0; 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: (
<Flex vertical gap={2} style={{ padding: '4px 0', minWidth: 180 }}>
<Typography.Text strong style={{ fontSize: 'var(--text-sm)' }}>
{user?.nome?.trim() ?? '—'}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 'var(--text-xs)', textTransform: 'capitalize' }}
>
{user?.role ?? ''}
</Typography.Text>
</Flex>
),
disabled: true,
},
{ type: 'divider' as const },
{
key: 'logout',
danger: true,
icon: <FontAwesomeIcon icon={faRightFromBracket} />,
label: 'Sair',
onClick: logout,
},
];
return ( return (
<Flex <Flex
@@ -69,7 +118,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
</Flex> </Flex>
</Flex> </Flex>
{/* Centro: search (Sandra/Daniel/Alice) */} {/* Centro: search (Supervisor/Admin) */}
<Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}> <Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}>
<Input <Input
size="large" size="large"
@@ -93,6 +142,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
aria-label="Notificações" aria-label="Notificações"
/> />
</Badge> </Badge>
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
<Avatar <Avatar
size={40} size={40}
style={{ style={{
@@ -102,8 +152,9 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
R {initials}
</Avatar> </Avatar>
</Dropdown>
</Flex> </Flex>
</Flex> </Flex>
); );

View File

@@ -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<UserProfile, Error>({
queryKey: AUTH_KEYS.me,
queryFn: async () => {
const res = await apiFetch('/auth/me');
return UserProfileSchema.parse(res);
},
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -7,15 +7,15 @@ import {
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { Typography } from 'antd'; import { Typography } from 'antd';
import { AppShell } from '../components/layout/AppShell'; import { AppShell } from '../components/layout/AppShell';
import { RafaelPainel } from '../cockpits/rafael/RafaelPainel'; import { RepPainel } from '../cockpits/rep/RepPainel';
import { ClientsPage } from '../cockpits/rafael/ClientsPage'; import { ClientsPage } from '../cockpits/rep/ClientsPage';
import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage'; import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
import { OrdersPage } from '../cockpits/rafael/OrdersPage'; import { OrdersPage } from '../cockpits/rep/OrdersPage';
import { OrderDetailPage } from '../cockpits/rafael/OrderDetailPage'; import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
import { NewOrderPage } from '../cockpits/rafael/NewOrderPage'; import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rafael/CatalogPage'; import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { ApprovalQueuePage } from '../cockpits/sandra/ApprovalQueuePage'; import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SandraPainel } from '../cockpits/sandra/SandraPainel'; import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { authStore } from './auth-store'; import { authStore } from './auth-store';
function getRoleFromToken(): string { function getRoleFromToken(): string {
@@ -31,7 +31,7 @@ function getRoleFromToken(): string {
function HomeRoute() { function HomeRoute() {
const role = getRoleFromToken(); const role = getRoleFromToken();
return role === 'supervisor' || role === 'manager' ? <SandraPainel /> : <RafaelPainel />; return role === 'supervisor' || role === 'manager' ? <SupervisorPainel /> : <RepPainel />;
} }
function NotFoundPage() { function NotFoundPage() {
@@ -65,7 +65,7 @@ const indexRoute = createRoute({
const rafaelRoute = createRoute({ const rafaelRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/rep', path: '/rep',
component: RafaelPainel, component: RepPainel,
}); });
const clientesRoute = createRoute({ const clientesRoute = createRoute({

View File

@@ -19,6 +19,14 @@ export const AuthTokenResponseSchema = z.object({
expiresIn: z.number().int().positive(), 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<typeof DevTokenRequestSchema>; export type DevTokenRequest = z.infer<typeof DevTokenRequestSchema>;
export type AuthTokenResponse = z.infer<typeof AuthTokenResponseSchema>; export type AuthTokenResponse = z.infer<typeof AuthTokenResponseSchema>;
export type JwtRole = z.infer<typeof JwtRoleSchema>; export type JwtRole = z.infer<typeof JwtRoleSchema>;
export type UserProfile = z.infer<typeof UserProfileSchema>;