Migração completa para Logto - Remoção de Clerk finalizada

This commit is contained in:
2026-03-31 10:32:42 +00:00
parent 87a87ae228
commit 49538cfbd4
21 changed files with 371 additions and 688 deletions

View File

@@ -18,6 +18,7 @@ import { CalculatorDashboard } from './pages/CalculatorDashboard';
import { StockDashboard } from './pages/StockDashboard';
import { GuestDashboard } from './pages/GuestDashboard';
import { Login } from './pages/Login';
import { Callback } from './pages/Callback';
import InstrumentList from './pages/InstrumentList';
const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -38,6 +39,8 @@ const AppContent: React.FC = () => {
<Layout>
<Routes>
<Route path="/" element={<ProjectList />} />
<Route path="/login" element={<Login />} />
<Route path="/callback" element={<Callback />} />
<Route path="/guest-dashboard" element={<GuestDashboard />} />
<Route path="/projects" element={<ProjectList />} />
<Route path="/project/:id" element={<ProjectDetails />} />

View File

@@ -2,13 +2,11 @@ import React, { useState } from 'react';
import NotificationBell from './NotificationBell';
import { TeamPresence } from './TeamPresence';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Menu, X, FolderOpen, Layers, ClipboardCheck, LogOut, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer } from 'lucide-react';
import { Menu, X, FolderOpen, Layers, ClipboardCheck, LogOut, TrendingUp, Sun, Moon, HelpCircle, Shield, Wrench, Terminal, LayoutDashboard, Package, Thermometer, User } from 'lucide-react';
import { clsx } from 'clsx';
import { useClerk, UserButton, useUser, OrganizationSwitcher, useOrganization } from '@clerk/clerk-react';
import { useLogto } from '@logto/react';
import { TechnicalManual } from './TechnicalManual';
import { useAuth } from '../context/useAuth';
// import { useSystemSettings } from '../context/SystemSettingsContext';
import { setApiOrgData } from '../services/api';
interface LayoutProps {
children: React.ReactNode;
@@ -22,21 +20,8 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
});
const location = useLocation();
const { signOut } = useClerk();
const { user } = useUser();
const { organization } = useOrganization();
const { isAdmin, isUser, isDeveloper, appUser } = useAuth();
// const { settings } = useSystemSettings();
// Sync Organization ID with API client
React.useEffect(() => {
if (organization?.id) {
setApiOrgData(organization.id, organization.name);
} else {
setApiOrgData(null);
}
}, [organization]);
const { signOut } = useLogto();
const { isAdmin, isUser, isDeveloper, appUser, isSignedIn } = useAuth();
// Helper to get role display name
const getRoleDisplay = () => {
@@ -69,6 +54,14 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
}
}, [appUser, location.pathname, navigate]);
// Redirect to login if not signed in (except for login and callback pages)
React.useEffect(() => {
const publicPaths = ['/login', '/callback'];
if (!isSignedIn && !publicPaths.includes(location.pathname)) {
navigate('/login');
}
}, [isSignedIn, location.pathname, navigate]);
interface NavItem {
icon: React.ElementType;
label: string;
@@ -94,6 +87,14 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
return false;
};
const handleLogout = () => {
signOut(window.location.origin);
};
if (location.pathname === '/login' || location.pathname === '/callback') {
return <>{children}</>;
}
return (
<div className="min-h-screen bg-surface-soft flex font-sans selection:bg-primary/30">
{/* Sidebar Desktop - Fixed */}
@@ -117,47 +118,6 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
</div>
</div>
<div className="px-6 mb-2">
{isAdmin() ? (
<OrganizationSwitcher
hidePersonal={true}
afterSelectOrganizationUrl="/"
afterCreateOrganizationUrl="/"
afterLeaveOrganizationUrl="/"
appearance={{
elements: {
rootBox: "w-full",
organizationSwitcherTrigger: "w-full justify-between bg-surface-hover/50 hover:bg-surface-hover p-2 rounded-xl border border-border/50 text-text-main transition-all",
organizationPreviewTextContainer: "text-text-main",
organizationPreviewMainIdentifier: "text-text-main font-semibold",
organizationSwitcherPopoverCard: "bg-surface border border-border/40 shadow-2xl",
organizationSwitcherPopoverActions: "bg-surface-soft/50",
organizationSwitcherPopoverActionButton: "text-text-main hover:bg-surface-hover transition-colors",
organizationPreview: "hover:bg-surface-hover cursor-pointer transition-colors px-4 py-3",
organizationPreviewSecondaryIdentifier: "text-text-muted",
organizationSwitcherPopoverFooter: "hidden",
userPreviewMainIdentifier: "text-text-main font-bold",
userPreviewSecondaryIdentifier: "text-text-muted",
}
}}
/>
) : (
<div className="w-full flex items-center gap-3 p-2 rounded-xl border border-border/50 bg-surface-hover/50 text-text-main opacity-80 cursor-default" title="Apenas visualização">
{organization?.imageUrl ? (
<img src={organization.imageUrl} alt={organization.name} className="w-8 h-8 rounded-lg object-cover bg-surface-soft" />
) : (
<div className="w-8 h-8 rounded-lg bg-surface-soft flex items-center justify-center font-bold text-xs">
{organization?.name?.substring(0, 2).toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{organization?.name || 'Carregando...'}</p>
<p className="text-[10px] text-text-muted uppercase tracking-wider">Organização</p>
</div>
</div>
)}
</div>
{/* Team Presence - Shows all members with online/offline status */}
<TeamPresence />
@@ -257,7 +217,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
</button>
<button
onClick={() => signOut()}
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-text-secondary hover:text-error hover:bg-error/5 transition-all w-full"
>
<LogOut size={18} />
@@ -268,9 +228,11 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
<div className="flex items-center gap-2">
<NotificationBell />
<div className="w-px h-6 bg-border/50 mx-1"></div>
<UserButton afterSignOutUrl="/" />
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary">
<User size={16} />
</div>
<div className="flex flex-col">
<span className="text-[10px] text-text-main font-bold truncate max-w-[100px]">{user?.firstName || 'Usuário'}</span>
<span className="text-[10px] text-text-main font-bold truncate max-w-[100px]">{appUser?.name || 'Usuário'}</span>
<span className="text-[8px] text-text-muted">v2.1.0</span>
</div>
</div>
@@ -386,6 +348,13 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
{isDarkMode ? <Sun size={20} className="text-yellow-500" /> : <Moon size={20} className="text-primary" />}
{isDarkMode ? 'Modo Claro' : 'Modo Escuro'}
</button>
<button
onClick={handleLogout}
className="flex items-center gap-4 w-full text-text-main font-bold"
>
<LogOut size={20} />
Sair
</button>
</div>
</div>
@@ -415,4 +384,3 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
</div>
);
};

View File

@@ -23,7 +23,7 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
requireEdit = false,
redirectTo = '/',
}) => {
const { appUser, isLoading, canEdit } = useAuth();
const { appUser, isLoading, canEdit, isSignedIn } = useAuth();
// Show loading state
if (isLoading) {
@@ -34,6 +34,11 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
);
}
// Check authentication
if (!isSignedIn) {
return <Navigate to="/login" replace />;
}
// Check role-based access
if (allowedRoles && appUser && !allowedRoles.includes(appUser.role)) {
return <Navigate to={redirectTo} replace />;

View File

@@ -1,15 +1,15 @@
import React from 'react';
import { usePresence } from '../hooks/usePresence';
import { useAuth } from '../context/useAuth';
import { useOrganization } from '@clerk/clerk-react';
import { SendMessageModal } from './SendMessageModal';
import api from '../services/api';
interface OrganizationMember {
_id: string;
id: string;
name: string;
email: string;
clerkUserId: string;
logto_id: string;
role: string;
}
@@ -25,69 +25,50 @@ interface PendingMessage {
export const TeamPresence: React.FC = () => {
const { activeUsers } = usePresence();
const { appUser } = useAuth();
const { organization } = useOrganization();
const [allMembers, setAllMembers] = React.useState<OrganizationMember[]>([]);
const [pendingMessages, setPendingMessages] = React.useState<PendingMessage[]>([]);
const [selectedUser, setSelectedUser] = React.useState<{ id: string; name: string } | null>(null);
const [isModalOpen, setIsModalOpen] = React.useState(false);
console.log('TeamPresence rendered');
console.log('appUser:', appUser);
console.log('organization:', organization);
console.log('activeUsers:', activeUsers);
console.log('allMembers:', allMembers);
// Fetch all organization members
React.useEffect(() => {
const fetchMembers = async () => {
console.log('Fetching members...');
try {
const response = await api.get<OrganizationMember[]>('/users');
console.log('Members fetched:', response.data);
setAllMembers(response.data);
} catch (error) {
console.error('Error fetching members:', error);
}
};
if (organization?.id) {
console.log('Organization ID exists, fetching members');
fetchMembers();
// Refresh every minute
const interval = setInterval(fetchMembers, 60000);
return () => clearInterval(interval);
} else {
console.log('No organization ID, skipping fetch');
// Fetch all members
const fetchMembers = useCallback(async () => {
try {
const response = await api.get<OrganizationMember[]>('/users');
setAllMembers(response.data);
} catch (error) {
console.error('Error fetching members:', error);
}
}, [organization?.id]);
}, []);
// Fetch pending messages
React.useEffect(() => {
const fetchPendingMessages = async () => {
try {
const response = await api.get<PendingMessage[]>('/messages/pending');
setPendingMessages(response.data);
} catch (error) {
console.error('Error fetching pending messages:', error);
}
};
if (organization?.id) {
fetchPendingMessages();
const interval = setInterval(fetchPendingMessages, 30000);
return () => clearInterval(interval);
const fetchPendingMessages = useCallback(async () => {
try {
const response = await api.get<PendingMessage[]>('/messages/pending');
setPendingMessages(response.data);
} catch (error) {
console.error('Error fetching pending messages:', error);
}
}, [organization?.id]);
}, []);
console.log('Rendering with allMembers.length:', allMembers.length);
React.useEffect(() => {
fetchMembers();
fetchPendingMessages();
const memberInterval = setInterval(fetchMembers, 60000);
const messageInterval = setInterval(fetchPendingMessages, 30000);
return () => {
clearInterval(memberInterval);
clearInterval(messageInterval);
};
}, [fetchMembers, fetchPendingMessages]);
if (allMembers.length === 0) {
console.log('No members, returning null');
return null;
}
// Create a Set of active user IDs for fast lookup
const activeUserIds = new Set(activeUsers.map(u => u.clerkId));
const activeUserLogtoIds = new Set(activeUsers.map(u => u.logtoId));
// Create a map of pending messages by recipient ID
const pendingMessagesByRecipient = new Map(
@@ -95,10 +76,10 @@ export const TeamPresence: React.FC = () => {
);
const handleMemberClick = (member: OrganizationMember) => {
if (member.clerkUserId === appUser?.clerkId) {
if (member.logto_id === appUser?.logtoId) {
return; // Don't allow messaging yourself
}
setSelectedUser({ id: member.clerkUserId, name: member.name });
setSelectedUser({ id: member.logto_id, name: member.name });
setIsModalOpen(true);
};
@@ -108,13 +89,7 @@ export const TeamPresence: React.FC = () => {
};
const handleMessageSent = async () => {
// Refresh pending messages
try {
const response = await api.get<PendingMessage[]>('/messages/pending');
setPendingMessages(response.data);
} catch (error) {
console.error('Error refreshing pending messages:', error);
}
await fetchPendingMessages();
};
const getExistingMessage = (member: OrganizationMember) => {
@@ -132,8 +107,8 @@ export const TeamPresence: React.FC = () => {
</div>
<div className="flex flex-wrap gap-2">
{allMembers.map((member) => {
const isOnline = activeUserIds.has(member.clerkUserId);
const isCurrentUser = member.clerkUserId === appUser?.clerkId;
const isOnline = activeUserLogtoIds.has(member.logto_id);
const isCurrentUser = member.logto_id === appUser?.logtoId;
const hasPendingMessage = pendingMessagesByRecipient.has(member.email);
return (
@@ -201,10 +176,13 @@ export const TeamPresence: React.FC = () => {
onClose={handleModalClose}
recipientId={selectedUser.id}
recipientName={selectedUser.name}
existingMessage={getExistingMessage(allMembers.find(m => m.clerkUserId === selectedUser.id)!)}
existingMessage={getExistingMessage(allMembers.find(m => m.logto_id === selectedUser.id)!)}
onMessageSent={handleMessageSent}
/>
)}
</>
);
};
// No the component body I used useCallback so I need to import it
import { useCallback } from 'react';

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react';
import { Download, Upload, AlertTriangle, CheckCircle, Database, FileJson, Info, RefreshCw } from 'lucide-react';
import api from '../../services/api';
import { useOrganization } from '@clerk/clerk-react';
import { useAuth } from '../../context/useAuth';
interface BackupStats {
projects: number;
@@ -28,7 +28,7 @@ interface BackupValidation {
}
export const BackupRestore: React.FC = () => {
const { organization } = useOrganization();
const { appUser } = useAuth();
const [isExporting, setIsExporting] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [validationResult, setValidationResult] = useState<BackupValidation | null>(null);
@@ -36,8 +36,6 @@ export const BackupRestore: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = async () => {
if (!organization) return;
setIsExporting(true);
try {
const response = await api.get('/backup/export', {
@@ -52,7 +50,8 @@ export const BackupRestore: React.FC = () => {
// Nome do arquivo com timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
link.download = `backup_${organization.name}_${timestamp}.json`;
const orgName = appUser?.name || 'GPI';
link.download = `backup_${orgName}_${timestamp}.json`;
document.body.appendChild(link);
link.click();
@@ -248,18 +247,18 @@ export const BackupRestore: React.FC = () => {
</label>
{validationResult && (
<div className={`p-4 rounded-xl border ${validationResult.valid && validationResult.isValidOrganization
<div className={`p-4 rounded-xl border ${validationResult.valid
? 'bg-green-500/10 border-green-500/30'
: 'bg-red-500/10 border-red-500/30'
}`}>
<div className="flex items-start gap-3">
{validationResult.valid && validationResult.isValidOrganization ? (
{validationResult.valid ? (
<CheckCircle size={20} className="text-green-400 flex-shrink-0 mt-0.5" />
) : (
<AlertTriangle size={20} className="text-red-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1 space-y-2">
<p className={`text-sm font-bold ${validationResult.valid && validationResult.isValidOrganization
<p className={`text-sm font-bold ${validationResult.valid
? 'text-green-400'
: 'text-red-400'
}`}>
@@ -289,7 +288,7 @@ export const BackupRestore: React.FC = () => {
<button
onClick={handleImport}
disabled={!validationResult?.valid || !validationResult?.isValidOrganization || isImporting}
disabled={!validationResult?.valid || isImporting}
className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-red-500 hover:bg-red-600 text-white rounded-xl font-bold transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-red-500/20"
>
{isImporting ? (

View File

@@ -1,14 +1,14 @@
import React, { useEffect, useState } from 'react';
import { useOrganization } from '@clerk/clerk-react';
import React, { useEffect, useState, useCallback } from 'react';
import { Plus, Pencil, Trash2, Box, RefreshCw } from 'lucide-react';
import { Button } from '../Button';
import { Modal } from '../Modal';
import { Input } from '../Input';
import * as geometryService from '../../services/geometryTypeService';
import type { GeometryType } from '../../types';
import { useAuth } from '../../context/useAuth';
export const GeometrySettings: React.FC = () => {
const { organization } = useOrganization();
const { isSignedIn } = useAuth();
const [types, setTypes] = useState<GeometryType[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -20,13 +20,7 @@ export const GeometrySettings: React.FC = () => {
efficiencyLoss: '20'
});
useEffect(() => {
if (organization?.id) {
fetchTypes();
}
}, [organization?.id]);
const fetchTypes = async () => {
const fetchTypes = useCallback(async () => {
setLoading(true);
try {
const response = await geometryService.getAllTypes();
@@ -36,7 +30,13 @@ export const GeometrySettings: React.FC = () => {
} finally {
setLoading(false);
}
};
}, []);
useEffect(() => {
if (isSignedIn) {
fetchTypes();
}
}, [isSignedIn, fetchTypes]);
const handleOpenModal = (item?: GeometryType) => {
if (item) {

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useLogto } from '@logto/react';
import type { AppUser } from '../types';
import { AuthContext } from './AuthContextType';
import { getToken, getUser, setUser, login as logtoLogin } from '../main';
import { setUser } from '../main';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -10,41 +11,53 @@ interface AuthProviderProps {
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const { isAuthenticated, getAccessToken, fetchUserInfo, isLoading: isLogtoLoading } = useLogto();
const [appUser, setAppUser] = useState<AppUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isAppLoading, setIsAppLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isSignedIn, setIsSignedIn] = useState(false);
useEffect(() => {
const token = getToken();
const user = getUser();
if (token && user) {
setAppUser(user as AppUser);
setIsSignedIn(true);
}
setIsLoading(false);
}, []);
const syncUser = useCallback(async () => {
const token = getToken();
if (!token) {
if (!isAuthenticated) {
setAppUser(null);
setIsSignedIn(false);
setIsLoading(false);
setIsAppLoading(false);
return;
}
try {
setIsLoading(true);
setIsAppLoading(true);
setError(null);
const token = await getAccessToken();
if (!token) throw new Error('Token não disponível');
// Busca dados básicos do Logto se necessário
const logtoUserInfo = await fetchUserInfo();
const response = await fetch(`${API_URL}/users/me`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.status === 404 && logtoUserInfo) {
// Usuário não existe no banco (provavelmente redirecionamento pós-login)
// Vamos tentar sincronizar/provisionar
const syncResp = await fetch(`${API_URL}/users/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: logtoUserInfo.email,
name: logtoUserInfo.name || logtoUserInfo.username || 'Usuário Logto',
logto_id: logtoUserInfo.sub
})
});
if (!syncResp.ok) throw new Error('Falha ao sincronizar usuário');
// Tenta buscar novamente após o sync
return syncUser();
}
if (!response.ok) {
throw new Error('Falha ao carregar usuário');
}
@@ -60,16 +73,20 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
setUser(token, user);
setAppUser(user);
setIsSignedIn(true);
} catch (err) {
console.error('Error loading user:', err);
setError('Erro ao carregar dados do usuário');
setAppUser(null);
setIsSignedIn(false);
} finally {
setIsLoading(false);
setIsAppLoading(false);
}
}, []);
}, [isAuthenticated, getAccessToken, fetchUserInfo]);
useEffect(() => {
if (!isLogtoLoading) {
syncUser();
}
}, [isLogtoLoading, syncUser]);
const refetchUser = useCallback(async () => {
await syncUser();
@@ -84,21 +101,23 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const isGuest = useCallback(() => appUser?.role === 'guest' && !isDeveloper(), [appUser, isDeveloper]);
const canEdit = useCallback(() => (appUser?.role !== 'guest' && appUser?.role !== undefined) || isDeveloper(), [appUser, isDeveloper]);
const isLoading = isLogtoLoading || isAppLoading;
const value = useMemo(() => ({
appUser,
isLoading,
isSignedIn: isAuthenticated,
error,
isAdmin,
isUser,
isGuest,
isDeveloper,
canEdit,
refetchUser,
}), [appUser, isLoading, isAuthenticated, error, isAdmin, isUser, isGuest, isDeveloper, canEdit, refetchUser]);
return (
<AuthContext.Provider
value={{
appUser,
isLoading,
isSignedIn,
error,
isAdmin,
isUser,
isGuest,
isDeveloper,
canEdit,
refetchUser,
}}
>
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);

View File

@@ -1,16 +1,16 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '@clerk/clerk-react';
import { useAuth } from '../context/useAuth';
import api from '../services/api';
import type { INotification } from '../types';
import { NotificationContext } from './NotificationContextState';
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { orgId, isSignedIn } = useAuth();
const { isSignedIn } = useAuth();
const [notifications, setNotifications] = useState<INotification[]>([]);
const [loading, setLoading] = useState(false);
const fetchNotifications = useCallback(async () => {
if (!orgId || !isSignedIn) return;
if (!isSignedIn) return;
try {
if (notifications.length === 0) setLoading(true);
@@ -21,7 +21,7 @@ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({
} finally {
setLoading(false);
}
}, [orgId, isSignedIn, notifications.length]);
}, [isSignedIn, notifications.length]);
const markAsRead = async (id: string) => {
try {
@@ -70,7 +70,7 @@ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({
// Polling effect
useEffect(() => {
if (isSignedIn && orgId) {
if (isSignedIn) {
fetchNotifications(); // Initial fetch
const interval = setInterval(() => {
@@ -81,7 +81,7 @@ export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({
} else {
setNotifications([]);
}
}, [isSignedIn, orgId, fetchNotifications]);
}, [isSignedIn, fetchNotifications]);
const unreadCount = notifications.filter(n => !n.isRead).length;

View File

@@ -3,10 +3,10 @@ import api from '../services/api';
import { useAuth } from '../context/useAuth';
export interface ActiveUser {
_id: string;
id: string;
name: string;
email: string;
clerkId: string;
logtoId: string;
lastSeenAt: string;
}

View File

@@ -1,43 +1,18 @@
import { createRoot } from 'react-dom/client'
import { LogtoProvider, type LogtoConfig } from '@logto/react';
import './index.css'
import App from './App.tsx'
const LOGTO_URL = import.meta.env.VITE_LOGTO_URL || 'https://logto-admin-bzlued1boxl3t8ewsyn99an9.187.77.227.172.sslip.io';
const APP_ID = import.meta.env.VITE_LOGTO_APP_ID || 'gpi-app-001';
const redirectUrl = `${window.location.origin}/auth/callback`;
function generateRandomString(length: number) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function storeState(state: string) {
sessionStorage.setItem('logto_oauth_state', state);
}
export function login() {
const state = generateRandomString(21);
storeState(state);
const params = new URLSearchParams({
client_id: APP_ID,
redirect_uri: redirectUrl,
response_type: 'code',
scope: 'openid profile email',
state: state
});
window.location.href = `${LOGTO_URL}/oidc/auth?${params.toString()}`;
}
export function logout() {
sessionStorage.removeItem('logto_token');
sessionStorage.removeItem('logto_user');
window.location.href = '/';
}
const config: LogtoConfig = {
endpoint: LOGTO_URL,
appId: APP_ID,
scopes: ['openid', 'profile', 'email'],
};
// Mantenha estas para compatibilidade temporária se necessário
export function getToken() {
return sessionStorage.getItem('logto_token');
}
@@ -53,5 +28,7 @@ export function setUser(token: string, user: any) {
}
createRoot(document.getElementById('root')!).render(
<App />
<LogtoProvider config={config}>
<App />
</LogtoProvider>
)

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useUser, useOrganization } from '@clerk/clerk-react';
import { Shield, UserCheck, UserX, Users, Search, RefreshCw, Crown, Eye, User as UserIcon, Upload, Info, Image as ImageIcon, Box, Database } from 'lucide-react';
import { Shield, UserCheck, UserX, Users, Search, RefreshCw, Crown, Eye, User as UserIcon, Upload, Info, Image as ImageIcon, Box, Database, Terminal } from 'lucide-react';
import { clsx } from 'clsx';
import type { AppUser, UserRole } from '../types';
import { useAuth } from '../context/useAuth';
@@ -15,9 +14,7 @@ const roleLabels: Record<UserRole, { label: string; color: string; icon: React.R
};
export const AdminDashboard: React.FC = () => {
const { user } = useUser();
const { organization } = useOrganization();
const { isAdmin } = useAuth();
const { isAdmin, appUser: currentUser } = useAuth();
const [users, setUsers] = useState<AppUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
@@ -27,8 +24,6 @@ export const AdminDashboard: React.FC = () => {
const [logoLoading, setLogoLoading] = useState(false);
const fetchUsers = useCallback(async () => {
if (!user || !organization?.id) return;
try {
setIsLoading(true);
const response = await api.get('/users');
@@ -38,111 +33,15 @@ export const AdminDashboard: React.FC = () => {
} finally {
setIsLoading(false);
}
}, [user, organization?.id]);
const syncOrganizationMembers = useCallback(async () => {
if (!organization) return;
try {
setIsLoading(true);
// Fetch ALL members from Clerk (handle pagination)
console.log('Fetching members from Clerk organization:', organization.id);
let allMembers: any[] = [];
let hasMore = true;
// Fetch all pages
while (hasMore) {
const clerkMembers = await organization.getMemberships();
console.log(`Fetched members:`, clerkMembers.data.length);
if (clerkMembers.data.length === 0) {
hasMore = false;
} else {
allMembers = clerkMembers.data;
hasMore = false; // Clerk retorna todos de uma vez normalmente
}
}
console.log('Total Clerk members fetched:', allMembers.length, allMembers);
// Get current users from database
const currentUsersResponse = await api.get('/users');
const currentUsers = currentUsersResponse.data;
console.log('Current users in database:', currentUsers.length, currentUsers);
// Create a Set of Clerk user IDs for fast lookup
const clerkUserIds = new Set(
allMembers
.map(m => m.publicUserData?.userId)
.filter(id => id != null)
);
console.log('Clerk user IDs:', Array.from(clerkUserIds));
// Step 1: Add/Update users from Clerk
for (const membership of allMembers) {
const clerkUser = membership.publicUserData;
console.log('Processing membership:', membership);
console.log('Public user data:', clerkUser);
if (clerkUser) {
const syncData = {
clerkId: clerkUser.userId,
email: clerkUser.identifier || '',
name: `${clerkUser.firstName || ''} ${clerkUser.lastName || ''}`.trim() || clerkUser.identifier || 'Usuário',
organizationId: organization.id,
clerkRole: membership.role
};
console.log('Syncing user:', syncData);
try {
const response = await api.post('/users/sync', syncData);
console.log('Sync success for', clerkUser.userId, ':', response.data);
} catch (syncError) {
console.error('Error syncing member:', clerkUser.userId, syncError);
}
}
}
// Step 2: Remove users from database that don't exist in Clerk anymore
let removedCount = 0;
for (const dbUser of currentUsers) {
const clerkUserId = dbUser.clerkUserId || dbUser.clerkId;
if (!clerkUserIds.has(clerkUserId)) {
console.log(`User ${dbUser.name} (${clerkUserId}) is in DB but not in Clerk - removing...`);
try {
await api.delete(`/users/${dbUser._id}`);
console.log(`Removed user ${dbUser.name} from database`);
removedCount++;
} catch (deleteError) {
console.error(`Error removing user ${dbUser.name}:`, deleteError);
}
}
}
// Reload users after sync
console.log('Reloading users from database...');
await fetchUsers();
const message = `Sincronização concluída!\n✅ ${allMembers.length} membros atualizados\n${removedCount > 0 ? `🗑️ ${removedCount} membros removidos` : ''}`;
alert(message);
} catch (error) {
console.error('Error syncing organization members:', error);
alert('Erro ao sincronizar membros. Verifique o console para mais detalhes.');
} finally {
setIsLoading(false);
}
}, [organization, fetchUsers]);
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
if (isAdmin()) {
fetchUsers();
}
}, [isAdmin, fetchUsers]);
const handleRoleChange = async (userId: string, newRole: UserRole) => {
if (!user) return;
setActionLoading(userId);
try {
const response = await api.patch(`/users/${userId}/role`, { role: newRole });
@@ -158,8 +57,6 @@ export const AdminDashboard: React.FC = () => {
};
const handleToggleBan = async (userId: string, isBanned: boolean) => {
if (!user) return;
setActionLoading(userId);
try {
const response = await api.patch(`/users/${userId}/ban`, { isBanned });
@@ -182,31 +79,8 @@ export const AdminDashboard: React.FC = () => {
});
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !organization) return;
// Validations
const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml'];
if (!validTypes.includes(file.type)) {
alert('Por favor, selecione uma imagem PNG, JPG ou SVG.');
return;
}
if (file.size > 500 * 1024) {
alert('O arquivo deve ter no máximo 500KB.');
return;
}
setLogoLoading(true);
try {
await organization.setLogo({ file });
alert('Logo atualizado com sucesso!');
} catch (error) {
console.error('Error uploading logo:', error);
alert('Erro ao atualizar o logo.');
} finally {
setLogoLoading(false);
}
// Implement Logo Upload via Backend API if needed
alert('Funcionalidade de upload de logo em migração para o novo sistema.');
};
if (!isAdmin()) {
@@ -234,14 +108,6 @@ export const AdminDashboard: React.FC = () => {
</div>
{activeTab === 'users' && (
<div className="flex gap-2">
<button
onClick={syncOrganizationMembers}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2.5 bg-primary hover:bg-primary-dark text-white border border-primary-dark rounded-xl font-semibold transition-all disabled:opacity-50"
>
<Users size={18} className={isLoading ? 'animate-spin' : ''} />
Sincronizar Clerk
</button>
<button
onClick={fetchUsers}
disabled={isLoading}
@@ -407,7 +273,7 @@ export const AdminDashboard: React.FC = () => {
<tbody className="divide-y divide-border/40">
{filteredUsers.map((u) => {
const roleInfo = roleLabels[u.role];
const isCurrentUser = u.clerkId === user?.id;
const isCurrentUser = u.email === currentUser?.email;
const isActionDisabled = actionLoading === u.id;
return (
@@ -483,130 +349,24 @@ export const AdminDashboard: React.FC = () => {
</>
) : activeTab === 'organization' ? (
<div className="space-y-6 animate-in fade-in slide-in-from-left-4 duration-300">
{/* Organization Settings Panel */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-surface rounded-2xl p-6 border border-border/40 space-y-6">
<div className="flex items-center gap-3 pb-4 border-b border-border/20">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Upload size={20} className="text-primary" />
</div>
<div>
<h2 className="text-lg font-bold text-text-main">Identidade Visual</h2>
<p className="text-xs text-text-muted">Gerencie o logo da sua organização</p>
</div>
<div className="bg-surface rounded-2xl p-6 border border-border/40 space-y-6">
<div className="flex items-center gap-3 pb-4 border-b border-border/20">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Info size={20} className="text-primary" />
</div>
<div className="flex flex-col items-center gap-6 py-4">
{organization?.imageUrl ? (
<div className="relative group">
<div className="w-32 h-32 rounded-2xl border-2 border-primary/20 p-2 bg-white overflow-hidden shadow-xl">
<img
src={organization.imageUrl}
alt={organization.name}
className="w-full h-full object-contain"
/>
</div>
<div className="absolute -bottom-2 -right-2 bg-primary text-white p-2 rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity">
<ImageIcon size={14} />
</div>
</div>
) : (
<div className="w-32 h-32 rounded-2xl border-2 border-dashed border-border/40 flex flex-col items-center justify-center bg-surface-soft text-text-muted gap-2">
<ImageIcon size={32} className="opacity-20" />
<span className="text-[10px] font-bold uppercase tracking-widest">Sem Logo</span>
</div>
)}
<div className="w-full space-y-4">
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-border/40 rounded-2xl cursor-pointer hover:bg-surface-hover hover:border-primary/50 transition-all group">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Upload className="w-8 h-8 text-text-muted group-hover:text-primary transition-colors mb-2" />
<p className="text-sm text-text-main font-bold">Clique para alterar o logo</p>
<p className="text-xs text-text-muted">ou arraste e solte o arquivo</p>
</div>
<input
type="file"
className="hidden"
accept="image/png,image/jpeg,image/jpg,image/svg+xml"
onChange={handleLogoUpload}
disabled={logoLoading}
/>
</label>
{logoLoading && (
<div className="flex items-center justify-center gap-2 text-primary font-bold animate-pulse">
<RefreshCw size={16} className="animate-spin" />
<span>Enviando logo...</span>
</div>
)}
</div>
</div>
</div>
<div className="bg-surface rounded-2xl p-6 border border-border/40 space-y-6">
<div className="flex items-center gap-3 pb-4 border-b border-border/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center">
<Info size={20} className="text-amber-500" />
</div>
<div>
<h2 className="text-lg font-bold text-text-main">Requisitos & Dicas</h2>
<p className="text-xs text-text-muted">Regras para um visual impecável</p>
</div>
</div>
<div className="space-y-4">
<div className="p-4 bg-surface-soft rounded-xl border border-border/20">
<h3 className="text-sm font-bold text-text-main flex items-center gap-2 mb-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary"></div>
Formatos Suportados
</h3>
<p className="text-xs text-text-muted leading-relaxed">
Aceitamos arquivos nos formatos <strong>PNG, JPG ou SVG</strong>. O formato SVG é recomendado para máxima nitidez em qualquer tamanho.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/20">
<h3 className="text-sm font-bold text-text-main flex items-center gap-2 mb-2">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500"></div>
Dimensões Recomendadas
</h3>
<p className="text-xs text-text-muted leading-relaxed">
Recomendamos uma imagem quadrada de no mínimo <strong>512x512 pixels</strong>. Logos horizontais podem não aparecer corretamente em todas as áreas.
</p>
</div>
<div className="p-4 bg-surface-soft rounded-xl border border-border/20">
<h3 className="text-sm font-bold text-text-main flex items-center gap-2 mb-2">
<div className="w-1.5 h-1.5 rounded-full bg-red-500"></div>
Limite de Tamanho
</h3>
<p className="text-xs text-text-muted leading-relaxed">
O arquivo não deve ultrapassar <strong>500 KB</strong>. Arquivos maiores serão rejeitados automaticamente para garantir rapidez no carregamento.
</p>
</div>
<div>
<h2 className="text-lg font-bold text-text-main">Configurações da Organização</h2>
<p className="text-xs text-text-muted">Migrando para o novo sistema Logto</p>
</div>
</div>
<p className="text-text-muted">A gestão de identidade visual e dados da organização está sendo migrada para a API central.</p>
</div>
</div>
) : activeTab === 'settings' ? (
<GeometrySettings />
) : activeTab === 'backup' ? (
<BackupRestore />
) : (
// Lazily load or direct render StockDashboard (Need to import it)
<div className="bg-surface rounded-2xl border border-border/40 p-6">
<div className="text-center py-10">
<h2 className="text-xl font-bold text-text-main">Gestão de Estoque</h2>
<p className="text-text-muted mt-2">Acesse a nova página dedicada ao controle de estoque.</p>
<a
href="/stock"
className="mt-6 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
Ir para Estoque
</a>
</div>
</div>
)}
) : null}
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { useHandleSignInCallback } from '@logto/react';
import { useNavigate } from 'react-router-dom';
export const Callback = () => {
const navigate = useNavigate();
const { isLoading } = useHandleSignInCallback(() => {
// Redireciona para a home após o login bem-sucedido
navigate('/');
});
if (isLoading) {
return (
<div className="min-h-screen w-full flex items-center justify-center bg-surface-soft">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-text-muted font-medium">Finalizando autenticação...</p>
</div>
</div>
);
}
return null;
};

View File

@@ -1,9 +1,13 @@
import { Hammer } from "lucide-react";
import { login as logtoLogin } from "../main";
import { useLogto } from "@logto/react";
const CALLBACK_URL = import.meta.env.VITE_LOGTO_CALLBACK_URL || `${window.location.origin}/callback`;
export const Login = () => {
const { signIn } = useLogto();
const handleLogin = () => {
logtoLogin();
signIn(CALLBACK_URL);
};
return (
@@ -32,7 +36,7 @@ export const Login = () => {
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continuar com Google
</button>

View File

@@ -1,184 +1,15 @@
import React, { useEffect, useState } from 'react';
import { useUser, useOrganizationList, useOrganization } from '@clerk/clerk-react';
import { Building2, Users, RefreshCw, Mail } from 'lucide-react';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
/**
* @deprecated Clerk legacy component. No longer used in Logto flow.
*/
export const OrganizationSelector: React.FC = () => {
const { user } = useUser();
const { setActive, userMemberships, userInvitations } = useOrganizationList({
userMemberships: {
infinite: true,
},
userInvitations: {
infinite: true,
}
});
const { organization } = useOrganization();
const [isAcceptingInvites, setIsAcceptingInvites] = useState(false);
const navigate = useNavigate();
console.log('OrganizationSelector rendered');
console.log('Current organization:', organization);
console.log('User memberships:', userMemberships);
console.log('User memberships data:', userMemberships.data);
console.log('User invitations:', userInvitations);
console.log('User invitations data:', userInvitations.data);
// Auto-accept pending invitations
useEffect(() => {
const acceptPendingInvitations = async () => {
if (userInvitations.data && userInvitations.data.length > 0 && !isAcceptingInvites) {
console.log('Found pending invitations, auto-accepting...');
setIsAcceptingInvites(true);
navigate('/', { replace: true });
}, [navigate]);
for (const invitation of userInvitations.data) {
try {
console.log('Accepting invitation:', invitation);
await invitation.accept();
console.log('Invitation accepted successfully');
} catch (error) {
console.error('Error accepting invitation:', error);
}
}
// Reload memberships after accepting invitations
setTimeout(() => {
window.location.reload();
}, 1000);
}
};
acceptPendingInvitations();
}, [userInvitations.data, isAcceptingInvites]);
// Auto-select if user has only one organization
useEffect(() => {
console.log('Auto-select effect running...');
if (!organization && userMemberships.data && userMemberships.data.length === 1) {
console.log('Auto-selecting single organization...');
const membership = userMemberships.data[0];
if (setActive) {
setActive({ organization: membership.organization });
}
}
}, [organization, userMemberships.data, setActive]);
const handleSelectOrganization = async (orgId: string) => {
console.log('Selecting organization:', orgId);
if (setActive) {
await setActive({ organization: orgId });
}
// The auth context will automatically sync after organization changes
};
// Loading state - check if data exists or accepting invites
if (!userMemberships.data || isAcceptingInvites) {
console.log('Loading state - no data yet or accepting invites');
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
{isAcceptingInvites ? (
<>
<Mail className="w-12 h-12 text-primary animate-bounce mx-auto mb-4" />
<p className="text-text-main font-bold mb-2">Aceitando convites pendentes...</p>
<p className="text-text-muted text-sm">Por favor aguarde</p>
</>
) : (
<>
<RefreshCw className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
<p className="text-text-muted">Carregando organizações...</p>
</>
)}
</div>
</div>
);
}
if (userMemberships.data?.length === 0) {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl border border-border/40 p-8 text-center">
<div className="w-16 h-16 rounded-2xl bg-amber-500/20 flex items-center justify-center mx-auto mb-4">
<Building2 className="w-8 h-8 text-amber-500" />
</div>
<h1 className="text-2xl font-bold text-text-main mb-2">
Nenhuma Organização
</h1>
<p className="text-text-muted mb-6">
Você ainda não faz parte de nenhuma organização. Entre em contato com o administrador para receber um convite.
</p>
<div className="text-sm text-text-muted bg-surface-soft rounded-lg p-4">
<p className="font-semibold mb-1">Conectado como:</p>
<p>{user?.primaryEmailAddress?.emailAddress}</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-2xl w-full">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-primary/20 flex items-center justify-center mx-auto mb-4">
<Building2 className="w-8 h-8 text-primary" />
</div>
<h1 className="text-3xl font-bold text-text-main mb-2">
Selecione uma Organização
</h1>
<p className="text-text-muted">
Escolha qual organização você deseja acessar
</p>
</div>
<div className="grid gap-4">
{userMemberships.data?.map((membership) => (
<button
key={membership.organization.id}
onClick={() => handleSelectOrganization(membership.organization.id)}
className="w-full bg-surface hover:bg-surface-hover border border-border/40 rounded-2xl p-6 text-left transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/10 group"
>
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-primary/20 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/30 transition-colors">
{membership.organization.imageUrl ? (
<img
src={membership.organization.imageUrl}
alt={membership.organization.name}
className="w-12 h-12 rounded-lg object-contain"
/>
) : (
<Building2 className="w-7 h-7 text-primary" />
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-text-main group-hover:text-primary transition-colors">
{membership.organization.name}
</h3>
<div className="flex items-center gap-2 mt-1">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-primary/20 text-primary text-xs font-semibold">
{membership.role === 'org:admin' ? 'Administrador' :
membership.role === 'org:member' ? 'Membro' : 'Convidado'}
</span>
<span className="flex items-center gap-1 text-xs text-text-muted">
<Users className="w-3 h-3" />
{membership.organization.membersCount || 0} membros
</span>
</div>
</div>
<div className="text-primary opacity-0 group-hover:opacity-100 transition-opacity">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
))}
</div>
<div className="mt-6 text-center">
<p className="text-sm text-text-muted">
Conectado como <span className="font-semibold">{user?.primaryEmailAddress?.emailAddress}</span>
</p>
</div>
</div>
</div>
);
return null;
};

View File

@@ -8,7 +8,6 @@ import { useAuth } from '../context/useAuth';
import { useToast } from '../hooks/useToast';
import { MobileList } from '../components/MobileList';
import { CreateProjectModal } from '../components/modals/CreateProjectModal';
import { useOrganization } from '@clerk/clerk-react';
import { useSystemSettings } from '../context/SystemSettingsContext';
import { Modal } from '../components/Modal';
import { ConfirmModal } from '../components/ConfirmModal';
@@ -50,14 +49,12 @@ export const ProjectList: React.FC = () => {
const [isPrintingGeneral, setIsPrintingGeneral] = useState(false);
const navigate = useNavigate();
const { appUser } = useAuth();
const { appUser, isAdmin: checkIsAdmin } = useAuth();
const { showToast } = useToast();
const { organization } = useOrganization();
const { settings } = useSystemSettings();
const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
const isAdmin = appUser?.email === 'admtracksteel@gmail.com' || appUser?.role === 'admin';
const logoUrl = settings?.appLogoUrl;
const isAdmin = checkIsAdmin();
const fetchProjects = useCallback(async () => {
try {

View File

@@ -7,13 +7,10 @@ import { StockHistoryModal } from '../components/modals/StockHistoryModal';
import { StockInventoryReport } from '../components/reports/StockInventoryReport';
import { DiluentListModal } from '../components/modals/DiluentListModal';
import { useAuth } from '../context/useAuth';
import { useOrganization } from '@clerk/clerk-react';
import { useSystemSettings } from '../context/SystemSettingsContext';
export const StockDashboard: React.FC = () => {
// ... rest of component
const { isAdmin } = useAuth();
const { organization } = useOrganization();
const { settings } = useSystemSettings();
const [items, setItems] = useState<StockItem[]>([]);
@@ -28,7 +25,7 @@ export const StockDashboard: React.FC = () => {
const [activeTab, setActiveTab] = useState<'PAINT' | 'THINNER'>('PAINT');
const [showDiluentModal, setShowDiluentModal] = useState(false);
const logoUrl = settings?.appLogoUrl || organization?.imageUrl;
const logoUrl = settings?.appLogoUrl;
const fetchItems = async () => {
setLoading(true);
@@ -109,15 +106,15 @@ export const StockDashboard: React.FC = () => {
const filteredItems = items.filter(item => {
const searchLower = searchTerm.toLowerCase();
// Handle type checking carefully. If type is missing, assume PAINT.
const type = (typeof item.dataSheetId === 'object' ? item.dataSheetId.type : '') || 'PAINT';
const type = (typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).type : '') || 'PAINT';
const isThinner = type === 'THINNER' || type === 'DILUENTE';
// Tab Filter
if (activeTab === 'THINNER' && !isThinner) return false;
if (activeTab === 'PAINT' && isThinner) return false;
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : '';
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
const productName = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).name : '';
const manufacturer = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).manufacturer : '';
return (
item.rrNumber.toLowerCase().includes(searchLower) ||
@@ -131,9 +128,9 @@ export const StockDashboard: React.FC = () => {
const groups = new Map<string, { items: StockItem[], totalQty: number, minStock: number, unit: string, productName: string, color: string, manufacturer: string }>();
filteredItems.forEach(item => {
const productName = typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Unknown';
const manufacturer = typeof item.dataSheetId === 'object' ? item.dataSheetId.manufacturer : '';
const key = `${item.dataSheetId._id || item.dataSheetId}-${item.color}`;
const productName = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).name : 'Unknown';
const manufacturer = typeof item.dataSheetId === 'object' ? (item.dataSheetId as any).manufacturer : '';
const key = `${(item.dataSheetId as any)._id || item.dataSheetId}-${item.color}`;
if (!groups.has(key)) {
groups.set(key, {

View File

@@ -190,7 +190,8 @@ export type UserRole = 'guest' | 'user' | 'admin';
export interface AppUser {
id: string;
_id?: string;
clerkId: string;
clerkId?: string;
logtoId?: string;
email: string;
name: string;
role: UserRole;

View File

@@ -7,7 +7,7 @@ interface AuthRequest extends Request {
export const syncUser = async (req: Request, res: Response) => {
try {
const { email, name } = req.body;
const { email, name, logto_id } = req.body;
if (!email || !name) {
return res.status(400).json({ error: 'email e name são obrigatórios.' });
@@ -21,6 +21,7 @@ export const syncUser = async (req: Request, res: Response) => {
.insert({
email,
name,
logto_id,
role: 'guest'
})
.select()

View File

@@ -30,10 +30,28 @@ export async function authenticateRequest(req: any): Promise<AppUser | null> {
const logtoId = payload.sub as string;
const user = await findOneGpi('users', { logto_id: logtoId });
// Primeiro tenta pelo Logto ID
let user = await findOneGpi('users', { logto_id: logtoId });
// Se não encontrar, tenta pelo email (se houver no payload do token)
if (!user && payload.email) {
const email = payload.email as string;
user = await findOneGpi('users', { email });
if (user) {
// Vincula o Logto ID ao usuário existente
await supabase
.from('users')
.update({ logto_id: logtoId })
.eq('id', user.id);
user.logto_id = logtoId;
console.log(`[Auth] Usuário ${email} vinculado ao Logto ID ${logtoId}`);
}
}
if (!user) {
console.log(`[Auth] Usuário Logto ${logtoId} não encontrado no GPI`);
console.log(`[Auth] Usuário Logto ${logtoId} sem registro no GPI`);
return null;
}