feat: migrate authentication to Logto and clear PWA cache

This commit is contained in:
2026-03-19 15:51:09 +00:00
parent 0e858b87c2
commit 778d6d18ee
7 changed files with 217 additions and 98 deletions

138
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "gpi-app",
"version": "1.0.0",
"dependencies": {
"@logto/node": "^3.1.9",
"@logto/react": "^4.0.13",
"@tailwindcss/postcss": "^4.1.18",
"@types/mongoose": "^5.11.96",
"@types/uuid": "^10.0.0",
@@ -2337,6 +2339,63 @@
"sisteransi": "^1.0.5"
}
},
"node_modules/@logto/browser": {
"version": "3.0.12",
"resolved": "https://registry.npmjs.org/@logto/browser/-/browser-3.0.12.tgz",
"integrity": "sha512-Ec45IExLYS64bF22wS7dZuWgOMmC2w3FZmWWnVCv2fX2vKQVs0wiI+FE/PlNhEvi8up4AW0zHO4NTGwF7ipFsQ==",
"license": "MIT",
"dependencies": {
"@logto/client": "^3.1.7",
"@silverhand/essentials": "^2.9.3",
"js-base64": "^3.7.4"
}
},
"node_modules/@logto/client": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@logto/client/-/client-3.1.7.tgz",
"integrity": "sha512-t/5wXMhiXtmbmP6Cmcl4uMsYetq21vSZuYZztPHXv6QX0dx7lSKBvYi/65ERoS+fmNmtV2/i4Ojf1U41o0TLPQ==",
"license": "MIT",
"dependencies": {
"@logto/js": "^6.1.1",
"@silverhand/essentials": "^2.9.3",
"camelcase-keys": "^9.1.3",
"jose": "^5.2.2"
}
},
"node_modules/@logto/js": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@logto/js/-/js-6.1.1.tgz",
"integrity": "sha512-G0lRS7VyOXdB06WYajEh9Kq2E3m11JshiKIKLj6LRPI1qZ06JYQ+Jsej3K60/4OIZMSzUas4FVnY+ORrhDdktA==",
"license": "MIT",
"dependencies": {
"@silverhand/essentials": "^2.9.3",
"camelcase-keys": "^9.1.3"
}
},
"node_modules/@logto/node": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/@logto/node/-/node-3.1.9.tgz",
"integrity": "sha512-ApbUf3tWZYtMt6KJZo+bfms+5WcR7Cuz3dE9mVoPuo/joA08aU18fSe3L7VXqFu0nUJnG8BZi7ngoqCJEkQTig==",
"license": "MIT",
"dependencies": {
"@logto/client": "^3.1.7",
"@silverhand/essentials": "^2.9.3",
"js-base64": "^3.7.4"
}
},
"node_modules/@logto/react": {
"version": "4.0.13",
"resolved": "https://registry.npmjs.org/@logto/react/-/react-4.0.13.tgz",
"integrity": "sha512-CU4rjJmueY0CQoJZq7BDZt/9sQYpxKDwVBrGHR55ljl4zPFF2URJPixqCtEEfWq5/pFk7MEnIOePOYbj7BWKfQ==",
"license": "MIT",
"dependencies": {
"@logto/browser": "^3.0.12",
"@silverhand/essentials": "^2.9.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.5",
"license": "MIT",
@@ -2798,6 +2857,16 @@
"win32"
]
},
"node_modules/@silverhand/essentials": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/@silverhand/essentials/-/essentials-2.9.3.tgz",
"integrity": "sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==",
"license": "MIT",
"engines": {
"node": ">=18.12.0",
"pnpm": "^10.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"license": "MIT"
@@ -4127,6 +4196,36 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
"license": "MIT",
"dependencies": {
"camelcase": "^8.0.0",
"map-obj": "5.0.0",
"quick-lru": "^6.1.1",
"type-fest": "^4.3.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"dev": true,
@@ -6473,6 +6572,21 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"license": "MIT"
@@ -7018,6 +7132,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/map-obj": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
@@ -7822,6 +7948,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",

View File

@@ -13,6 +13,8 @@
"start": "node dist/server/index.js"
},
"dependencies": {
"@logto/node": "^3.1.9",
"@logto/react": "^4.0.13",
"@tailwindcss/postcss": "^4.1.18",
"@types/mongoose": "^5.11.96",
"@types/uuid": "^10.0.0",

View File

@@ -1,5 +1,5 @@
// App v1.5 - JWT Migration Final Check
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { Callback } from './pages/Callback';
import { AuthProvider, useAuth } from './context/AuthContext';
import { SystemSettingsProvider } from './context/SystemSettingsContext';
import { NotificationProvider } from './contexts/NotificationContext';
@@ -103,11 +103,10 @@ const MainRouter: React.FC = () => {
return (
<Router>
{!appUser ? (
<Login />
) : (
<AppContent />
)}
<Routes>
<Route path="/callback" element={<Callback />} />
<Route path="/*" element={!appUser ? <Login /> : <AppContent />} />
</Routes>
</Router>
);
};

View File

@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { AppUser } from '../types';
import { setApiToken, setApiOrganizationId, getBaseUrl } from '../services/api';
import { useLogto } from '@logto/react';
const API_URL = getBaseUrl();
@@ -23,40 +24,55 @@ export interface AuthContextType {
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, getAccessToken, signOut, isLoading: isLogtoLoading } = useLogto();
const [appUser, setAppUser] = useState<AppUser | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('jwt_token'));
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchTokenAndUser = async () => {
if (isAuthenticated) {
try {
const accessToken = await getAccessToken();
if (accessToken) {
setToken(accessToken);
setApiToken(accessToken);
}
} catch (err) {
console.error(err);
}
} else if (!isLogtoLoading) {
setIsLoading(false);
}
};
fetchTokenAndUser();
}, [isAuthenticated, isLogtoLoading, getAccessToken]);
// Initial load: se tem token, setar no interceptor e buscar dados do usuário
useEffect(() => {
if (token) {
setApiToken(token);
refetchUser();
} else {
setIsLoading(false);
}
}, [token]);
const login = useCallback((newToken: string, user: AppUser) => {
localStorage.setItem('jwt_token', newToken);
setToken(newToken);
setAppUser(user);
setApiToken(newToken);
// Se a organização existir, setar o header
if (user.organizationId) {
setApiOrganizationId(user.organizationId);
}
}, []);
const logout = useCallback(() => {
localStorage.removeItem('jwt_token');
setToken(null);
setAppUser(null);
setApiToken(null);
setApiOrganizationId(null);
}, []);
signOut(window.location.origin);
}, [signOut]);
const refetchUser = useCallback(async () => {
if (!token) return;

View File

@@ -1,7 +1,26 @@
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { LogtoProvider } from '@logto/react';
import type { LogtoConfig } from '@logto/react';
// Require the user to define VITE_LOGTO_APP_ID in Coolify
const config: LogtoConfig = {
endpoint: 'https://logto.reifonas.cloud',
appId: import.meta.env.VITE_LOGTO_APP_ID || '', // Replace or add via Envs!
};
// Force service worker cache clearing because of persistent Clerk error
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(registrations) {
for(let registration of registrations) {
registration.unregister();
}
});
}
createRoot(document.getElementById('root')!).render(
<App />
<LogtoProvider config={config}>
<App />
</LogtoProvider>
);

