Migracao Logto + Supabase - backend e frontend atualizados para nova autenticação
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
)
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user