pre version 2

This commit is contained in:
2026-06-17 13:50:41 +02:00
parent c333bbce4e
commit 6909e21b67
26 changed files with 1213 additions and 136 deletions
+6 -2
View File
@@ -7,8 +7,12 @@ APP_PROTOCOL=https
FORCE_HTTPS=true
TRUSTED_PROXIES=*
GOOGLE_CLIENT_ID=980774223097-o5h7a6kepvg69te34fof2otn7ibi9uha.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
#GOOGLE_CLIENT_ID=980774223097-o5h7a6kepvg69te34fof2otn7ibi9uha.apps.googleusercontent.com
#GOOGLE_CLIENT_SECRET=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
GOOGLE_CLIENT_ID=416164569291-mss5favrg1qdmbk343plaj1898g3200f.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
APP_LOCALE=it
+198
View File
@@ -3,6 +3,28 @@
## Obiettivo
App gestionale Laravel 13 con AdminLTE 4 per gestione Persone e Gruppi.
## Riassunto Generale
| Area | Stato | Ultima modifica |
|------|-------|-----------------|
| **Core** (Individui, Gruppi, Eventi, Documenti, Mailing) | ✅ Completo | CRUD, relazioni, filtri, paginazione, ACL, export |
| **Tag System** | ✅ Completo | morphToMany su 5 entità, filtri OR/AND, report, ricerca unificata |
| **Viste Colonne** | ✅ Completo | Per-User column visibility/width/order, resize/drag-reorder, print |
| **Email (IMAP/SMTP)** | ✅ Completo | Sync messaggi, firma, allegati, mittenti multipli, XOAUTH2 OAuth |
| **Google OAuth 2.0** | ✅ Implementato | Unified (Email/Drive/Calendar), UI impostazioni, XOAUTH2 SMTP+IMAP |
| **Repository Remoti** | ✅ Completo | WebDAV, Google Drive, OAuth |
| **Calendario** | ✅ Completo | Google Calendar, CalDAV, sync bidirezionale, pulsante sync |
| **Report** | ✅ Completo | Custom report con colonne/filtri salvabili, tag filter |
| **Help / Documentazione** | ✅ Completo | Tab Google Drive + Calendar in help page, PDF export |
| **Distribuzione** | ✅ Completo | build-dist.sh, fix path/cache/permessi/symlink |
### Cronologia modifiche principali
- **2026-06-07**: Fix colonne mancanti (eventi, gruppo_individuo)
- **2026-06-08**: build-dist.sh fix, logo remote fix, 18 migration guardie, help Calendar, sync pulsante, form annidato fix, checkbox hidden fallback, DiagnoseEmail command
- **2026-06-09**: Tag completato (MailingList, Report, Ricerca), @stack scripts fix, storeCustom tag_filter fix, MailingList edit JS fix
- **2026-06-10**: Per-User Column Views, ColumnManager JS, editVista data-* fix, 405 AJAX fix
- **2026-06-17**: Google OAuth 2.0 unificato (revert Socialite, unified OAuth per Email/Drive/Calendar, XOAUTH2 SMTP+IMAP, UI impostazioni)
## Funzionalità Implementate
### Page-length selector
@@ -380,3 +402,179 @@ if (!empty($contatto['individuo_id'])) { create }
**Fix**:
1. `VistaReportController@update` (linea 101-103): aggiunto `if ($request->expectsJson()) { return response()->json([...]); }` — così il fetch riceve JSON 200, non un redirect 302.
2. Fetch headers in `table-settings.blade.php`: aggiunto `'Accept': 'application/json'` — necessario perché `expectsJson()` controlla l'header `Accept`, non `Content-Type`.
---
## 2026-06-17 — Google OAuth 2.0 Unificato (Email/Drive/Calendar)
**Obiettivo**: Implementare OAuth 2.0 centralizzato per tutti i servizi Google (Gmail email, Google Drive, Google Calendar), con UI nelle impostazioni e supporto XOAUTH2 per SMTP/IMAP via Symfony Mailer + ImapEngine. Sistema password attuale deve funzionare in parallelo.
### Cosa è stato fatto
**Enum** (`app/Enums/GoogleService.php`):
- Definisce 3 casi: `Email`, `Drive`, `Calendar`
- Metodi: `scopes()` (array scopes OAuth per servizio), `label()` (nome italiano), `icon()`, `description()` (testo help)
**Migration** (`2026_06_17_000001_create_google_oauth_connections_table.php`):
- Tabella `google_oauth_connections`: id, user_id (FK), service, email, access_token, refresh_token, expires_at, timestamps
- Unique constraint `[user_id, service, email]`
- Già eseguita in batch 39
**Migration** (`2026_06_17_000002_add_oauth_columns.php`):
- `email_settings`: aggiunti `auth_method` (varchar 20, default 'password') + `google_oauth_connection_id` (FK → google_oauth_connections, nullOnDelete)
- `sender_accounts`: stesse colonne
- Guardia `Schema::hasColumn` per idempotenza
- Già eseguita in batch 39
**Model** (`app/Models/GoogleOAuthConnection.php`):
- `$fillable`: user_id, service, email, access_token, refresh_token, expires_at
- `$casts`: expires_at → datetime
- `user()`: BelongsTo User
- `isExpired()`: controllo scadenza token
- `refresh()`: refresh automatico via GoogleOAuthService
- `getValidAccessToken()`: refresh se expired, ritorna token valido
**Service** (`app/Services/GoogleOAuthService.php`):
- `buildClient()`: configura Google_Client con client_id/secret/redirect_uri, access_type=offline, prompt=consent
- `getAuthUrl(service)`: URL di autorizzazione con scopes specifici per servizio
- `handleCallback(code, service)`: scambia code per token, crea/aggiorna GoogleOAuthConnection
- `getAccessToken(connection)`: cache 5 min, refresh automatico se expired
- `refreshToken(connection)`: fetch nuovo token via refresh_token, elimina se refresh fallisce
- `revoke(connection)`: revoca token Google, elimina record DB
- `createXoAuth2SmtpTransport(email, token)`: SMTP transport con solo XOAuth2Authenticator (evita tentativi PLAIN/LOGIN)
- `getConnectionStatus()`: restituisce array con stato connessioni per ogni servizio
**Controller** (`app/Http/Controllers/GoogleOAuthController.php`):
- `redirect(service)`: reindirizza a Google OAuth
- `callback(request)`: gestisce risposta Google, crea connessione, redirect a impostazioni con success/error
- `revoke(connection)`: revoca connessione (solo proprietario)
- `status()`: JSON con stato connessioni
**Config/Route changes**:
- `config/services.php`: sezione `google` con `client_id`, `client_secret`, `redirect` (usa `config('app.url')` per compatibilità CLI)
- `routes/web.php`: 4 nuove route (`google-oauth.redirect`, `google-oauth.callback`, `google-oauth.revoke`, `google-oauth.status`) + `use App\Http\Controllers\GoogleOAuthController`
**View** (`resources/views/impostazioni/_google-oauth.blade.php`):
- Card con lista servizi (Gmail, Drive, Calendar), badge connesso/non connesso, pulsanti Connetti/Disconnetti
- JS: auto-click tab se `?tab=google` in URL (dopo callback)
**Modifiche EmailSetting.php**:
- `$fillable` + `auth_method`, `google_oauth_connection_id`
- `$casts` + `google_oauth_connection_id` → integer
- `googleOAuthConnection()`: BelongsTo relationship
- `getImapConfig()`: se auth_method=oauth, imposta `authentication => 'oauth'` e password = access token
- `getDecryptedPassword()`: se OAuth, risolve token invece di decrittare password
- `getDecryptedSmtpPassword()`: stessa logica per SMTP
- `resolveOAuthToken()`: carica relazione + chiama GoogleOAuthService::getAccessToken
- `getImapClient()`: passa `authentication => 'oauth'` se auth_method=oauth
- `getSmtpMailer()`: se OAuth → `buildOAuthSmtpMailer()` (XOAuth2 solo), altrimenti DSN normale
- `buildOAuthSmtpMailer()`: EsmtpTransport + XOAuth2Authenticator
- `buildSmtpDsn()`: estratto da EmailSettingsController (password decrittata)
**Modifiche SenderAccount.php**:
- Stesso pattern: `auth_method`, `google_oauth_connection_id`, relationship, `resolveOAuthToken()`
- `getDecryptedPassword()`: OAuth-aware
- `sendEmail()`: se OAuth → `buildOAuthMailer()` (XOAuth2), altrimenti DSN
- `buildOAuthMailer()`: EsmtpTransport + XOAuth2Authenticator
**Modifiche EmailSettingsController.php**:
- `index()`: passa `$googleStatus``GoogleOAuthService::getConnectionStatus()`
- `save()`: validazione + salvataggio `auth_method`, `google_oauth_connection_id`
- `testSmtp()`: ora usa `$settings->getSmtpMailer()` invece di costruire DSN manualmente
- `senderStore()`/`senderUpdate()`: validazione + salvataggio `auth_method`, `google_oauth_connection_id`
**Modifiche impostazioni/index.blade.php**:
- Sidebar link "Google" (icona `fab fa-google`) prima del link "Calendario"
- Tab-pane con `@include("impostazioni._google-oauth")` prima del tab calendario
### Note tecniche
- **XOAUTH2**: Symfony Mailer ha già `XOAuth2Authenticator` in vendor (`vendor/symfony/mailer/Transport/Smtp/Auth/XOAuth2Authenticator.php`), invia `AUTH XOAUTH2 user=<email>\1auth=Bearer <token>\1\1`
- **ImapEngine**: già supporta `'authentication' => 'oauth'` in config → `AUTHENTICATE XOAUTH2 <base64>`
- **google/apiclient**: presente in vendor, usato per Google_Client (buildClient, fetchAccessTokenWithAuthCode, refresh, revoke). `Google\Service\Gmail` NON disponibile ma non necessario (XOAUTH2 bypassa API REST)
- **Permessi filesystem**: nuovi file copiati con sudo (www-data:www-data, 644), index.blade.php modificato con sed
- **refresh token**: richiede `access_type=offline` + `prompt=consent` + `includeGrantedScopes=true` per garantire refresh token sempre al primo auth
- **config('app.url')** in services.php invece di `url()` helper (url() fallisce in CLI context)
### 2026-06-17 — Bug Fix: Tag massivo e per-riga per Eventi + Documenti
**Problemi riscontrati**:
1. Eventi: modal mass tag HTML corrotto (`&times;``<div class="modal fade" id="deleteModal"`)
2. Eventi + Documenti: nessun feedback visivo dopo submit mass tag (mancava `session('error')` su Eventi; mancavano ENTRAMBI su Documenti)
3. Eventi + Documenti: nessuna icona TAG per-riga nelle azioni
4. Documenti: mancava funzione `openSingleTag()` JS
**Fix**:
1. `resources/views/eventi/index.blade.php:340` — Ripristinato `&times;` nel close button del mass tag modal
2. `resources/views/eventi/index.blade.php:22-27` — Aggiunto `@if(session('error'))` alert danger
3. `resources/views/eventi/index.blade.php:283-285` — Aggiunto pulsante per-riga `fa-tags``openSingleTag(eventId)`
4. `resources/views/eventi/index.blade.php:427-431` — Aggiunta funzione JS `openSingleTag(eventId)`
5. `resources/views/documenti/index.blade.php:21-32` — Aggiunti `@if(session('success'))` + `@if(session('error'))` alert
6. `resources/views/documenti/index.blade.php:283-285` e `484-486` — Aggiunto pulsante per-riga `fa-tags` in vista griglia + lista
7. `resources/views/documenti/index.blade.php:1318-1323` — Aggiunta funzione JS `openSingleTag(docId)` con querySelector per `.doc-checkbox[value=...]`
8. Verifica JS brace balance: OK su entrambi (diff=0)
9. Verifica PHP lint: OK su entrambi i controller
## 2026-06-17 — Bug Fix: Documenti + Eventi (syntax error, strict_types, validazione)
### Problemi trovati e fixati
**1. CRITICO — Syntax error PHP in DocumentoController.php:239**
- **Problema**: Parentesi `)` extra alla fine del ternario: `/documenti');` invece di `/documenti';`
- **Root cause**: `$redirect = ... : '/documenti');` — parentesi di chiusura in eccesso
- **Effetto**: PHP parse error: `Unclosed '{' on line 197 does not match ')'` — l'intero metodo `update()` era rotto
- **Fix**: Rimosso `)` extra
**2. declare(strict_types=1) mancante** (violazione AGENTS.md)
- **Problema**: 5 file del dominio documenti/eventi non avevano `declare(strict_types=1)`, obbligatorio per PHP 8.4
- **File fixati**: `Documento.php`, `Evento.php`, `EventoController.php`, `TipologiaDocumento.php`, `EventoDocumentoController.php`
**3. Validazione assente `contesto_tipo` in DocumentoController@update**
- **Problema**: Il campo `contesto_tipo` veniva letto dal raw request (`$request->contesto_tipo`) senza validazione. Un utente poteva forzare valori arbitrari.
- **Fix**: Aggiunto `'contesto_tipo' => 'nullable|in:individuo,gruppo,evento,mailing'` alle regole di validazione. Ora si usa il valore validato (`$contestoTipo`) invece del raw request.
**4. Tipologia hardcoded in DocumentoController@store**
- **Problema**: `store()` usava `'tipologia' => 'required|in:avatar,galleria,documento,statuto,altro'` (hardcoded), mentre `update()` usava `TipologiaDocumento::opzioni()` (dinamico). Nuove tipologie aggiunte via UI non sarebbero state accettate da `store()`.
- **Fix**: Allineato `store()` a usare `TipologiaDocumento::opzioni()` come `update()`.
**5. JS brace balance verificato**
- `resources/views/documenti/index.blade.php` → OK (diff=0)
- `resources/views/eventi/index.blade.php` → OK (diff=0)
### Verifica
- `php -l` su tutti e 6 i file modificati: nessun errore di sintassi
## 2026-06-17 — Fix: Eventi mass tag "nessun evento selezionato"
**Problema**: La toolbar button chiamava `$('#massTagModal').modal('show')` direttamente, affidandosi a un listener `show.bs.modal` per popolare l'hidden field `massTagIds`. Se l'evento non veniva intercettato, `ids` restava vuoto e il server rispondeva "Nessun evento selezionato".
**Fix** (`resources/views/eventi/index.blade.php`):
1. Toolbar button: `onclick="$('#massTagModal').modal('show')"``onclick="showMassTagModal()"`
2. Rimosso listener `show.bs.modal` che popolava i campi
3. Aggiunta funzione `showMassTagModal()` (stesso pattern di Gruppi/Individui) che valida `getSelectedIds()` e popola i campi **prima** di aprire la modale
4. `openSingleTag()` ora chiama `showMassTagModal()` invece di mostrare la modale direttamente
## 2026-06-17 — Fix: Documenti — root mostra documenti di tutte le cartelle
**Problema**: Alla root (`/documenti`), la query includeva tutti i documenti locali (`WHERE repository_id IS NULL`), mostrando documenti di tutte le cartelle in un unico elenco piatto.
**Fix** (`app/Http/Controllers/DocumentoController.php`):
- Aggiunto `->whereNull('cartella_id')` alla query di root, così mostra solo documenti **senza cartella** (radice). Per vedere i documenti dentro una cartella bisogna navigarci dentro con `?folder_id=X`.
## 2026-06-17 — Fix: Documenti mass tag "nessun documento selezionato"
**Problema**: Stesso identico bug di Eventi — toolbar button chiamava `$('#massTagModal').modal('show')` e il listener `show.bs.modal` non sempre popolava i campi.
**Fix** (`resources/views/documenti/index.blade.php`):
1. Rimosso listener `show.bs.modal`
2. Creata funzione `showMassTagModal()` (con fallback per `data-pre-selected`)
3. `openSingleTag()` ora usa `showMassTagModal()` invece di mostrare la modale direttamente
## 2026-06-17 — Fix: ColumnManager pin colonne select/azioni con `cells[]` invece di `querySelector`
**Problema**: Il pinning di `select` e `azioni` usava `row.querySelector('[data-column="..."]')` che funziona solo sul `<thead>` (dove `<th>` ha `data-column`), ma non sul `<tbody>` (dove i `<td>` non hanno `data-column`).
**Fix** (`public/js/column-manager.js`): Sostituito con `cells[keyToThIndex['select']]` che usa l'indice della colonna, valido sia per `th` che per `td`.
### DA FARE
- Configurare `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` in `.env`
- Test end-to-end: flusso OAuth completo (redirect → auth → callback → connessione creata)
- Test XOAUTH2 SMTP: invio email via connessione OAuth
- Test IMAP OAuth: sync email via connessione OAuth
- Verificare refresh token automatico allo scadere
- Verificare revoca e riconnessione
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum GoogleService: string
{
case Email = 'email';
case Drive = 'drive';
case Calendar = 'calendar';
public function scopes(): array
{
return match ($this) {
self::Email => ['https://mail.google.com/'],
self::Drive => ['https://www.googleapis.com/auth/drive'],
self::Calendar => ['https://www.googleapis.com/auth/calendar'],
};
}
public function label(): string
{
return match ($this) {
self::Email => 'Gmail',
self::Drive => 'Google Drive',
self::Calendar => 'Google Calendar',
};
}
public function icon(): string
{
return match ($this) {
self::Email => 'fas fa-envelope',
self::Drive => 'fab fa-google-drive',
self::Calendar => 'fas fa-calendar-alt',
};
}
public function description(): string
{
return match ($this) {
self::Email => 'Invia e ricevi email tramite Gmail API (alternativa a SMTP/IMAP con password)',
self::Drive => 'Accedi ai file su Google Drive per documenti e allegati',
self::Calendar => 'Sincronizza eventi con Google Calendar',
};
}
}
@@ -2,11 +2,13 @@
namespace App\Http\Controllers\Admin;
use App\Enums\GoogleService;
use App\Http\Controllers\Controller;
use App\Models\EmailAttachment;
use App\Models\EmailFolder;
use App\Models\EmailSetting;
use App\Models\SenderAccount;
use App\Services\GoogleOAuthService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
@@ -19,7 +21,8 @@ class EmailSettingsController extends Controller
$this->authorizeWrite('settings');
$settings = EmailSetting::first() ?? new EmailSetting();
$senderAccounts = SenderAccount::orderBy('email_address')->get();
return view('admin.email-settings.index', compact('settings', 'senderAccounts'));
$googleStatus = app(GoogleOAuthService::class)->getConnectionStatus();
return view('admin.email-settings.index', compact('settings', 'senderAccounts', 'googleStatus'));
}
public function save(Request $request)
@@ -43,6 +46,8 @@ class EmailSettingsController extends Controller
'is_active' => 'boolean',
'signature' => 'nullable|string',
'signature_enabled' => 'boolean',
'auth_method' => 'nullable|in:password,oauth',
'google_oauth_connection_id' => 'nullable|integer|exists:google_oauth_connections,id',
]);
if (!empty($validated['imap_password'])) {
@@ -61,29 +66,17 @@ class EmailSettingsController extends Controller
$validated
);
// Verifica che i dati siano stati salvati
$verify = EmailSetting::find($saved->id);
if ($verify &&
$allGood = $verify &&
$verify->imap_host === $validated['imap_host'] &&
$verify->email_address === $validated['email_address']) {
$verify->email_address === $validated['email_address'];
$signatureMatch = !array_key_exists('signature', $validated) || $verify->signature === $validated['signature'];
$signatureEnabledMatch = !array_key_exists('signature_enabled', $validated) || (bool)$verify->signature_enabled === (bool)$validated['signature_enabled'];
if (!$signatureMatch) {
\Illuminate\Support\Facades\Log::warning('Signature mismatch after save', [
'expected' => $validated['signature'] ?? null,
'actual' => $verify->signature,
]);
}
if (!$signatureEnabledMatch) {
\Illuminate\Support\Facades\Log::warning('Signature enabled mismatch after save', [
'expected' => $validated['signature_enabled'] ?? null,
'actual' => $verify->signature_enabled,
]);
}
$allGood = $allGood &&
(!array_key_exists('signature', $validated) || $verify->signature === $validated['signature']) &&
(!array_key_exists('signature_enabled', $validated) || (bool)$verify->signature_enabled === (bool)$validated['signature_enabled']);
if ($allGood) {
return back()->with('success', 'Impostazioni email salvate e verificate nel database.');
} else {
return back()->with('error', 'Errore nel salvataggio: i dati non sono stati salvati correttamente.');
@@ -99,7 +92,6 @@ class EmailSettingsController extends Controller
return response()->json(['success' => false, 'message' => 'Configurazione non trovata.']);
}
// Verifica che i dati siano salvati
if (empty($settings->imap_host) || empty($settings->email_address)) {
return response()->json(['success' => false, 'message' => 'Prima salva le impostazioni.']);
}
@@ -129,41 +121,13 @@ class EmailSettingsController extends Controller
return response()->json(['success' => false, 'message' => 'Configurazione non trovata.']);
}
if (empty($settings->smtp_host)) {
$settings->smtp_host = 'smtp.gmail.com';
$settings->smtp_port = 587;
$settings->smtp_encryption = 'tls';
}
try {
$smtpUsername = $settings->smtp_username;
$smtpPassword = $settings->smtp_password;
if (empty($smtpUsername)) {
$smtpUsername = $settings->email_address;
}
if (empty($smtpPassword)) {
$smtpPassword = $settings->getDecryptedPassword();
} else {
$smtpPassword = Crypt::decryptString($smtpPassword);
$mailer = $settings->getSmtpMailer();
if ($mailer === null) {
return response()->json(['success' => false, 'message' => 'Impossibile creare il mailer SMTP. Verifica la configurazione.']);
}
$encryption = $settings->smtp_encryption ?? 'tls';
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp';
$dsn = sprintf(
'%s://%s:%s@%s:%d?encryption=%s',
$scheme,
rawurlencode($smtpUsername),
rawurlencode($smtpPassword),
$settings->smtp_host,
$settings->smtp_port ?? 587,
$encryption
);
$transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn);
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
$fromName = $settings->email_name ?? '';
if (filter_var($fromName, FILTER_VALIDATE_EMAIL)) {
$fromName = 'Glastree';
@@ -182,7 +146,7 @@ class EmailSettingsController extends Controller
->to($request->email)
->subject('Test Configurazione SMTP - Glastree')
->text('Test email da Glastree - Configurazione SMTP funziona!');
$mailer->send($email);
\Illuminate\Support\Facades\Log::info('Test SMTP ok', ['to' => $request->email, 'from' => $settings->email_address]);
@@ -227,6 +191,8 @@ class EmailSettingsController extends Controller
'verify_email' => 'nullable|email|max:255',
'note' => 'nullable|string|max:500',
'is_active' => 'boolean',
'auth_method' => 'nullable|in:password,oauth',
'google_oauth_connection_id' => 'nullable|integer|exists:google_oauth_connections,id',
]);
if (!empty($data['smtp_password'])) {
@@ -257,6 +223,8 @@ class EmailSettingsController extends Controller
'verify_email' => 'nullable|email|max:255',
'note' => 'nullable|string|max:500',
'is_active' => 'boolean',
'auth_method' => 'nullable|in:password,oauth',
'google_oauth_connection_id' => 'nullable|integer|exists:google_oauth_connections,id',
]);
if (!empty($data['smtp_password'])) {
@@ -326,4 +294,4 @@ class EmailSettingsController extends Controller
return response()->json(['success' => false, 'message' => 'Errore invio: ' . $e->getMessage()]);
}
}
}
}
+21 -9
View File
@@ -56,7 +56,7 @@ class DocumentoController extends Controller
} elseif ($folderId) {
$query->where('cartella_id', $folderId);
} else {
$query->whereNull('repository_id');
$query->whereNull('repository_id')->whereNull('cartella_id');
}
$documenti = $query->paginate(20);
@@ -204,24 +204,24 @@ class DocumentoController extends Controller
$data = $request->validate([
'nome_file' => 'required|string|max:255',
'tipologia' => 'required|' . $tipologieRule,
'visibilita' => 'nullable|string',
'contesto_tipo' => 'nullable|in:individuo,gruppo,evento,mailing',
'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string',
'note' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
$data['visibilita'] = $request->contesto_tipo ?: 'pubblico';
$contestoTipo = $data['contesto_tipo'] ?? null;
$data['visibilita'] = $contestoTipo ?: 'pubblico';
if ($request->contesto_tipo) {
if ($contestoTipo) {
$typeMap = [
'individuo' => Individuo::class,
'gruppo' => Gruppo::class,
'evento' => Evento::class,
'mailing' => \App\Models\MailingList::class,
];
$data['visibilita_target_type'] = $typeMap[$request->contesto_tipo] ?? null;
$data['visibilita_target_type'] = $typeMap[$contestoTipo] ?? null;
} else {
$data['visibilita'] = 'pubblico';
$data['visibilita_target_id'] = null;
@@ -236,21 +236,27 @@ class DocumentoController extends Controller
$documento->tags()->detach();
}
return redirect('/documenti')->with('success', 'Documento aggiornato.');
$redirect = $request->input('folder_id') ? '/documenti?folder_id=' . $request->input('folder_id') : '/documenti';
return redirect($redirect)->with('success', 'Documento aggiornato.');
}
public function store(Request $request)
{
$this->authorizeWrite('documenti');
$tipologieValidi = TipologiaDocumento::opzioni();
$tipologieRule = 'in:' . implode(',', $tipologieValidi);
$data = $request->validate([
'nome_file' => 'required|string|max:255',
'tipologia' => 'required|in:avatar,galleria,documento,statuto,altro',
'tipologia' => 'required|' . $tipologieRule,
'file' => 'required|file|max:10240',
'visibilita' => 'required|in:pubblico,individuo,gruppo',
'visibilita_target_id' => 'nullable|integer',
'visibilita_target_type' => 'nullable|string',
'cartella_id' => 'nullable|integer|exists:documenti_cartelle,id',
'repository_id' => 'nullable|integer|exists:storage_repositories,id',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
]);
$repositoryId = $data['repository_id'] ?? null;
@@ -284,7 +290,7 @@ class DocumentoController extends Controller
$visibilitaTargetType = Gruppo::class;
}
Documento::create([
$doc = Documento::create([
'nome_file' => $data['nome_file'],
'file_path' => $filePath,
'storage_disk' => $disk ?? null,
@@ -300,7 +306,13 @@ class DocumentoController extends Controller
'repository_id' => $repositoryId,
]);
if ($doc && $request->has('tags')) {
$doc->tags()->sync($request->tags);
}
$redirect = $request->_redirect ?? url()->previous();
if ($request->filled('folder_id')) {
$redirect = '/documenti?folder_id=' . $request->input('folder_id');
}
return redirect($redirect)->with('success', 'Documento caricato.');
}
+37
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Evento;
@@ -630,4 +632,39 @@ class EventoController extends Controller
return redirect('/eventi')->with('success', $message);
}
public function massTag(Request $request)
{
$this->authorizeWrite('eventi');
$idsInput = $request->input('ids', '');
$ids = is_array($idsInput) ? $idsInput : (is_string($idsInput) ? explode(',', $idsInput) : []);
if (empty($ids)) {
return back()->with('error', 'Nessun evento selezionato.');
}
$data = $request->validate([
'tags' => 'required|array',
'tags.*' => 'exists:tags,id',
'mode' => 'required|in:assign,remove',
]);
$tagIds = $data['tags'];
$mode = $data['mode'];
$count = 0;
Evento::whereIn('id', $ids)->chunk(100, function ($eventi) use ($tagIds, $mode, &$count) {
foreach ($eventi as $evento) {
if ($mode === 'assign') {
$evento->tags()->syncWithoutDetaching($tagIds);
} else {
$evento->tags()->detach($tagIds);
}
$count++;
}
});
$actionLabel = $mode === 'assign' ? 'assegnati' : 'rimossi';
return back()->with('success', "Tag $actionLabel per $count eventi.");
}
}
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Evento;
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Enums\GoogleService;
use App\Models\GoogleOAuthConnection;
use App\Services\GoogleOAuthService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class GoogleOAuthController extends Controller
{
public function __construct(
private readonly GoogleOAuthService $googleOAuthService,
) {}
public function redirect(string $service): RedirectResponse
{
try {
$serviceEnum = GoogleService::from($service);
} catch (\ValueError $e) {
return redirect()->back()->withErrors(['error' => 'Servizio Google non valido: ' . $service]);
}
$authUrl = $this->googleOAuthService->getAuthUrl($service);
return redirect()->away($authUrl);
}
public function callback(Request $request): RedirectResponse
{
$error = $request->input('error');
if ($error) {
$message = match ($error) {
'access_denied' => 'Accesso negato. Autorizzazione non concessa.',
default => 'Errore Google: ' . $error,
};
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => $message]);
}
$code = $request->input('code');
if (!$code) {
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => 'Nessun codice di autorizzazione ricevuto']);
}
$state = $request->input('state', 'email');
try {
$service = GoogleService::from($state);
} catch (\ValueError $e) {
$service = GoogleService::Email;
}
try {
$connection = $this->googleOAuthService->handleCallback($code, $service);
$message = match ($service) {
GoogleService::Email => 'Account email Gmail connesso con successo!',
GoogleService::Drive => 'Account Google Drive connesso con successo!',
GoogleService::Calendar => 'Account Google Calendar connesso con successo!',
};
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->with('success', $message);
} catch (\Exception $e) {
Log::error('Google OAuth callback error', [
'error' => $e->getMessage(),
'service' => $service->value,
]);
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => 'Errore durante la connessione: ' . $e->getMessage()]);
}
}
public function revoke(GoogleOAuthConnection $connection): RedirectResponse
{
if ($connection->user_id !== auth()->id()) {
abort(403);
}
try {
$service = GoogleService::from($connection->service);
$message = match ($service) {
GoogleService::Email => 'Account email Gmail disconnesso.',
GoogleService::Drive => 'Account Google Drive disconnesso.',
GoogleService::Calendar => 'Account Google Calendar disconnesso.',
};
$this->googleOAuthService->revoke($connection);
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->with('success', $message);
} catch (\Exception $e) {
Log::error('Google OAuth revoke error', [
'error' => $e->getMessage(),
'connection_id' => $connection->id,
]);
return redirect()->route('impostazioni.index', ['tab' => 'google'])
->withErrors(['error' => 'Errore durante la disconnessione: ' . $e->getMessage()]);
}
}
public function status(): \Illuminate\Http\JsonResponse
{
$status = $this->googleOAuthService->getConnectionStatus();
return response()->json($status);
}
}
@@ -10,6 +10,7 @@ use App\Models\Ruolo;
use App\Models\SenderAccount;
use App\Models\StorageRepository;
use App\Models\Tag;
use App\Services\GoogleOAuthService;
use App\Models\TipologiaDocumento;
use App\Models\TipologiaEvento;
use Illuminate\Http\RedirectResponse;
@@ -34,8 +35,9 @@ class ImpostazioniController extends Controller
$repositories = StorageRepository::orderBy('ordine')->get();
$googleDriveNewToken = session('google_drive_new_token');
$calendarioConnessioni = CalendarioConnessione::orderBy('ordine')->get();
$googleStatus = app(GoogleOAuthService::class)->getConnectionStatus();
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'tags', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni'));
return view('impostazioni.index', compact('tipologie', 'tipologieEventi', 'ruoli', 'tags', 'appSettings', 'emailSettings', 'senderAccounts', 'repositories', 'googleDriveNewToken', 'calendarioConnessioni', 'googleStatus'));
}
public function saveAppSettings(Request $request)
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Traits\HasTagsLight;
+120 -7
View File
@@ -2,7 +2,9 @@
namespace App\Models;
use App\Services\GoogleOAuthService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Crypt;
@@ -14,13 +16,15 @@ class EmailSetting extends Model
'imap_host', 'imap_port', 'imap_encryption', 'imap_username', 'imap_password',
'smtp_host', 'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password',
'email_address', 'email_name', 'reply_to', 'sync_interval_minutes',
'last_sync_at', 'is_active', 'signature', 'signature_enabled'
'last_sync_at', 'is_active', 'signature', 'signature_enabled',
'auth_method', 'google_oauth_connection_id',
];
protected $casts = [
'is_active' => 'boolean',
'signature_enabled' => 'boolean',
'last_sync_at' => 'datetime',
'google_oauth_connection_id' => 'integer',
];
public function folders(): HasMany
@@ -28,8 +32,25 @@ class EmailSetting extends Model
return $this->hasMany(EmailFolder::class)->orderBy('sort_order');
}
public function googleOAuthConnection(): BelongsTo
{
return $this->belongsTo(GoogleOAuthConnection::class);
}
public function getImapConfig(): array
{
if ($this->auth_method === 'oauth') {
$token = $this->resolveOAuthToken();
return [
'host' => $this->imap_host,
'port' => $this->imap_port,
'encryption' => $this->imap_encryption,
'username' => $this->imap_username,
'authentication' => 'oauth',
'password' => $token,
];
}
return [
'host' => $this->imap_host,
'port' => $this->imap_port,
@@ -41,6 +62,10 @@ class EmailSetting extends Model
public function getDecryptedPassword(): string
{
if ($this->auth_method === 'oauth') {
return $this->resolveOAuthToken();
}
if (empty($this->imap_password)) {
return '';
}
@@ -53,6 +78,10 @@ class EmailSetting extends Model
public function getDecryptedSmtpPassword(): string
{
if ($this->auth_method === 'oauth') {
return $this->resolveOAuthToken();
}
if (empty($this->smtp_password)) {
return $this->getDecryptedPassword();
}
@@ -63,6 +92,22 @@ class EmailSetting extends Model
}
}
protected function resolveOAuthToken(): string
{
if (!$this->relationLoaded('googleOAuthConnection')) {
$this->load('googleOAuthConnection');
}
$connection = $this->googleOAuthConnection;
if (!$connection) {
return '';
}
try {
return app(GoogleOAuthService::class)->getAccessToken($connection);
} catch (\Exception $e) {
return '';
}
}
public function getSmtpConfig(): array
{
return [
@@ -87,33 +132,101 @@ class EmailSetting extends Model
default => 'none',
};
return new \DirectoryTree\ImapEngine\Mailbox([
$config = [
'host' => $this->imap_host,
'port' => $this->imap_port ?? 993,
'encryption' => $encryption,
'validate_cert' => false,
'username' => $this->imap_username,
'password' => $this->getDecryptedPassword(),
]);
];
if ($this->auth_method === 'oauth') {
$config['authentication'] = 'oauth';
}
return new \DirectoryTree\ImapEngine\Mailbox($config);
} catch (\Exception $e) {
return null;
}
}
public function getSmtpMailer(): ?\Symfony\Component\Mailer\Mailer
{
if ($this->auth_method === 'oauth') {
return $this->buildOAuthSmtpMailer();
}
$dsn = $this->buildSmtpDsn();
if (!$dsn) {
return null;
}
try {
$transport = \Symfony\Component\Mailer\Transport::fromDsn($dsn);
return new \Symfony\Component\Mailer\Mailer($transport);
} catch (\Exception $e) {
return null;
}
}
protected function buildOAuthSmtpMailer(): ?\Symfony\Component\Mailer\Mailer
{
$token = $this->resolveOAuthToken();
if (empty($token)) {
return null;
}
$username = $this->smtp_username ?? $this->email_address;
$host = $this->smtp_host ?? 'smtp.gmail.com';
$port = $this->smtp_port ?? 587;
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport($host, $port, true);
$transport->setUsername($username);
$transport->setPassword($token);
$transport->setAuthenticators([
new \Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator(),
]);
return new \Symfony\Component\Mailer\Mailer($transport);
}
public function buildSmtpDsn(): ?string
{
$password = $this->getDecryptedSmtpPassword();
if (empty($password)) {
return null;
}
$username = $this->smtp_username ?? $this->email_address;
$encryption = $this->smtp_encryption ?? 'tls';
$scheme = ($encryption === 'ssl') ? 'smtps' : 'smtp';
return sprintf(
'%s://%s:%s@%s:%d?encryption=%s',
$scheme,
rawurlencode($username),
rawurlencode($password),
$this->smtp_host ?? 'smtp.gmail.com',
$this->smtp_port ?? 587,
$encryption
);
}
public function testConnection(): array
{
try {
$mailbox = $this->getImapClient();
if (!$mailbox) {
return ['success' => false, 'message' => 'Configurazione IMAP non valida'];
}
$mailbox->connect();
$mailbox->disconnect();
return [
'success' => true,
'success' => true,
'message' => 'Connessione IMAP riuscita! Server: ' . $this->imap_host
];
} catch (\Exception $e) {
@@ -134,4 +247,4 @@ class EmailSetting extends Model
{
return $this->signature;
}
}
}
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Traits\HasTagsLight;
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Services\GoogleOAuthService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GoogleOAuthConnection extends Model
{
protected $table = 'google_oauth_connections';
protected $fillable = [
'user_id',
'service',
'email',
'access_token',
'refresh_token',
'expires_at',
];
protected $casts = [
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
public function refresh(): void
{
if (!$this->refresh_token) {
throw new \RuntimeException('Nessun refresh token disponibile per ' . $this->email);
}
app(GoogleOAuthService::class)->refreshToken($this);
}
public function getValidAccessToken(): string
{
if ($this->isExpired() && $this->refresh_token) {
$this->refresh();
}
return $this->access_token;
}
}
+54 -2
View File
@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Models;
use App\Services\GoogleOAuthService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
class SenderAccount extends Model
@@ -24,11 +26,14 @@ class SenderAccount extends Model
'verify_email',
'note',
'is_active',
'auth_method',
'google_oauth_connection_id',
];
protected $casts = [
'is_active' => 'boolean',
'smtp_port' => 'integer',
'google_oauth_connection_id' => 'integer',
];
public function scopeActive(Builder $query): Builder
@@ -36,8 +41,33 @@ class SenderAccount extends Model
return $query->where('is_active', true);
}
public function googleOAuthConnection(): BelongsTo
{
return $this->belongsTo(GoogleOAuthConnection::class);
}
protected function resolveOAuthToken(): string
{
if (!$this->relationLoaded('googleOAuthConnection')) {
$this->load('googleOAuthConnection');
}
$connection = $this->googleOAuthConnection;
if (!$connection) {
return '';
}
try {
return app(GoogleOAuthService::class)->getAccessToken($connection);
} catch (\Exception $e) {
return '';
}
}
public function getDecryptedPassword(): string
{
if ($this->auth_method === 'oauth') {
return $this->resolveOAuthToken();
}
if (empty($this->smtp_password)) {
return '';
}
@@ -121,11 +151,33 @@ class SenderAccount extends Model
$email->attachFromPath($path);
}
$transport = \Symfony\Component\Mailer\Transport::fromDsn($this->buildDsn());
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
if ($this->auth_method === 'oauth') {
$mailer = $this->buildOAuthMailer();
} else {
$transport = \Symfony\Component\Mailer\Transport::fromDsn($this->buildDsn());
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
}
$mailer->send($email);
}
protected function buildOAuthMailer(): \Symfony\Component\Mailer\Mailer
{
$token = $this->resolveOAuthToken();
$username = $this->smtp_username ?? $this->email_address;
$host = $this->smtp_host ?? 'smtp.gmail.com';
$port = $this->smtp_port ?? 587;
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport($host, $port, true);
$transport->setUsername($username);
$transport->setPassword($token);
$transport->setAuthenticators([
new \Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator(),
]);
return new \Symfony\Component\Mailer\Mailer($transport);
}
public function sendReport(string $subject, string $body): bool
{
if (empty($this->verify_email)) {
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
+178
View File
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\GoogleOAuthConnection;
use App\Enums\GoogleService;
use Google_Client;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class GoogleOAuthService
{
private const TOKEN_CACHE_TTL = 300;
private const MAX_RETRIES = 2;
public function buildClient(): Google_Client
{
$client = new Google_Client();
$client->setClientId(config('services.google.client_id'));
$client->setClientSecret(config('services.google.client_secret'));
$client->setRedirectUri(route('google-oauth.callback'));
$client->setAccessType('offline');
$client->setPrompt('consent');
$client->setIncludeGrantedScopes(true);
return $client;
}
public function getAuthUrl(string $service): string
{
$serviceEnum = GoogleService::from($service);
$client = $this->buildClient();
$client->setScopes($serviceEnum->scopes());
$client->setState($service);
return $client->createAuthUrl();
}
public function handleCallback(string $authorizationCode, GoogleService $service): GoogleOAuthConnection
{
$client = $this->buildClient();
$client->setScopes($service->scopes());
$client->fetchAccessTokenWithAuthCode($authorizationCode);
$tokenData = $client->getAccessToken();
if (isset($tokenData['error'])) {
throw new \RuntimeException('Google OAuth error: ' . ($tokenData['error_description'] ?? $tokenData['error']));
}
$token = $tokenData['access_token'];
$refreshToken = $tokenData['refresh_token'] ?? null;
$expiresAt = now()->addSeconds($tokenData['expires_in']);
$email = $this->getEmailFromToken($token);
$connection = GoogleOAuthConnection::updateOrCreate([
'user_id' => auth()->id(),
'service' => $service->value,
'email' => $email,
], [
'access_token' => $token,
'refresh_token' => $refreshToken,
'expires_at' => $expiresAt,
]);
return $connection;
}
public function getAccessToken(GoogleOAuthConnection $connection): string
{
$cacheKey = "google_oauth_token_{$connection->id}";
return Cache::remember($cacheKey, self::TOKEN_CACHE_TTL, function () use ($connection) {
if ($connection->isExpired()) {
$this->refreshToken($connection);
}
return $connection->access_token;
});
}
public function refreshToken(GoogleOAuthConnection $connection): void
{
if (!$connection->refresh_token) {
throw new \RuntimeException('Impossibile rinnovare: nessun refresh token');
}
$client = $this->buildClient();
$client->fetchAccessTokenWithRefreshToken($connection->refresh_token);
$tokenData = $client->getAccessToken();
if (isset($tokenData['error'])) {
$connection->delete();
Cache::forget("google_oauth_token_{$connection->id}");
throw new \RuntimeException(
'Refresh token scaduto o revocato per ' . $connection->email
);
}
$connection->access_token = $tokenData['access_token'];
$connection->expires_at = now()->addSeconds($tokenData['expires_in']);
if (isset($tokenData['refresh_token'])) {
$connection->refresh_token = $tokenData['refresh_token'];
}
$connection->save();
Cache::forget("google_oauth_token_{$connection->id}");
}
public function revoke(GoogleOAuthConnection $connection): void
{
$client = $this->buildClient();
$client->revokeToken($connection->access_token);
$connection->delete();
Cache::forget("google_oauth_token_{$connection->id}");
}
public function createXoAuth2SmtpTransport(
string $email,
string $accessToken
): \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport {
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
'smtp.gmail.com',
587,
true,
);
$transport->setUsername($email);
$transport->setPassword($accessToken);
$transport->setAuthenticators([
new \Symfony\Component\Mailer\Transport\Smtp\Auth\XOAuth2Authenticator(),
]);
return $transport;
}
private function getEmailFromToken(string $accessToken): string
{
$client = $this->buildClient();
$payload = $client->verifyIdToken($accessToken);
if ($payload && isset($payload['email'])) {
return $payload['email'];
}
$http = new \GuzzleHttp\Client();
$response = $http->get('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', [
'headers' => ['Authorization' => "Bearer $accessToken"],
]);
$data = json_decode((string) $response->getBody(), true);
return $data['email'];
}
public function getConnectionStatus(): array
{
$connections = GoogleOAuthConnection::where('user_id', auth()->id())->get();
$status = [];
foreach (GoogleService::cases() as $service) {
$connection = $connections->firstWhere('service', $service->value);
$status[$service->value] = [
'connected' => $connection !== null,
'connection' => $connection,
'email' => $connection?->email,
'expired' => $connection?->isExpired() ?? false,
];
}
return $status;
}
}
+6
View File
@@ -35,4 +35,10 @@ return [
],
],
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI', config('app.url') . '/auth/google/callback'),
],
];
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('google_oauth_connections', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('service'); // email, drive, calendar
$table->string('email');
$table->text('access_token');
$table->text('refresh_token')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'service', 'email']);
});
}
public function down(): void
{
Schema::dropIfExists('google_oauth_connections');
}
};
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasColumn('email_settings', 'auth_method')) {
Schema::table('email_settings', function (Blueprint $table) {
$table->string('auth_method', 20)->default('password');
$table->foreignId('google_oauth_connection_id')
->nullable()
->constrained('google_oauth_connections')
->nullOnDelete();
});
}
if (!Schema::hasColumn('sender_accounts', 'auth_method')) {
Schema::table('sender_accounts', function (Blueprint $table) {
$table->string('auth_method', 20)->default('password');
$table->foreignId('google_oauth_connection_id')
->nullable()
->constrained('google_oauth_connections')
->nullOnDelete();
});
}
}
public function down(): void
{
Schema::table('email_settings', function (Blueprint $table) {
if (Schema::hasColumn('email_settings', 'google_oauth_connection_id')) {
$table->dropForeign(['google_oauth_connection_id']);
}
if (Schema::hasColumn('email_settings', 'auth_method')) {
$table->dropColumn(['auth_method', 'google_oauth_connection_id']);
}
});
Schema::table('sender_accounts', function (Blueprint $table) {
if (Schema::hasColumn('sender_accounts', 'google_oauth_connection_id')) {
$table->dropForeign(['google_oauth_connection_id']);
}
if (Schema::hasColumn('sender_accounts', 'auth_method')) {
$table->dropColumn(['auth_method', 'google_oauth_connection_id']);
}
});
}
};
+30 -4
View File
@@ -145,8 +145,11 @@ class ColumnManager {
let dragIndex = -1;
let placeholder = null;
const pinnedKeys = ['select', 'azioni'];
thead.querySelectorAll('th').forEach(th => {
if (th.querySelector('.col-resize-handle')) return;
const colKey = th.dataset.column;
if (!colKey || pinnedKeys.includes(colKey)) return;
th.draggable = true;
th.addEventListener('dragstart', (e) => {
@@ -228,8 +231,10 @@ class ColumnManager {
const ths = this.table.querySelectorAll('thead th');
this.columnOrder = [];
const keysInOrder = [];
const pinnedKeys = ['select', 'azioni'];
ths.forEach((th, idx) => {
const colKey = th.dataset.column || `col_${idx}`;
if (pinnedKeys.includes(colKey)) return;
keysInOrder.push(colKey);
this.columnOrder.push(colKey);
});
@@ -240,6 +245,11 @@ class ColumnManager {
reorderColumns(keyOrder, persist = true) {
if (!keyOrder || keyOrder.length === 0) return;
const pinnedKeys = ['select', 'azioni'];
const dataOrder = keyOrder.filter(k => !pinnedKeys.includes(k));
if (dataOrder.length === 0) return;
const thead = this.table.querySelector('thead');
if (!thead) return;
@@ -257,7 +267,7 @@ class ColumnManager {
const reordered = [];
let lastRef = null;
keyOrder.forEach(key => {
dataOrder.forEach(key => {
const srcIdx = keyToThIndex[key];
if (srcIdx !== undefined && cells[srcIdx]) {
reordered.push(cells[srcIdx]);
@@ -272,10 +282,26 @@ class ColumnManager {
}
lastRef = cell;
});
const selectIdx = keyToThIndex['select'];
if (selectIdx !== undefined) {
const selectCell = cells[selectIdx];
if (selectCell && row.firstChild !== selectCell) {
row.insertBefore(selectCell, row.firstChild);
}
}
const azioniIdx = keyToThIndex['azioni'];
if (azioniIdx !== undefined) {
const azioniCell = cells[azioniIdx];
if (azioniCell) {
row.appendChild(azioniCell);
}
}
});
this.columnOrder = [...keyOrder];
this.columns.sort((a, b) => keyOrder.indexOf(a.key) - keyOrder.indexOf(b.key));
this.columnOrder = [...dataOrder];
this.columns.sort((a, b) => dataOrder.indexOf(a.key) - dataOrder.indexOf(b.key));
this.columns.forEach((col, idx) => col.index = idx);
if (persist) this.persistState();
-34
View File
@@ -105,40 +105,6 @@
</div>
@include('partials._tag-selector', ['selectedTags' => $selectedTags ?? [], 'label' => 'Etichette'])
</div>
<div class="card-body">
<table class="table table-sm table-borderless">
<tr>
<td style="width: 100px;"><strong>File:</strong></td>
<td>{{ $documento->file_path }}</td>
</tr>
<tr>
<td><strong>Tipo MIME:</strong></td>
<td>{{ $documento->mime_type ?? '-' }}</td>
</tr>
<tr>
<td><strong>Dimensione:</strong></td>
<td>{{ $documento->dimensione ? number_format($documento->dimensione / 1024, 1) . ' KB' : '-' }}</td>
</tr>
<tr>
<td><strong>Creato:</strong></td>
<td>{{ $documento->created_at->format('d/m/Y H:i') }}</td>
</tr>
@if($documento->user)
<tr>
<td><strong>Utente:</strong></td>
<td>{{ $documento->user->name }}</td>
</tr>
@endif
</table>
@if($documento->file_path)
<hr>
<a href="/documenti/{{ $documento->id }}/download" class="btn btn-primary btn-block">
<i class="fas fa-download mr-1"></i> Scarica
</a>
@endif
</div>
</div>
</div>
</div>
+63 -14
View File
@@ -18,6 +18,18 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
@endsection
@section('content')
@if(session('success'))
<div class="alert alert-success alert-dismissible">
<button type="button" class="close" data-dismiss="alert">&times;</button>
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible">
<button type="button" class="close" data-dismiss="alert">&times;</button>
{{ session('error') }}
</div>
@endif
<div class="row">
{{-- Sidebar cartelle --}}
<div class="col-md-3">
@@ -133,7 +145,7 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
<form id="massForm" method="POST">
@csrf
<input type="hidden" name="_method" id="formMethod" value="POST">
@if($canDeleteDocumenti)
@if($canWriteDocumenti)
<div class="p-3 bg-light border-bottom d-flex align-items-center flex-wrap gap-2">
<span class="font-weight-bold mr-2">Azioni:</span>
<button type="button" class="btn btn-sm btn-danger" onclick="massAction('delete')">
@@ -149,7 +161,7 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
<button type="button" class="btn btn-sm btn-secondary" onclick="$('#massMoveModal').modal('show')">
<i class="fas fa-folder-open mr-1"></i> Sposta selezionati in...
</button>
<button type="button" class="btn btn-sm btn-info" onclick="$('#massTagModal').modal('show')">
<button type="button" class="btn btn-sm btn-info" onclick="showMassTagModal()">
<i class="fas fa-tags mr-1"></i> Tag
</button>
<button type="button" class="btn btn-sm btn-success" onclick="massDownload()">
@@ -186,7 +198,7 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
<i class="fas fa-folder-open"></i>
</button>
@endif
@if($canDeleteDocumenti)
@if($canWriteDocumenti)
<button type="button" class="btn btn-xs btn-danger" onclick='deleteFolder({{ $cartella->id }}, {{ json_encode($cartella->nome) }})' title="Elimina">
<i class="fas fa-times"></i>
</button>
@@ -213,13 +225,13 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
<div class="col-md-4 col-sm-6 mb-3">
<div class="card card-outline card-secondary h-100">
<div class="card-body p-3">
@if($canDeleteDocumenti)
@if($canWriteDocumenti)
<div class="float-right">
<input type="checkbox" name="ids[]" value="{{ $documento->id }}" class="doc-checkbox" onchange="updateCount()">
</div>
@endif
<h6 class="card-title font-weight-bold text-truncate mb-2" title="{{ $documento->nome_file }}">
{{ $documento->nome_file }}
<a href="/documenti/{{ $documento->id }}/edit">{{ $documento->nome_file }}</a>
</h6>
<div class="text-center py-2" style="font-size: 2.5rem;">
<i class="fas {{ $icon }}"></i>
@@ -268,8 +280,11 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
<button type="button" class="btn btn-xs btn-secondary" onclick="showUpdateModal({{ $documento->id }})" title="Cambia tipo/contesto">
<i class="fas fa-tag"></i>
</button>
<button type="button" class="btn btn-xs btn-info" onclick="openSingleTag({{ $documento->id }})" title="Assegna tag">
<i class="fas fa-tags"></i>
</button>
@endif
@if($canDeleteDocumenti)
@if($canWriteDocumenti)
<button type="button" class="btn btn-xs btn-danger" onclick="deleteDocumento({{ $documento->id }}, '{{ e($documento->nome_file) }}')" title="Elimina">
<i class="fas fa-trash"></i>
</button>
@@ -299,7 +314,7 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
<table class="table table-bordered table-hover table-striped mb-0" id="documenti-table">
<thead class="thead-light">
<tr>
@if($canDeleteDocumenti)
@if($canWriteDocumenti)
<th style="width: 40px;" data-column="select">
<input type="checkbox" id="selectAll" onchange="toggleAll(this)">
</th>
@@ -316,7 +331,7 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
<tbody>
@foreach($sottoCartelle as $cartella)
<tr class="table-warning">
@if($canDeleteDocumenti)
@if($canWriteDocumenti)
<td></td>
@endif
<td>
@@ -341,7 +356,7 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
<i class="fas fa-folder-open"></i>
</button>
@endif
@if($canDeleteDocumenti)
@if($canWriteDocumenti)
<button type="button" class="btn btn-xs btn-danger" onclick='deleteFolder({{ $cartella->id }}, {{ json_encode($cartella->nome) }})' title="Elimina">
<i class="fas fa-times"></i>
</button>
@@ -362,7 +377,7 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
};
@endphp
<tr>
@if($canDeleteDocumenti)
@if($canWriteDocumenti)
<td>
<input type="checkbox" name="ids[]" value="{{ $documento->id }}" class="doc-checkbox" onchange="updateCount()">
</td>
@@ -466,8 +481,11 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
<button type="button" class="btn btn-xs btn-secondary" onclick="showUpdateModal({{ $documento->id }})" title="Cambia tipo/contesto">
<i class="fas fa-tag"></i>
</button>
<button type="button" class="btn btn-xs btn-info" onclick="openSingleTag({{ $documento->id }})" title="Assegna tag">
<i class="fas fa-tags"></i>
</button>
@endif
@if($canDeleteDocumenti)
@if($canWriteDocumenti)
<button type="button" class="btn btn-xs btn-danger" onclick="deleteDocumento({{ $documento->id }}, '{{ e($documento->nome_file) }}')" title="Elimina">
<i class="fas fa-trash"></i>
</button>
@@ -635,6 +653,10 @@ $tableColumnsJson = json_encode($tableColumns ?? []);
</button>
</div>
@if($currentRepo)
<div class="form-group">
<label>Tag (opzionale)</label>
@include("partials._tag-selector", ["selectedTags" => [], "label" => "Tag"])
</div>
<input type="hidden" name="repository_id" value="{{ $currentRepo->id }}">
<p class="text-muted small mb-0">Caricamento su repository remoto: <strong>{{ $currentRepo->nome }}</strong></p>
@endif
@@ -1287,11 +1309,38 @@ document.getElementById('massMoveModal')?.addEventListener('show.bs.modal', func
document.getElementById('massMoveCount').textContent = ids.length;
});
document.getElementById('massTagModal')?.addEventListener('show.bs.modal', function() {
const ids = getSelectedIds();
function showMassTagModal() {
var ids = getSelectedIds();
if (ids.length === 0) {
const preSelected = document.querySelector('.doc-checkbox[data-pre-selected="true"]');
if (preSelected) {
ids = [preSelected.value];
preSelected.removeAttribute('data-pre-selected');
}
}
if (ids.length === 0) {
alert('Seleziona almeno un documento');
return;
}
document.getElementById('massTagIds').value = ids.join(',');
document.getElementById('massTagCount').textContent = ids.length;
});
$('#massTagModal').modal('show');
}
function openSingleTag(docId) {
document.querySelectorAll('.doc-checkbox').forEach(cb => cb.checked = false);
let cb = document.querySelector('.doc-checkbox[value="' + docId + '"]');
if (cb) {
cb.checked = true;
cb.dispatchEvent(new Event('change'));
}
showMassTagModal();
}
document.getElementById('massMoveNewFolderBtn')?.addEventListener('click', function() {
Swal.fire({
+81 -8
View File
@@ -19,6 +19,12 @@ $entityType = $entityType ?? 'eventi';
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible">
<button type="button" class="close" data-dismiss="alert">&times;</button>
{{ session('error') }}
</div>
@endif
<div class="card">
<div class="card-header">
@@ -33,12 +39,15 @@ $entityType = $entityType ?? 'eventi';
<button type="button" class="btn btn-sm btn-success mr-2" onclick="exportSelectedIcs()">
<i class="fas fa-calendar-plus mr-1"></i> Esporta ICS
</button>
@if($canWriteEventi)
<button type="button" class="btn btn-sm btn-info mr-2" onclick="showMassTagModal()">
<i class="fas fa-tags mr-1"></i> Tag
</button>
@endif
@if($canDeleteEventi)
<div class="btn-group mr-2">
<button type="button" class="btn btn-sm btn-danger" onclick="deleteSelected()">
<i class="fas fa-trash mr-1"></i> Elimina Selezionati
</button>
</div>
<button type="button" class="btn btn-sm btn-danger" onclick="deleteSelected()">
<i class="fas fa-trash mr-1"></i> Elimina Selezionati
</button>
@endif
@if($canWriteEventi)
<a href="/eventi/import" class="btn btn-sm btn-warning mr-2">
@@ -86,7 +95,7 @@ $entityType = $entityType ?? 'eventi';
<thead class="thead-light">
<tr>
<th style="width: 40px;" data-column="select">
@if($canDeleteEventi)
@if($canWriteEventi)
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="select-all" onchange="toggleSelectAll(this)">
<label class="custom-control-label" for="select-all"></label>
@@ -187,7 +196,7 @@ $entityType = $entityType ?? 'eventi';
@forelse($eventi as $evento)
<tr data-id="{{ $evento->id }}">
<td>
@if($canDeleteEventi)
@if($canWriteEventi)
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input row-checkbox" id="row-checkbox-{{ $evento->id }}" value="{{ $evento->id }}">
<label class="custom-control-label" for="row-checkbox-{{ $evento->id }}"></label>
@@ -277,6 +286,9 @@ $entityType = $entityType ?? 'eventi';
<a href="{{ url('/eventi/' . $evento->id . '/edit') }}" class="btn btn-xs btn-warning" title="Modifica">
<i class="fas fa-edit"></i>
</a>
<button type="button" class="btn btn-xs btn-info" onclick="openSingleTag({{ $evento->id }})" title="Assegna tag">
<i class="fas fa-tags"></i>
</button>
<form action="{{ url('/eventi/' . $evento->id) }}" method="POST" class="d-inline">
@csrf @method('DELETE')
<button type="submit" class="btn btn-xs btn-danger" onclick="return confirm('Confermi l\'eliminazione?')" title="Elimina">
@@ -327,8 +339,51 @@ $entityType = $entityType ?? 'eventi';
<div id="vista-data" style="display:none;">{{ $vistaDefaultJson }}</div>
{{-- MODAL: Gestione Tag massiva --}}
<div class="modal fade" id="massTagModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-tags mr-2"></i>Gestione Tag</h5>
<button type="button" class="close" onclick="$('#massTagModal').modal('hide')">
<span>&times;</span>
</button>
</div>
<form id="massTagForm" method="POST" action="/eventi/mass-tag">
@csrf
<div class="modal-body">
<p class="mb-2">Operazione su <strong id="massTagCount">0</strong> eventi selezionati.</p>
<div class="form-group mb-3">
<label class="font-weight-bold">Azione</label>
<div class="d-flex" style="gap:1.5rem;">
<div class="form-check">
<input class="form-check-input" type="radio" name="mode" id="mtModeAssign" value="assign" checked>
<label class="form-check-label" for="mtModeAssign">Assegna tag</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="mode" id="mtModeRemove" value="remove">
<label class="form-check-label" for="mtModeRemove">Rimuovi tag</label>
</div>
</div>
</div>
@include('partials._tag-selector', ['label' => 'Seleziona tag'])
<input type="hidden" name="ids" id="massTagIds" value="">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="$('#massTagModal').modal('hide')">Annulla</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-tags mr-1"></i> Applica
</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog">
@if($canDeleteEventi)
@if($canWriteEventi)
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-danger">
@@ -358,10 +413,28 @@ function toggleSelectAll(source) {
document.querySelectorAll('.row-checkbox').forEach(cb => cb.checked = source.checked);
}
function showMassTagModal() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Seleziona almeno un evento');
return;
}
document.getElementById('massTagIds').value = ids.join(',');
document.getElementById('massTagCount').textContent = ids.length;
$('#massTagModal').modal('show');
}
function getSelectedIds() {
return Array.from(document.querySelectorAll('.row-checkbox:checked')).map(cb => cb.value);
}
function openSingleTag(eventId) {
document.querySelectorAll('.row-checkbox').forEach(cb => cb.checked = false);
var cb = document.getElementById('row-checkbox-' + eventId);
if (cb) cb.checked = true;
showMassTagModal();
}
function deleteSelected() {
const selectedIds = getSelectedIds();
if (selectedIds.length === 0) {
@@ -0,0 +1,66 @@
<div class="card">
<div class="card-header">
<h3 class="card-title">Connessioni Google</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="card-body">
<p class="text-muted">
Connetti il tuo account Google per utilizzare i servizi di Gmail, Google Drive e Google Calendar.
</p>
@foreach ($googleStatus as $service => $status)
<div class="row mb-3 pb-3 border-bottom">
<div class="col-md-8">
<h5>
<i class="{{ $status['connection']?->service === 'email' ? 'fas fa-envelope' : ($status['connection']?->service === 'drive' ? 'fab fa-google-drive' : 'fas fa-calendar') }} text-primary me-2"></i>
{{ match($service) { 'email' => 'Gmail', 'drive' => 'Google Drive', 'calendar' => 'Google Calendar', default => ucfirst($service) } }}
</h5>
@if ($status['connected'])
<span class="badge bg-success">Connesso</span>
<small class="text-muted ms-2">{{ $status['email'] }}</small>
@if ($status['expired'])
<span class="badge bg-warning ms-2">Token in scadenza</span>
@endif
@else
<span class="badge bg-secondary">Non connesso</span>
@endif
</div>
<div class="col-md-4 text-end">
@if ($status['connected'])
<form action="{{ route('google-oauth.revoke', $status['connection']->id) }}"
method="POST"
class="d-inline"
onsubmit="return confirm('Disconnettere {{ $service === 'email' ? 'Gmail' : ($service === 'drive' ? 'Google Drive' : 'Google Calendar') }}?')">
@csrf
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-unlink me-1"></i> Disconnetti
</button>
</form>
@else
<a href="{{ route('google-oauth.redirect', $service) }}" class="btn btn-primary btn-sm">
<i class="fab fa-google me-1"></i> Connetti
</a>
@endif
</div>
</div>
@endforeach
</div>
</div>
@push('js')
<script>
document.addEventListener('DOMContentLoaded', function () {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('tab') === 'google') {
const tabTrigger = document.querySelector('[data-tab="google"]');
if (tabTrigger) {
tabTrigger.click();
}
}
});
</script>
@endpush
@@ -35,6 +35,9 @@
<a href="#calendario" class="list-group-item list-group-item-action" data-toggle="tab">
<i class="nav-icon fas fa-calendar-alt mr-2"></i> Calendario
</a>
<a href="#google" class="list-group-item list-group-item-action" data-toggle="tab">
<i class="nav-icon fab fa-google mr-2"></i> Google
</a>
<a href="#tipologie" class="list-group-item list-group-item-action" data-toggle="tab">
<i class="nav-icon fas fa-file mr-2"></i> Tipologie Documenti
</a>
@@ -740,6 +743,11 @@
</div>
</div>
{{-- === GOOGLE OAUTH === --}}
<div class="tab-pane" id="google">
@include("impostazioni._google-oauth")
</div>
{{-- === CALENDARIO === --}}
<div class="tab-pane" id="calendario">
<div class="card card-primary">
+8
View File
@@ -25,6 +25,7 @@ use App\Http\Controllers\Admin\EmailSettingsController;
use App\Http\Controllers\EmailController;
use App\Http\Controllers\StorageRepositoryController;
use App\Http\Controllers\CalendarioConnessioneController;
use App\Http\Controllers\GoogleOAuthController;
use App\Http\Middleware\AdminOnly;
use App\Http\Middleware\CheckPermission;
@@ -110,6 +111,7 @@ Route::post('eventi/import', [EventoController::class, 'importIcsStore'])->middl
Route::get('eventi/{evento}/export-ics', [EventoController::class, 'exportIcs'])->name('eventi.export-ics')->middleware('auth');
Route::post('eventi/export-ics', [EventoController::class, 'exportSelectedIcs'])->name('eventi.export-selected-ics')->middleware('auth');
Route::post('eventi/mass-elimina', [EventoController::class, 'massElimina'])->middleware('auth');
Route::post('eventi/mass-tag', [EventoController::class, 'massTag'])->middleware('auth');
Route::resource('eventi', EventoController::class)->middleware('auth');
Route::post('eventi/{evento}/documenti', [EventoDocumentoController::class, 'store'])->middleware('auth');
Route::delete('eventi/{evento}/documenti/{documento}', [EventoDocumentoController::class, 'destroy'])->middleware('auth');
@@ -235,6 +237,12 @@ Route::post('/storage-repositories/{storage_repository}/import', [StorageReposit
Route::get('/auth/google-drive/redirect', [StorageRepositoryController::class, 'oauthRedirect'])->middleware('auth')->name('google-drive.redirect');
Route::get('/auth/google-drive/callback', [StorageRepositoryController::class, 'oauthCallback'])->middleware('auth')->name('google-drive.callback');
// Google OAuth 2.0 (centralizzato per tutti i servizi)
Route::get('/auth/google/redirect/{service}', [GoogleOAuthController::class, 'redirect'])->middleware('auth')->name('google-oauth.redirect');
Route::get('/auth/google/callback', [GoogleOAuthController::class, 'callback'])->middleware('auth')->name('google-oauth.callback');
Route::post('/auth/google/revoke/{connection}', [GoogleOAuthController::class, 'revoke'])->middleware('auth')->name('google-oauth.revoke');
Route::get('/auth/google/status', [GoogleOAuthController::class, 'status'])->middleware('auth')->name('google-oauth.status');
Route::get('/email/{folder?}', [EmailController::class, 'index'])->middleware('auth')->name('email.index')->defaults('folder', 'inbox');
Route::get('/email/{folder}/{id}', [EmailController::class, 'show'])->middleware('auth')->name('email.show');
Route::get('/email/compose', [EmailController::class, 'compose'])->middleware('auth')->name('email.compose');