View File

@@ -0,0 +1,16 @@
import { useHandleSignInCallback } from '@logto/react';
import { useNavigate } from 'react-router-dom';
export const Callback = () => {
const navigate = useNavigate();
const { isLoading } = useHandleSignInCallback(() => {
// Done, navigate to home
navigate('/');
});
return (
<div className="flex h-screen items-center justify-center">
{isLoading ? 'Redirecionando...' : 'Concluído'}
</div>
);
};

View File

@@ -1,53 +1,20 @@
import React, { useState } from "react";
import { Hammer } from "lucide-react";
import { useAuth } from "../context/useAuth";
import { getBaseUrl } from "../services/api";
const API_URL = getBaseUrl();
import { Hammer } from "lucide-react";
import { useLogto } from "@logto/react";
export const Login = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const [loading, setLoading] = useState(false);
const { signIn, isAuthenticated } = useLogto();
const { login } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg("");
setLoading(true);
try {
const response = await fetch(`${API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (!response.ok) {
setErrorMsg(data.error || "Erro ao efetuar login");
setLoading(false);
return;
}
login(data.token, data.user);
} catch (err) {
setErrorMsg("Falha na conexão com o servidor.");
setLoading(false);
}
const handleLogtoSignIn = () => {
signIn(window.location.origin + "/callback");
};
return (
<div className="min-h-screen w-full flex items-center justify-center bg-surface-soft relative overflow-hidden">
{/* Background decorative elements */}
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] animate-pulse" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-primary/5 rounded-full blur-[120px]" />
<div className="relative z-10 w-full max-w-md px-6 flex flex-col items-center">
{/* Logo Area */}
<div className="mb-8 flex flex-col items-center text-center">
<div className="w-16 h-16 rounded-2xl bg-primary flex items-center justify-center text-white font-bold text-3xl shadow-2xl shadow-primary/40 mb-4 animate-in zoom-in duration-700">
G
@@ -56,53 +23,15 @@ export const Login = () => {
<p className="text-text-muted text-sm font-medium uppercase tracking-widest">Gestão de Pintura Industrial</p>
</div>
{/* Custom Login Form */}
<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">
<h2 className="text-xl font-bold text-text-main mb-6 text-center">Entrar na sua conta</h2>
<h2 className="text-xl font-bold text-text-main mb-6 text-center">Autenticação</h2>
{errorMsg && (
<div className="mb-4 p-3 rounded-lg bg-error/10 border border-error/20 text-error text-sm text-center">
{errorMsg}
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-text-secondary" htmlFor="email">Email</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-surface-soft border border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl px-4 py-3 text-text-main outline-none transition-all"
placeholder="seu@email.com"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<label className="text-sm font-semibold text-text-secondary" htmlFor="password">Senha</label>
</div>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-surface-soft border border-border/40 focus:ring-2 focus:ring-primary/20 focus:border-primary rounded-xl px-4 py-3 text-text-main outline-none transition-all"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="mt-4 bg-primary hover:bg-primary/90 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-primary/20 disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? "Entrando..." : "Entrar"}
</button>
</form>
<button
onClick={handleLogtoSignIn}
className="w-full mt-4 bg-primary hover:bg-primary/90 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-primary/20"
>
{isAuthenticated ? "Autenticado (Redirecionando...)" : "Entrar com Logto"}
</button>
</div>
<div className="mt-8 flex items-center gap-2 text-text-muted/60 text-xs font-medium">