feat: Implement client authentication and pre-fill functionality

- Added AuthController for client login and token management.
- Introduced AuthGate for handling authentication state and login UI.
- Updated API to support fetching client data for pre-filling forms.
- Modified App.vue to pre-fill client information if logged in.
- Enhanced client model to include username and password fields.
- Created migration for adding username and password to clients table.
- Added utility for generating secure usernames and passwords.
- Updated frontend to handle client tokens for pre-filling checkout forms.
- Removed old CSS file and updated HTML references to new assets.
- Added StatsOverview widget for displaying client and quote statistics.
- Improved error handling and user feedback in the login process.
This commit is contained in:
2026-08-24 17:24:46 +02:00
parent 1c40ef6601
commit d6a9978167
23 changed files with 666 additions and 54 deletions
+7
View File
@@ -0,0 +1,7 @@
// Base URL dell'API Laravel: impostata in build (import.meta.env.VITE_PREVENTIVI_API_BASE, da
// .env/.env.production — vedi root del repo), sovrascrivibile a runtime con window.PREVENTIVI_API_BASE
// senza dover rebuildare (es. per un hotfix). Nessuna delle due impostata → fallback dev locale.
export const API_BASE =
(typeof window.PREVENTIVI_API_BASE === 'string' && window.PREVENTIVI_API_BASE) ||
import.meta.env.VITE_PREVENTIVI_API_BASE ||
'http://localhost:8000/api';
+2 -2
View File
@@ -150,7 +150,7 @@ var APP = {
modello.visible = false;
}
this.viewFrontSide = function () {
this.viewFrontSide = function (fillFactor) {
camera.position.set(0, 0, 0.7);
const box = new THREE.Box3().setFromObject(scene);
@@ -160,7 +160,7 @@ var APP = {
const maxDim = Math.max(size.x, size.y, size.z);
const fov = camera.fov * (Math.PI / 180);
const cameraZ = Math.abs(maxDim / (2 * Math.tan(fov / 2))) / 0.4;
const cameraZ = Math.abs(maxDim / (2 * Math.tan(fov / 2))) / (fillFactor || 0.4);
const direction = new THREE.Vector3().subVectors(camera.position, center).normalize();
camera.position.copy(center).add(direction.multiplyScalar(cameraZ));
+208
View File
@@ -0,0 +1,208 @@
import { API_BASE } from './api_base.js';
const TOKEN_KEY = 'kreios_auth_token';
const EXPIRES_KEY = 'kreios_auth_expires';
// sessionStorage: dura finché la finestra/scheda resta aperta, così cambiare
// prodotto nel configuratore (nuova navigazione con ?type=...) non richiede
// di riaggiungere ?test manualmente.
const TEST_BYPASS_KEY = 'kreios_auth_test_bypass';
const getStoredAuth = () => {
const token = localStorage.getItem(TOKEN_KEY);
const expiresAt = localStorage.getItem(EXPIRES_KEY);
if (!token || !expiresAt) return null;
if (new Date(expiresAt).getTime() <= Date.now()) return null;
return { token, expiresAt };
};
const storeAuth = (token, expiresAt) => {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(EXPIRES_KEY, expiresAt);
};
const clearAuth = () => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXPIRES_KEY);
};
// Verifica col backend che il token non sia stato revocato. Se il backend non è
// raggiungibile non blocchiamo una sessione locale ancora nei termini di validità.
const verifyToken = async (token) => {
try {
const res = await fetch(`${API_BASE}/auth/me`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
});
return res.ok;
} catch (e) {
return true;
}
};
const injectStyles = () => {
if (document.getElementById('auth-gate-styles')) return;
const style = document.createElement('style');
style.id = 'auth-gate-styles';
style.textContent = `
#auth-gate-overlay {
position: fixed;
inset: 0;
z-index: 20000;
background: #12373C;
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
}
#auth-gate-form {
width: 100%;
max-width: 320px;
padding: 32px 28px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
}
#auth-gate-form h1 {
font-size: 18px;
margin: 0 0 20px;
color: #12373C;
text-align: center;
}
#auth-gate-form label {
display: block;
font-size: 13px;
color: #12373C;
margin-bottom: 14px;
}
#auth-gate-form input {
width: 100%;
margin-top: 4px;
padding: 9px 10px;
font-size: 14px;
border: 1px solid #c7d2d3;
border-radius: 4px;
box-sizing: border-box;
}
#auth-gate-form button {
width: 100%;
margin-top: 6px;
padding: 10px;
font-size: 14px;
font-weight: 600;
color: #fff;
background: #12373C;
border: none;
border-radius: 4px;
cursor: pointer;
}
#auth-gate-form button:disabled {
opacity: 0.6;
cursor: default;
}
#auth-gate-form .auth-gate-error {
margin-bottom: 14px;
padding: 8px 10px;
font-size: 12px;
color: #7a1f1f;
background: #fbe4e4;
border-radius: 4px;
}
#auth-gate-form .d-none {
display: none;
}
`;
document.head.appendChild(style);
};
const renderLoginForm = () => new Promise((resolve) => {
injectStyles();
const overlay = document.createElement('div');
overlay.id = 'auth-gate-overlay';
overlay.innerHTML = `
<form id="auth-gate-form" autocomplete="on">
<h1>Accedi al configuratore</h1>
<div class="auth-gate-error d-none"></div>
<label>Nome utente
<input type="text" name="username" required autocomplete="username" autofocus />
</label>
<label>Password
<input type="password" name="password" required autocomplete="current-password" />
</label>
<button type="submit">Accedi</button>
</form>
`;
document.body.appendChild(overlay);
const form = overlay.querySelector('#auth-gate-form');
const errorBox = overlay.querySelector('.auth-gate-error');
const submitBtn = form.querySelector('button[type=submit]');
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorBox.classList.add('d-none');
submitBtn.disabled = true;
submitBtn.textContent = 'Accesso in corso...';
const username = form.username.value.trim();
const password = form.password.value;
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errorBox.textContent = data.message || 'Accesso non riuscito.';
errorBox.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.textContent = 'Accedi';
return;
}
const data = await res.json();
storeAuth(data.token, data.expires_at);
overlay.remove();
resolve();
} catch (err) {
errorBox.textContent = 'Impossibile contattare il server. Riprova.';
errorBox.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.textContent = 'Accedi';
}
});
});
const AuthGate = {
// Token del cliente loggato (o null se non autenticato/scaduto/bypass ?test).
// Usato per passare l'identità al checkout (app "cliente") e precompilare l'anagrafica.
getToken() {
return getStoredAuth()?.token ?? null;
},
// Risolve quando l'utente è autenticato (o quando l'accesso è bypassato con ?test).
async ensure() {
const params = new URLSearchParams(window.location.search);
if (params.has('test')) {
sessionStorage.setItem(TEST_BYPASS_KEY, '1');
return;
}
if (sessionStorage.getItem(TEST_BYPASS_KEY) === '1') {
return;
}
const stored = getStoredAuth();
if (stored) {
const valid = await verifyToken(stored.token);
if (valid) return;
clearAuth();
}
await renderLoginForm();
},
};
export default AuthGate;
+13 -9
View File
@@ -12,6 +12,8 @@ import './price_calculator.js';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import Configurator from './configurator.js';
import AuthGate from './auth_gate.js';
import { API_BASE as PREVENTIVI_API_BASE } from './api_base.js';
window.$ = $;
@@ -20,13 +22,6 @@ window.OrbitControls = OrbitControls; // Used by APP Scripts.
window.GLTFLoader = GLTFLoader; // Used by APP Scripts.
// --- Integrazione backend preventivi ---
// Base URL dell'API Laravel: impostata in build (import.meta.env.VITE_PREVENTIVI_API_BASE, da
// .env/.env.production — vedi root del repo), sovrascrivibile a runtime con window.PREVENTIVI_API_BASE
// senza dover rebuildare (es. per un hotfix). Nessuna delle due impostata → fallback dev locale.
const PREVENTIVI_API_BASE =
(typeof window.PREVENTIVI_API_BASE === 'string' && window.PREVENTIVI_API_BASE) ||
import.meta.env.VITE_PREVENTIVI_API_BASE ||
'http://localhost:8000/api';
const CART_TOKEN_KEY = 'kreios_cart_token';
// URL dell'app cliente (checkout): richiesto all'API invece di essere indovinato, perché la
@@ -59,7 +54,14 @@ window.mostraLinkPreventivo = (token, count) => {
// diverse in dev/prod): lo passiamo qui come parametro, così il link "torna al configuratore"
// nella pagina di conferma può essere generato dinamicamente invece di essere hardcoded.
const ritorno = encodeURIComponent(window.location.href);
link.href = `${appClienteUrl}/?token=${encodeURIComponent(token)}&ritorno=${ritorno}`;
let href = `${appClienteUrl}/?token=${encodeURIComponent(token)}&ritorno=${ritorno}`;
// Se il cliente è loggato nel configuratore, passiamo il suo token anche al checkout
// così può precompilare l'anagrafica già salvata (vedi AuthGate.getToken()).
const clienteToken = AuthGate.getToken();
if (clienteToken) {
href += `&cliente_token=${encodeURIComponent(clienteToken)}`;
}
link.href = href;
link.textContent = count > 0 ? `Vai al preventivo (${count})` : 'Vai al preventivo';
link.classList.remove('d-none');
};
@@ -256,12 +258,14 @@ const start = async () => {
});
window.generaImagine = async () => {
playerOff.viewFrontSide(0.6);
playerOff.render();
$('#canvas-offscreen').removeClass('d-none');
const canvas = $('#canvas-offscreen canvas')[0];
const dataUrl = await canvas.toDataURL('image/png');
$('#canvas-offscreen').addClass('d-none');
playerOff.viewFrontSide();
return dataUrl;
const a = document.createElement('a');
a.href = dataUrl;
@@ -437,4 +441,4 @@ const start = async () => {
}
start();
AuthGate.ensure().then(start);