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:
34
apps/api/src/app/auth/auth.controller.ts
Normal file
34
apps/api/src/app/auth/auth.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 */}
|
||||
<Flex vertical gap={4}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
{greeting()}, Rafael
|
||||
{greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{today()}
|
||||
@@ -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<PedidoSummary> = [
|
||||
},
|
||||
];
|
||||
|
||||
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 */}
|
||||
<Flex vertical gap={4}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Bom dia, Sandra
|
||||
{greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{today()}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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: (
|
||||
<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 (
|
||||
<Flex
|
||||
@@ -69,7 +118,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Centro: search (Sandra/Daniel/Alice) */}
|
||||
{/* Centro: search (Supervisor/Admin) */}
|
||||
<Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}>
|
||||
<Input
|
||||
size="large"
|
||||
@@ -93,6 +142,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
aria-label="Notificações"
|
||||
/>
|
||||
</Badge>
|
||||
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
|
||||
<Avatar
|
||||
size={40}
|
||||
style={{
|
||||
@@ -102,8 +152,9 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
R
|
||||
{initials}
|
||||
</Avatar>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
18
apps/web/src/lib/queries/auth.ts
Normal file
18
apps/web/src/lib/queries/auth.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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' ? <SandraPainel /> : <RafaelPainel />;
|
||||
return role === 'supervisor' || role === 'manager' ? <SupervisorPainel /> : <RepPainel />;
|
||||
}
|
||||
|
||||
function NotFoundPage() {
|
||||
@@ -65,7 +65,7 @@ const indexRoute = createRoute({
|
||||
const rafaelRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/rep',
|
||||
component: RafaelPainel,
|
||||
component: RepPainel,
|
||||
});
|
||||
|
||||
const clientesRoute = createRoute({
|
||||
|
||||
@@ -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<typeof DevTokenRequestSchema>;
|
||||
export type AuthTokenResponse = z.infer<typeof AuthTokenResponseSchema>;
|
||||
export type JwtRole = z.infer<typeof JwtRoleSchema>;
|
||||
export type UserProfile = z.infer<typeof UserProfileSchema>;
|
||||
|
||||
Reference in New Issue
Block a user