Migracao Logto + Supabase - backend e frontend atualizados para nova autenticação

This commit is contained in:
2026-03-30 20:50:10 +00:00
parent 9d3958b82b
commit f89d5571f4
22 changed files with 1266 additions and 1047 deletions

View File

@@ -1,5 +1,4 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { SignedIn, SignedOut, useOrganization } from '@clerk/clerk-react';
import { AuthProvider } from './context/AuthContext';
import { useAuth } from './context/useAuth';
import { SystemSettingsProvider } from './context/SystemSettingsContext';
@@ -19,7 +18,6 @@ import { CalculatorDashboard } from './pages/CalculatorDashboard';
import { StockDashboard } from './pages/StockDashboard';
import { GuestDashboard } from './pages/GuestDashboard';
import { Login } from './pages/Login';
import { OrganizationSelector } from './pages/OrganizationSelector';
import InstrumentList from './pages/InstrumentList';
const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -32,18 +30,6 @@ const DeveloperRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =
};
const AppContent: React.FC = () => {
const { organization } = useOrganization();
console.log('AppContent rendered');
console.log('Current organization:', organization);
// If user is signed in but has no organization, show org selector
if (!organization) {
console.log('No organization - showing OrganizationSelector');
return <OrganizationSelector />;
}
console.log('Organization exists - showing main app');
return (
<ToastProvider>
<AuthProvider>
@@ -109,12 +95,7 @@ const AppContent: React.FC = () => {
function App() {
return (
<Router>
<SignedOut>
<Login />
</SignedOut>
<SignedIn>
<AppContent />
</SignedIn>
<AppContent />
</Router>
);
}

View File

@@ -1,129 +1,83 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useUser, useOrganization } from '@clerk/clerk-react';
import React, { useState, useEffect, useCallback } from 'react';
import type { AppUser } from '../types';
import { AuthContext } from './AuthContextType';
import { setApiClerkUserId, setApiOrganizationId, getBaseUrl } from '../services/api';
import { getToken, getUser, setUser, login as logtoLogin } from '../main';
const API_URL = getBaseUrl();
const API_URL = import.meta.env.VITE_API_URL || '/api';
interface AuthProviderProps {
children: React.ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const { user, isLoaded } = useUser();
const { organization, membership } = useOrganization();
const [appUser, setAppUser] = useState<AppUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const lastContextRef = useRef<{ clerkId?: string, orgId?: string | null }>({});
const [isSignedIn, setIsSignedIn] = useState(false);
// Set the clerk user ID and organization ID for the API interceptor
useEffect(() => {
setApiClerkUserId(user?.id || null);
setApiOrganizationId(organization?.id || null);
}, [user?.id, organization?.id]);
const token = getToken();
const user = getUser();
if (token && user) {
setAppUser(user as AppUser);
setIsSignedIn(true);
}
setIsLoading(false);
}, []);
const syncUser = useCallback(async () => {
if (!user) {
const token = getToken();
if (!token) {
setAppUser(null);
setIsSignedIn(false);
setIsLoading(false);
return;
}
try {
// Only set loading if the context has changed (new user or new organization)
// This prevents unmounting/remounting components on window focus revalidations
const isSameContext =
lastContextRef.current.clerkId === user.id &&
lastContextRef.current.orgId === (organization?.id || null);
if (!isSameContext) {
setIsLoading(true);
}
setIsLoading(true);
setError(null);
// Sync user with backend, including organization context
const response = await fetch(`${API_URL}/users/sync`, {
method: 'POST',
const response = await fetch(`${API_URL}/users/me`, {
headers: {
'Content-Type': 'application/json',
'x-clerk-user-id': user.id,
...(organization?.id && { 'x-organization-id': organization.id }),
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
clerkId: user.id,
email: user.primaryEmailAddress?.emailAddress || '',
name: user.fullName || user.firstName || 'Usuário',
organizationId: organization?.id || null,
clerkRole: membership?.role || null, // org:admin, org:member, etc.
}),
});
if (!response.ok) {
const data = await response.json();
if (response.status === 403 && data.error?.includes('bloqueada')) {
setError('Sua conta foi bloqueada. Entre em contato com o administrador.');
setAppUser(null);
return;
}
throw new Error('Falha ao sincronizar usuário');
throw new Error('Falha ao carregar usuário');
}
const syncedUser = await response.json();
// Use organizationRole if available (per-org role), otherwise fall back to global role
const effectiveRole = syncedUser.organizationRole || syncedUser.role || 'guest';
setAppUser({
...syncedUser,
id: syncedUser._id || syncedUser.id,
role: effectiveRole, // Override with organization-specific role
});
// Update last context ref
lastContextRef.current = { clerkId: user.id, orgId: organization?.id || null };
const userData = await response.json();
const effectiveRole = userData.role || 'guest';
const user = {
...userData,
id: userData._id || userData.id,
role: effectiveRole,
};
setUser(token, user);
setAppUser(user);
setIsSignedIn(true);
} catch (err) {
console.error('Error syncing user:', err);
console.error('Error loading user:', err);
setError('Erro ao carregar dados do usuário');
setAppUser(null);
setIsSignedIn(false);
} finally {
setIsLoading(false);
}
}, [user, organization?.id, membership?.role]);
}, []);
const refetchUser = useCallback(async () => {
if (!user) return;
try {
const response = await fetch(`${API_URL}/users/me`, {
headers: {
'x-clerk-user-id': user.id,
...(organization?.id && { 'x-organization-id': organization.id }),
},
});
if (response.ok) {
const userData = await response.json();
const effectiveRole = userData.organizationRole || userData.role || 'guest';
setAppUser({
...userData,
id: userData._id || userData.id,
role: effectiveRole,
});
}
} catch (err) {
console.error('Error refetching user:', err);
}
}, [user, organization?.id]);
// Re-sync when organization changes
useEffect(() => {
if (isLoaded && user) {
syncUser();
}
}, [isLoaded, user, organization?.id, syncUser]);
await syncUser();
}, [syncUser]);
const isDeveloper = useCallback(() => {
return user?.primaryEmailAddress?.emailAddress === 'admtracksteel@gmail.com';
}, [user]);
return appUser?.email === 'admtracksteel@gmail.com';
}, [appUser]);
const isAdmin = useCallback(() => appUser?.role === 'admin' || isDeveloper(), [appUser, isDeveloper]);
const isUser = useCallback(() => appUser?.role === 'user' || isAdmin(), [appUser, isAdmin]);
@@ -135,7 +89,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
value={{
appUser,
isLoading,
isSignedIn: !!user,
isSignedIn,
error,
isAdmin,
isUser,

View File

@@ -1,47 +1,57 @@
import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/clerk-react'
import { ptBR } from '@clerk/localizations'
import './index.css'
import App from './App.tsx'
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY
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';
if (!PUBLISHABLE_KEY) {
throw new Error("Missing Publishable Key")
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 = '/';
}
export function getToken() {
return sessionStorage.getItem('logto_token');
}
export function getUser() {
const user = sessionStorage.getItem('logto_user');
return user ? JSON.parse(user) : null;
}
export function setUser(token: string, user: any) {
sessionStorage.setItem('logto_token', token);
sessionStorage.setItem('logto_user', JSON.stringify(user));
}
createRoot(document.getElementById('root')!).render(
<ClerkProvider
publishableKey={PUBLISHABLE_KEY}
afterSignOutUrl="/"
localization={ptBR}
appearance={{
variables: {
colorPrimary: '#fb923c', // Cor primária do GPI (Laranja)
colorBackground: '#ffffff',
colorText: '#1c1917',
colorTextSecondary: '#57534e',
borderRadius: '0.75rem',
},
elements: {
card: "shadow-none border-0 bg-transparent", // Deixamos o container da página controlar o card
navbar: "hidden",
headerTitle: "text-2xl font-bold tracking-tight",
headerSubtitle: "text-text-muted font-medium",
formButtonPrimary: "bg-primary hover:bg-primary/90 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-primary/20",
socialButtonsBlockButton: "bg-white hover:bg-surface-hover border-border/40 text-text-main font-semibold transition-all duration-300 rounded-xl",
footerActionLink: "text-primary hover:text-primary/80 font-bold",
formFieldInput: "bg-surface-soft border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl",
organizationSwitcherTrigger: "hover:bg-surface-hover transition-colors rounded-xl",
organizationPreviewMainIdentifier: "font-bold",
// Personalização específica para a lista de organizações que aparece na imagem
organizationListPreview: "hover:bg-surface-soft rounded-xl transition-all p-3",
organizationListCreateOrganizationButton: "text-primary font-bold hover:text-primary/80",
}
}}
>
<App />
</ClerkProvider>,
<App />
)

View File

@@ -1,7 +1,11 @@
import { SignIn } from "@clerk/clerk-react";
import { Hammer } from "lucide-react";
import { login as logtoLogin } from "../main";
export const Login = () => {
const handleLogin = () => {
logtoLogin();
};
return (
<div className="min-h-screen w-full flex items-center justify-center bg-surface-soft relative overflow-hidden">
{/* Background decorative elements */}
@@ -18,13 +22,20 @@ export const Login = () => {
<p className="text-text-muted text-sm font-medium uppercase tracking-widest">Gestão de Pintura Industrial</p>
</div>
{/* Clerk SignIn Component - Customizado via Tema Global no main.tsx */}
<div className="w-full bg-surface rounded-[2rem] border border-border/40 shadow-2xl shadow-primary/5 p-4 animate-in slide-in-from-bottom-8 duration-1000">
<SignIn
afterSignInUrl="/"
afterSignUpUrl="/"
forceRedirectUrl="/"
/>
{/* Login Button - Logto */}
<div className="w-full bg-surface rounded-[2rem] border border-border/40 shadow-2xl shadow-primary/5 p-8 animate-in slide-in-from-bottom-8 duration-1000">
<button
onClick={handleLogin}
className="w-full flex items-center justify-center gap-3 px-6 py-4 bg-primary hover:bg-primary/90 text-white font-bold rounded-xl transition-all shadow-lg shadow-primary/20"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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"/>
</svg>
Continuar com Google
</button>
</div>
<div className="mt-8 flex items-center gap-2 text-text-muted/60 text-xs font-medium">

View File

@@ -1,9 +1,9 @@
// API service configuration v1.4 - with auth and error interceptors
// API service configuration v2.0 - Logto Auth
import axios from 'axios';
import { triggerGuestWarning } from '../utils/toastHandler';
import { getToken } from '../main';
export const getBaseUrl = () => {
// Priority: Env var -> Relative path (handled by Vite proxy in dev, or Nginx/Vercel in prod)
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL;
}
@@ -17,41 +17,26 @@ const api = axios.create({
},
});
// Store the current user's clerk ID and Organization ID/Name
let currentClerkUserId: string | null = null;
let currentOrgId: string | null = null;
let currentOrgName: string | null = null;
// Function to set the clerk user ID (called from AuthContext)
export const setApiClerkUserId = (clerkId: string | null) => {
currentClerkUserId = clerkId;
};
// Function to set the organization ID and Name (called from Layout/Context)
export const setApiOrgData = (orgId: string | null, orgName: string | null = null) => {
currentOrgId = orgId;
currentOrgName = orgName;
};
// Legacy support
export const setApiOrgId = (orgId: string | null) => {
setApiOrgData(orgId, null);
};
export const setApiOrganizationId = setApiOrgData;
// Alias for consistency
export const setApiOrganizationId = setApiOrgId;
// Request interceptor to add clerk user ID and Org ID headers
api.interceptors.request.use(
(config) => {
if (currentClerkUserId) {
config.headers['x-clerk-user-id'] = currentClerkUserId;
const token = getToken();
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
if (currentOrgId) {
config.headers['x-organization-id'] = currentOrgId;
}
if (currentOrgName) {
// Encode to handle special characters
config.headers['x-organization-name'] = encodeURIComponent(currentOrgName);
}
return config;
@@ -61,12 +46,10 @@ api.interceptors.request.use(
}
);
// Response interceptor to handle 403 errors (guest access denied)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403) {
// Check if it's a guest permission error
const errorMessage = error.response?.data?.error || '';
if (errorMessage.includes('Convidados') || errorMessage.includes('guest') || errorMessage.includes('permissão')) {
triggerGuestWarning();