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 { 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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()}
|
||||||
@@ -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()}
|
||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
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';
|
} 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({
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user