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:
@@ -1,6 +1,7 @@
|
|||||||
/.venv/
|
/.venv/
|
||||||
/kreios/
|
/kreios/
|
||||||
/dist/
|
/dist/
|
||||||
|
/backoffice/cliente/dist/
|
||||||
/node_modules/
|
/node_modules/
|
||||||
kreios.zip
|
kreios.zip
|
||||||
.env.production
|
.env.production
|
||||||
@@ -4,8 +4,11 @@ namespace App\Filament\Resources;
|
|||||||
|
|
||||||
use App\Filament\Resources\ClienteResource\Pages;
|
use App\Filament\Resources\ClienteResource\Pages;
|
||||||
use App\Models\Cliente;
|
use App\Models\Cliente;
|
||||||
|
use App\Support\ClienteCredenziali;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Infolists;
|
||||||
|
use Filament\Infolists\Infolist;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@@ -27,16 +30,54 @@ class ClienteResource extends Resource
|
|||||||
public static function form(Form $form): Form
|
public static function form(Form $form): Form
|
||||||
{
|
{
|
||||||
return $form->schema([
|
return $form->schema([
|
||||||
Forms\Components\TextInput::make('nome')->label('Nome')->required(),
|
Forms\Components\Section::make('Accesso')
|
||||||
|
->description('Credenziali per l\'accesso del cliente. Generate automaticamente, ma modificabili.')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('username')
|
||||||
|
->label('Nome utente')
|
||||||
|
->required()
|
||||||
|
->maxLength(50)
|
||||||
|
->unique(ignoreRecord: true)
|
||||||
|
->default(fn (string $operation) => $operation === 'create' ? ClienteCredenziali::generaUsername() : null)
|
||||||
|
->suffixAction(
|
||||||
|
Forms\Components\Actions\Action::make('rigeneraUsername')
|
||||||
|
->icon('heroicon-m-arrow-path')
|
||||||
|
->tooltip('Genera un nuovo nome utente')
|
||||||
|
->action(fn (Forms\Set $set) => $set('username', ClienteCredenziali::generaUsername()))
|
||||||
|
),
|
||||||
|
Forms\Components\TextInput::make('password')
|
||||||
|
->label('Password')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->required(fn (string $operation) => $operation === 'create')
|
||||||
|
->dehydrated(fn (?string $state) => filled($state))
|
||||||
|
->default(fn (string $operation) => $operation === 'create' ? ClienteCredenziali::generaPassword() : null)
|
||||||
|
->suffixAction(
|
||||||
|
Forms\Components\Actions\Action::make('rigeneraPassword')
|
||||||
|
->icon('heroicon-m-arrow-path')
|
||||||
|
->tooltip('Genera una nuova password')
|
||||||
|
->action(fn (Forms\Set $set) => $set('password', ClienteCredenziali::generaPassword()))
|
||||||
|
)
|
||||||
|
->helperText(fn (string $operation) => $operation === 'create'
|
||||||
|
? 'Generata automaticamente. Ricordati di copiarla dopo il salvataggio: non sarà più leggibile.'
|
||||||
|
: 'Lascia vuoto per mantenere la password attuale, oppure genera/inserisci una nuova password.'),
|
||||||
|
]),
|
||||||
|
Forms\Components\Section::make('Anagrafica')
|
||||||
|
->description('Campi facoltativi.')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('nome')->label('Nome'),
|
||||||
Forms\Components\TextInput::make('cognome')->label('Cognome'),
|
Forms\Components\TextInput::make('cognome')->label('Cognome'),
|
||||||
Forms\Components\TextInput::make('ragione_sociale')->label('Ragione sociale'),
|
Forms\Components\TextInput::make('ragione_sociale')->label('Ragione sociale'),
|
||||||
Forms\Components\TextInput::make('email')->label('Email')->email()->required(),
|
Forms\Components\TextInput::make('email')->label('Email')->email(),
|
||||||
Forms\Components\TextInput::make('telefono')->label('Telefono')->tel(),
|
Forms\Components\TextInput::make('telefono')->label('Telefono')->tel(),
|
||||||
Forms\Components\TextInput::make('indirizzo')->label('Indirizzo'),
|
Forms\Components\TextInput::make('indirizzo')->label('Indirizzo'),
|
||||||
Forms\Components\TextInput::make('cap')->label('CAP')->maxLength(10),
|
Forms\Components\TextInput::make('cap')->label('CAP')->maxLength(10),
|
||||||
Forms\Components\TextInput::make('citta')->label('Città'),
|
Forms\Components\TextInput::make('citta')->label('Città'),
|
||||||
Forms\Components\TextInput::make('provincia')->label('Provincia')->maxLength(5),
|
Forms\Components\TextInput::make('provincia')->label('Provincia')->maxLength(5),
|
||||||
Forms\Components\Textarea::make('note')->label('Note')->columnSpanFull(),
|
Forms\Components\Textarea::make('note')->label('Note')->columnSpanFull(),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,13 +87,15 @@ class ClienteResource extends Resource
|
|||||||
->defaultSort('created_at', 'desc')
|
->defaultSort('created_at', 'desc')
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('nominativo')->label('Nominativo')->searchable(['nome', 'cognome', 'ragione_sociale']),
|
Tables\Columns\TextColumn::make('nominativo')->label('Nominativo')->searchable(['nome', 'cognome', 'ragione_sociale']),
|
||||||
Tables\Columns\TextColumn::make('email')->label('Email')->searchable()->copyable(),
|
Tables\Columns\TextColumn::make('username')->label('Nome utente')->searchable()->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('email')->label('Email')->searchable()->copyable()->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('telefono')->label('Telefono')->placeholder('—'),
|
Tables\Columns\TextColumn::make('telefono')->label('Telefono')->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('citta')->label('Città')->placeholder('—'),
|
Tables\Columns\TextColumn::make('citta')->label('Città')->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('preventivi_count')->label('Preventivi')->counts('preventivi'),
|
Tables\Columns\TextColumn::make('preventivi_count')->label('Preventivi')->counts('preventivi'),
|
||||||
Tables\Columns\TextColumn::make('created_at')->label('Registrato')->dateTime('d/m/Y')->sortable(),
|
Tables\Columns\TextColumn::make('created_at')->label('Registrato')->dateTime('d/m/Y')->sortable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Tables\Actions\ViewAction::make(),
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\EditAction::make(),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
@@ -62,10 +105,42 @@ class ClienteResource extends Resource
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function infolist(Infolist $infolist): Infolist
|
||||||
|
{
|
||||||
|
return $infolist->schema([
|
||||||
|
Infolists\Components\View::make('filament.infolists.cliente-password-reveal')
|
||||||
|
->visible(fn () => filled(session('cliente_password_plain')))
|
||||||
|
->viewData(fn () => [
|
||||||
|
'username' => session('cliente_username_plain'),
|
||||||
|
'password' => session('cliente_password_plain'),
|
||||||
|
]),
|
||||||
|
Infolists\Components\Section::make('Accesso')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Infolists\Components\TextEntry::make('username')->label('Nome utente')->copyable()->placeholder('—'),
|
||||||
|
Infolists\Components\TextEntry::make('created_at')->label('Cliente dal')->dateTime('d/m/Y H:i'),
|
||||||
|
]),
|
||||||
|
Infolists\Components\Section::make('Anagrafica')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Infolists\Components\TextEntry::make('nominativo')->label('Nominativo')->placeholder('—'),
|
||||||
|
Infolists\Components\TextEntry::make('email')->label('Email')->copyable()->placeholder('—'),
|
||||||
|
Infolists\Components\TextEntry::make('telefono')->label('Telefono')->placeholder('—'),
|
||||||
|
Infolists\Components\TextEntry::make('indirizzo')->label('Indirizzo')->placeholder('—'),
|
||||||
|
Infolists\Components\TextEntry::make('cap')->label('CAP')->placeholder('—'),
|
||||||
|
Infolists\Components\TextEntry::make('citta')->label('Città')->placeholder('—'),
|
||||||
|
Infolists\Components\TextEntry::make('provincia')->label('Provincia')->placeholder('—'),
|
||||||
|
Infolists\Components\TextEntry::make('note')->label('Note')->columnSpanFull()->placeholder('—'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'index' => Pages\ListClientes::route('/'),
|
'index' => Pages\ListClientes::route('/'),
|
||||||
|
'create' => Pages\CreateCliente::route('/create'),
|
||||||
|
'view' => Pages\ViewCliente::route('/{record}'),
|
||||||
'edit' => Pages\EditCliente::route('/{record}/edit'),
|
'edit' => Pages\EditCliente::route('/{record}/edit'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,31 @@
|
|||||||
namespace App\Filament\Resources\ClienteResource\Pages;
|
namespace App\Filament\Resources\ClienteResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ClienteResource;
|
use App\Filament\Resources\ClienteResource;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class CreateCliente extends CreateRecord
|
class CreateCliente extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = ClienteResource::class;
|
protected static string $resource = ClienteResource::class;
|
||||||
|
|
||||||
|
protected ?string $plainPassword = null;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$this->plainPassword = $data['password'];
|
||||||
|
$data['password'] = Hash::make($data['password']);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
session()->flash('cliente_username_plain', $this->record->username);
|
||||||
|
session()->flash('cliente_password_plain', $this->plainPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ namespace App\Filament\Resources\ClienteResource\Pages;
|
|||||||
use App\Filament\Resources\ClienteResource;
|
use App\Filament\Resources\ClienteResource;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class EditCliente extends EditRecord
|
class EditCliente extends EditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = ClienteResource::class;
|
protected static string $resource = ClienteResource::class;
|
||||||
|
|
||||||
|
protected ?string $plainPassword = null;
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -17,4 +20,29 @@ class EditCliente extends EditRecord
|
|||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
if (filled($data['password'] ?? null)) {
|
||||||
|
$this->plainPassword = $data['password'];
|
||||||
|
$data['password'] = Hash::make($data['password']);
|
||||||
|
} else {
|
||||||
|
unset($data['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
if ($this->plainPassword !== null) {
|
||||||
|
session()->flash('cliente_username_plain', $this->record->username);
|
||||||
|
session()->flash('cliente_password_plain', $this->plainPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ClienteResource;
|
||||||
|
use App\Filament\Resources\PreventivoResource;
|
||||||
|
use App\Models\Cliente;
|
||||||
|
use App\Models\Preventivo;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class StatsOverview extends BaseWidget
|
||||||
|
{
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Stat::make('Preventivi', Preventivo::count())
|
||||||
|
->icon('heroicon-o-document-text')
|
||||||
|
->color('warning')
|
||||||
|
->url(PreventivoResource::getUrl('index')),
|
||||||
|
Stat::make('Clienti', Cliente::count())
|
||||||
|
->icon('heroicon-o-users')
|
||||||
|
->color('success')
|
||||||
|
->url(ClienteResource::getUrl('index')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Cliente;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* POST /api/auth/login
|
||||||
|
* Login del cliente per l'accesso al configuratore (username + password).
|
||||||
|
*/
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'username' => ['required', 'string'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cliente = Cliente::where('username', $data['username'])->first();
|
||||||
|
|
||||||
|
if (! $cliente || ! $cliente->password || ! password_verify($data['password'], $cliente->password)) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Nome utente o password non validi.',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$giorni = (int) config('preventivi.configurator_session_days', 30);
|
||||||
|
$token = $cliente->createToken('configuratore', ['*'], now()->addDays($giorni));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'token' => $token->plainTextToken,
|
||||||
|
'expires_at' => $token->accessToken->expires_at,
|
||||||
|
'cliente' => [
|
||||||
|
'id' => $cliente->id,
|
||||||
|
'username' => $cliente->username,
|
||||||
|
'nominativo' => $cliente->nominativo,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auth/me
|
||||||
|
* Verifica che il token del configuratore sia ancora valido e restituisce i dati
|
||||||
|
* anagrafici del cliente, usati per precompilare il form di richiesta preventivo.
|
||||||
|
*/
|
||||||
|
public function me(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$cliente = $request->user();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'cliente' => [
|
||||||
|
'id' => $cliente->id,
|
||||||
|
'username' => $cliente->username,
|
||||||
|
'nominativo' => $cliente->nominativo,
|
||||||
|
'nome' => $cliente->nome,
|
||||||
|
'cognome' => $cliente->cognome,
|
||||||
|
'ragione_sociale' => $cliente->ragione_sociale,
|
||||||
|
'email' => $cliente->email,
|
||||||
|
'telefono' => $cliente->telefono,
|
||||||
|
'indirizzo' => $cliente->indirizzo,
|
||||||
|
'cap' => $cliente->cap,
|
||||||
|
'citta' => $cliente->citta,
|
||||||
|
'provincia' => $cliente->provincia,
|
||||||
|
'note' => $cliente->note,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,28 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
class Cliente extends Model
|
class Cliente extends Model
|
||||||
{
|
{
|
||||||
|
use HasApiTokens;
|
||||||
|
|
||||||
protected $table = 'clienti';
|
protected $table = 'clienti';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'username', 'password',
|
||||||
'nome', 'cognome', 'ragione_sociale', 'email', 'telefono',
|
'nome', 'cognome', 'ragione_sociale', 'email', 'telefono',
|
||||||
'indirizzo', 'cap', 'citta', 'provincia', 'note',
|
'indirizzo', 'cap', 'citta', 'provincia', 'note',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
|
||||||
public function preventivi(): HasMany
|
public function preventivi(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Preventivo::class);
|
return $this->hasMany(Preventivo::class);
|
||||||
@@ -25,6 +37,8 @@ class Cliente extends Model
|
|||||||
return $this->ragione_sociale;
|
return $this->ragione_sociale;
|
||||||
}
|
}
|
||||||
|
|
||||||
return trim("{$this->nome} {$this->cognome}");
|
$nome = trim("{$this->nome} {$this->cognome}");
|
||||||
|
|
||||||
|
return $nome !== '' ? $nome : $this->username;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use Filament\Pages;
|
|||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\Support\Colors\Color;
|
||||||
use Filament\Widgets;
|
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
@@ -41,10 +40,7 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
Pages\Dashboard::class,
|
Pages\Dashboard::class,
|
||||||
])
|
])
|
||||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||||
->widgets([
|
->widgets([])
|
||||||
Widgets\AccountWidget::class,
|
|
||||||
Widgets\FilamentInfoWidget::class,
|
|
||||||
])
|
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Cliente;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ClienteCredenziali
|
||||||
|
{
|
||||||
|
/** Genera un nome utente tipo "user2130" (numero casuale, minimo 4 cifre) non ancora in uso. */
|
||||||
|
public static function generaUsername(): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$username = 'user'.random_int(1000, 999999);
|
||||||
|
} while (Cliente::where('username', $username)->exists());
|
||||||
|
|
||||||
|
return $username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genera una password casuale sicura (lettere maiuscole/minuscole, numeri, simboli). */
|
||||||
|
public static function generaPassword(): string
|
||||||
|
{
|
||||||
|
return Str::password(14);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
:root{--brand: #1a4b8c;--brand-dark: #143a6b;--bg: #f5f6f8;--card: #ffffff;--border: #e4e7ec;--text: #1f2937;--muted: #6b7280;--danger: #c0392b;--ok: #1e874b}*{box-sizing:border-box}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text)}.app-header{background:var(--brand);color:#fff;padding:18px 20px}.app-header h1{margin:0;font-size:18px;font-weight:600}.container{max-width:780px;margin:0 auto;padding:20px 16px 60px}.card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:14px}.item{display:flex;gap:14px}.item img{width:90px;height:90px;object-fit:contain;background:#fafafa;border:1px solid var(--border);border-radius:8px;flex:none}.item-body{flex:1;min-width:0}.item-title{font-weight:600;margin:0 0 2px}.item-sub{color:var(--muted);font-size:13px;margin-bottom:6px}.conf{font-size:13px;color:#374151;line-height:1.5}.conf b{color:var(--muted);font-weight:500}.item-foot{display:flex;align-items:center;justify-content:space-between;margin-top:8px}.price{font-weight:700}.totale-row{display:flex;justify-content:space-between;align-items:baseline;font-size:20px;font-weight:700;color:var(--brand);padding-top:8px}.iva-nota{color:var(--muted);font-size:12px;font-weight:400}button{font:inherit;cursor:pointer;border:none;border-radius:8px;padding:12px 18px}.btn-primary{background:var(--brand);color:#fff;font-weight:600;width:100%}.btn-primary:hover{background:var(--brand-dark)}.btn-primary:disabled{opacity:.6;cursor:not-allowed}.btn-link{background:none;color:var(--muted);padding:6px;text-decoration:underline}.btn-remove{background:none;color:var(--danger);padding:4px 8px;font-size:13px}.field{margin-bottom:12px}.field label{display:block;font-size:13px;color:var(--muted);margin-bottom:4px}.field input,.field textarea{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:8px;font:inherit}.field .err{color:var(--danger);font-size:12px;margin-top:3px}.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:12px}@media(max-width:520px){.grid-2{grid-template-columns:1fr}}.muted{color:var(--muted)}.center{text-align:center}.empty{text-align:center;padding:40px 20px;color:var(--muted)}.success-icon{font-size:48px}.alert{background:#fdecea;color:var(--danger);padding:10px 12px;border-radius:8px;margin-bottom:12px;font-size:14px}
|
|
||||||
Vendored
+4
-3
@@ -3,9 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Il tuo preventivo · Infissionline</title>
|
<link rel="icon" type="image/jpeg" href="/kreios-logo.jpg" />
|
||||||
<script type="module" crossorigin src="/assets/index-BidQ3v5l.js"></script>
|
<title>Il tuo preventivo · Krèios</title>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DNK0MLY8.css">
|
<script type="module" crossorigin src="/assets/index-D5nts1N1.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-vT7pz0E4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { api, resolveToken, resolveReturnUrl, clearToken, formatEuro } from './api.js'
|
import { api, resolveToken, resolveReturnUrl, resolveClienteToken, clearToken, formatEuro } from './api.js'
|
||||||
|
|
||||||
const step = ref('cart') // cart | checkout | done
|
const step = ref('cart') // cart | checkout | done
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -16,9 +16,37 @@ const form = reactive({
|
|||||||
nome: '', cognome: '', ragione_sociale: '', email: '', telefono: '',
|
nome: '', cognome: '', ragione_sociale: '', email: '', telefono: '',
|
||||||
indirizzo: '', cap: '', citta: '', provincia: '', note: '',
|
indirizzo: '', cap: '', citta: '', provincia: '', note: '',
|
||||||
})
|
})
|
||||||
|
const anagraficaCaricata = ref(false)
|
||||||
|
|
||||||
const isEmpty = computed(() => !carrello.items || carrello.items.length === 0)
|
const isEmpty = computed(() => !carrello.items || carrello.items.length === 0)
|
||||||
|
|
||||||
|
// Se il cliente è loggato nel configuratore, precompila il form con i suoi dati già
|
||||||
|
// salvati (solo i campi ancora vuoti, per non sovrascrivere quanto già digitato).
|
||||||
|
async function precompilaAnagrafica() {
|
||||||
|
if (anagraficaCaricata.value) return
|
||||||
|
anagraficaCaricata.value = true
|
||||||
|
|
||||||
|
const clienteToken = resolveClienteToken()
|
||||||
|
if (!clienteToken) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.me(clienteToken)
|
||||||
|
const campi = ['nome', 'cognome', 'ragione_sociale', 'email', 'telefono', 'indirizzo', 'cap', 'citta', 'provincia', 'note']
|
||||||
|
for (const campo of campi) {
|
||||||
|
if (!form[campo] && data.cliente[campo]) {
|
||||||
|
form[campo] = data.cliente[campo]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Token scaduto/non valido: l'utente compila il form manualmente.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaiAlCheckout() {
|
||||||
|
step.value = 'checkout'
|
||||||
|
precompilaAnagrafica()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCarrello() {
|
async function loadCarrello() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
@@ -123,7 +151,7 @@ onMounted(loadCarrello)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn-primary" @click="step = 'checkout'">
|
<button class="btn-primary" @click="vaiAlCheckout">
|
||||||
Richiedi il preventivo
|
Richiedi il preventivo
|
||||||
</button>
|
</button>
|
||||||
<a v-if="returnUrl" class="btn-link" style="width:100%;margin-top:8px" :href="returnUrl">
|
<a v-if="returnUrl" class="btn-link" style="width:100%;margin-top:8px" :href="returnUrl">
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ const BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
|
|||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
||||||
...options,
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -20,23 +24,34 @@ export const api = {
|
|||||||
rimuoviItem: (token, itemId) => request(`/carrello/${token}/item/${itemId}`, { method: 'DELETE' }),
|
rimuoviItem: (token, itemId) => request(`/carrello/${token}/item/${itemId}`, { method: 'DELETE' }),
|
||||||
conferma: (token, dati) =>
|
conferma: (token, dati) =>
|
||||||
request(`/carrello/${token}/conferma`, { method: 'POST', body: JSON.stringify(dati) }),
|
request(`/carrello/${token}/conferma`, { method: 'POST', body: JSON.stringify(dati) }),
|
||||||
|
// Dati anagrafici del cliente loggato nel configuratore, per precompilare il checkout.
|
||||||
|
me: (clienteToken) =>
|
||||||
|
request('/auth/me', { headers: { Authorization: `Bearer ${clienteToken}` } }),
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'kreios_cart_token'
|
const STORAGE_KEY = 'kreios_cart_token'
|
||||||
const RETURN_URL_KEY = 'kreios_return_url'
|
const RETURN_URL_KEY = 'kreios_return_url'
|
||||||
|
const CLIENTE_TOKEN_KEY = 'kreios_cliente_token'
|
||||||
|
|
||||||
export function resolveToken() {
|
export function resolveToken() {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const fromUrl = url.searchParams.get('token')
|
const fromUrl = url.searchParams.get('token')
|
||||||
const ritorno = url.searchParams.get('ritorno')
|
const ritorno = url.searchParams.get('ritorno')
|
||||||
|
const clienteToken = url.searchParams.get('cliente_token')
|
||||||
if (ritorno) {
|
if (ritorno) {
|
||||||
localStorage.setItem(RETURN_URL_KEY, ritorno)
|
localStorage.setItem(RETURN_URL_KEY, ritorno)
|
||||||
}
|
}
|
||||||
|
// Token del cliente loggato nel configuratore (vedi AuthGate.getToken() in starter.js),
|
||||||
|
// usato per precompilare l'anagrafica nel checkout. Assente se non era loggato.
|
||||||
|
if (clienteToken) {
|
||||||
|
localStorage.setItem(CLIENTE_TOKEN_KEY, clienteToken)
|
||||||
|
}
|
||||||
if (fromUrl) {
|
if (fromUrl) {
|
||||||
localStorage.setItem(STORAGE_KEY, fromUrl)
|
localStorage.setItem(STORAGE_KEY, fromUrl)
|
||||||
// pulisci l'URL mantenendo token/ritorno in localStorage
|
// pulisci l'URL mantenendo token/ritorno/cliente_token in localStorage
|
||||||
url.searchParams.delete('token')
|
url.searchParams.delete('token')
|
||||||
url.searchParams.delete('ritorno')
|
url.searchParams.delete('ritorno')
|
||||||
|
url.searchParams.delete('cliente_token')
|
||||||
window.history.replaceState({}, '', url.pathname + url.search)
|
window.history.replaceState({}, '', url.pathname + url.search)
|
||||||
return fromUrl
|
return fromUrl
|
||||||
}
|
}
|
||||||
@@ -50,6 +65,10 @@ export function resolveReturnUrl() {
|
|||||||
return localStorage.getItem(RETURN_URL_KEY)
|
return localStorage.getItem(RETURN_URL_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveClienteToken() {
|
||||||
|
return localStorage.getItem(CLIENTE_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
export function clearToken() {
|
export function clearToken() {
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,7 @@ return [
|
|||||||
|
|
||||||
// URL base dell'app cliente (per link nelle email admin).
|
// URL base dell'app cliente (per link nelle email admin).
|
||||||
'frontend_cliente_url' => env('APP_FRONTEND_CLIENTE_URL', 'http://localhost:5199'),
|
'frontend_cliente_url' => env('APP_FRONTEND_CLIENTE_URL', 'http://localhost:5199'),
|
||||||
|
|
||||||
|
// Durata (in giorni) della sessione di login del configuratore.
|
||||||
|
'configurator_session_days' => (int) env('CONFIGURATOR_SESSION_DAYS', 30),
|
||||||
];
|
];
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('clienti', function (Blueprint $table) {
|
||||||
|
// Nullable: i clienti creati dal checkout pubblico non hanno credenziali di accesso.
|
||||||
|
$table->string('username')->nullable()->unique()->after('id');
|
||||||
|
$table->string('password')->nullable()->after('username');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('clienti', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['username', 'password']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('clienti', function (Blueprint $table) {
|
||||||
|
$table->string('nome')->nullable()->change();
|
||||||
|
$table->string('email')->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('clienti', function (Blueprint $table) {
|
||||||
|
$table->string('nome')->nullable(false)->change();
|
||||||
|
$table->string('email')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@php $passwordJs = \Illuminate\Support\Js::from($password); @endphp
|
||||||
|
<div
|
||||||
|
x-data="{ copied: false }"
|
||||||
|
class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-800 dark:bg-warning-950"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-warning-800 dark:text-warning-200">
|
||||||
|
Copia subito la password: per motivi di sicurezza non sarà più possibile visualizzarla in seguito.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl class="mt-3 grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2 text-sm">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Nome utente</dt>
|
||||||
|
<dd class="font-mono font-medium">{{ $username }}</dd>
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Password</dt>
|
||||||
|
<dd class="rounded bg-white px-2 py-1 font-mono dark:bg-gray-900">{{ $password }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
color="warning"
|
||||||
|
size="sm"
|
||||||
|
class="mt-3"
|
||||||
|
x-on:click="
|
||||||
|
navigator.clipboard.writeText({{ $passwordJs }});
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
new FilamentNotification()
|
||||||
|
.title('Password copiata negli appunti')
|
||||||
|
.success()
|
||||||
|
.send();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span x-show="!copied">Copia password</span>
|
||||||
|
<span x-show="copied" x-cloak>Copiata!</span>
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\AuthController;
|
||||||
use App\Http\Controllers\Api\CarrelloController;
|
use App\Http\Controllers\Api\CarrelloController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -9,6 +10,11 @@ Route::get('config', fn () => response()->json([
|
|||||||
'frontend_cliente_url' => config('preventivi.frontend_cliente_url'),
|
'frontend_cliente_url' => config('preventivi.frontend_cliente_url'),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
Route::prefix('auth')->group(function () {
|
||||||
|
Route::post('login', [AuthController::class, 'login']);
|
||||||
|
Route::middleware('auth:sanctum')->get('me', [AuthController::class, 'me']);
|
||||||
|
});
|
||||||
|
|
||||||
Route::prefix('carrello')->group(function () {
|
Route::prefix('carrello')->group(function () {
|
||||||
Route::post('aggiungi', [CarrelloController::class, 'aggiungi']);
|
Route::post('aggiungi', [CarrelloController::class, 'aggiungi']);
|
||||||
Route::get('{token}', [CarrelloController::class, 'mostra']);
|
Route::get('{token}', [CarrelloController::class, 'mostra']);
|
||||||
|
|||||||
@@ -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
@@ -150,7 +150,7 @@ var APP = {
|
|||||||
modello.visible = false;
|
modello.visible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.viewFrontSide = function () {
|
this.viewFrontSide = function (fillFactor) {
|
||||||
camera.position.set(0, 0, 0.7);
|
camera.position.set(0, 0, 0.7);
|
||||||
const box = new THREE.Box3().setFromObject(scene);
|
const box = new THREE.Box3().setFromObject(scene);
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ var APP = {
|
|||||||
const maxDim = Math.max(size.x, size.y, size.z);
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
|
||||||
const fov = camera.fov * (Math.PI / 180);
|
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();
|
const direction = new THREE.Vector3().subVectors(camera.position, center).normalize();
|
||||||
camera.position.copy(center).add(direction.multiplyScalar(cameraZ));
|
camera.position.copy(center).add(direction.multiplyScalar(cameraZ));
|
||||||
|
|||||||
@@ -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
@@ -12,6 +12,8 @@ import './price_calculator.js';
|
|||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||||
import Configurator from './configurator.js';
|
import Configurator from './configurator.js';
|
||||||
|
import AuthGate from './auth_gate.js';
|
||||||
|
import { API_BASE as PREVENTIVI_API_BASE } from './api_base.js';
|
||||||
|
|
||||||
|
|
||||||
window.$ = $;
|
window.$ = $;
|
||||||
@@ -20,13 +22,6 @@ window.OrbitControls = OrbitControls; // Used by APP Scripts.
|
|||||||
window.GLTFLoader = GLTFLoader; // Used by APP Scripts.
|
window.GLTFLoader = GLTFLoader; // Used by APP Scripts.
|
||||||
|
|
||||||
// --- Integrazione backend preventivi ---
|
// --- 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';
|
const CART_TOKEN_KEY = 'kreios_cart_token';
|
||||||
|
|
||||||
// URL dell'app cliente (checkout): richiesto all'API invece di essere indovinato, perché la
|
// 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"
|
// 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.
|
// nella pagina di conferma può essere generato dinamicamente invece di essere hardcoded.
|
||||||
const ritorno = encodeURIComponent(window.location.href);
|
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.textContent = count > 0 ? `Vai al preventivo (${count})` : 'Vai al preventivo';
|
||||||
link.classList.remove('d-none');
|
link.classList.remove('d-none');
|
||||||
};
|
};
|
||||||
@@ -256,12 +258,14 @@ const start = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.generaImagine = async () => {
|
window.generaImagine = async () => {
|
||||||
|
playerOff.viewFrontSide(0.6);
|
||||||
playerOff.render();
|
playerOff.render();
|
||||||
$('#canvas-offscreen').removeClass('d-none');
|
$('#canvas-offscreen').removeClass('d-none');
|
||||||
const canvas = $('#canvas-offscreen canvas')[0];
|
const canvas = $('#canvas-offscreen canvas')[0];
|
||||||
const dataUrl = await canvas.toDataURL('image/png');
|
const dataUrl = await canvas.toDataURL('image/png');
|
||||||
|
|
||||||
$('#canvas-offscreen').addClass('d-none');
|
$('#canvas-offscreen').addClass('d-none');
|
||||||
|
playerOff.viewFrontSide();
|
||||||
return dataUrl;
|
return dataUrl;
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = dataUrl;
|
a.href = dataUrl;
|
||||||
@@ -437,4 +441,4 @@ const start = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
start();
|
AuthGate.ensure().then(start);
|
||||||
Reference in New Issue
Block a user