pre version 2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 (`×` → `<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 `×` 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
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasTagsLight;
|
||||
|
||||
+120
-7
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasTagsLight;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">×</button>
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert">×</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({
|
||||
|
||||
@@ -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">×</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>×</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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user