mtornani Claude Sonnet 4.6 commited on
Commit
4ce8d6e
·
1 Parent(s): 83a3dd6

Add team invite system: collaboratori accedono al sistema senza login

Browse files

- GET /join/<token> → log accesso + sessione guest → redirect guest_dashboard
- GET /guest-dashboard → lista piani in read-only (no login)
- GET /view-public/<plan_id> → accetta anche team token da sessione
- POST /api/admin/team-invite → genera link invito (solo super_admin)
- Template guest_dashboard.html con lista piani navigabile
- Fix plan_viewer.js: rimuovi codice rotto revokeShare, pulisci submit handler
- Aggiunto campo "Nome collaboratore" nel modale condivisione piano

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

app.py CHANGED
@@ -1424,6 +1424,74 @@ def api_delete_guest_token(plan_id: str, token: str):
1424
  return jsonify({"ok": True})
1425
 
1426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1427
  @app.route("/preview/<token>")
1428
  def guest_preview(token: str):
1429
  """Visualizza piano senza login tramite guest token."""
@@ -1446,9 +1514,10 @@ def guest_preview(token: str):
1446
  @app.route("/view-public/<plan_id>")
1447
  def view_plan_public(plan_id: str):
1448
  """Viewer pubblico per guest token — nessun login."""
1449
- token = request.args.get("token", "")
1450
  rec = knowledge_manager.store.get_guest_token(token) if token else None
1451
- if not rec or rec["plan_id"] != plan_id:
 
1452
  return render_template("error.html", message="Accesso non autorizzato."), 403
1453
  if rec.get("expires_at") and rec["expires_at"] < datetime.now().isoformat():
1454
  return render_template("error.html", message="Link scaduto."), 403
 
1424
  return jsonify({"ok": True})
1425
 
1426
 
1427
+ # ─── TEAM INVITE (accesso sistema intero senza login) ────────────────────────
1428
+
1429
+ @app.route("/api/admin/team-invite", methods=["POST"])
1430
+ @login_required
1431
+ def api_create_team_invite():
1432
+ """Crea un token di invito team — solo super_admin."""
1433
+ if current_user.role != "super_admin":
1434
+ return jsonify({"error": "Forbidden"}), 403
1435
+ import uuid
1436
+ from datetime import timedelta
1437
+ data = request.get_json(silent=True) or {}
1438
+ label = data.get("label", "Team Rooting Future")
1439
+ days = int(data.get("expires_days", 90))
1440
+ token = "team_" + str(uuid.uuid4()).replace("-", "")
1441
+ expires_at = (datetime.now() + timedelta(days=days)).isoformat() if days > 0 else ""
1442
+ # Riusa la tabella guest_tokens con plan_id="__team__"
1443
+ knowledge_manager.store.create_guest_token(
1444
+ token=token, plan_id="__team__",
1445
+ owner_id=int(current_user.id),
1446
+ label=label, expires_at=expires_at,
1447
+ )
1448
+ link = url_for("team_access", token=token, _external=True)
1449
+ return jsonify({"token": token, "link": link, "label": label, "expires_at": expires_at})
1450
+
1451
+
1452
+ @app.route("/join/<token>")
1453
+ def team_access(token: str):
1454
+ """Accesso al sistema per collaboratori tramite invite link (no login)."""
1455
+ rec = knowledge_manager.store.get_guest_token(token)
1456
+ if not rec or rec["plan_id"] != "__team__":
1457
+ return render_template("error.html", message="Link di invito non valido."), 404
1458
+ if rec.get("expires_at") and rec["expires_at"] < datetime.now().isoformat():
1459
+ return render_template("error.html", message="Link di invito scaduto."), 403
1460
+ # Log accesso
1461
+ knowledge_manager.store.log_guest_access(
1462
+ token=token, plan_id="__team__",
1463
+ ip=request.headers.get("X-Forwarded-For", request.remote_addr or ""),
1464
+ user_agent=request.user_agent.string or "",
1465
+ )
1466
+ # Salva token in sessione per navigazione successiva
1467
+ session["guest_team_token"] = token
1468
+ session["guest_label"] = rec.get("label", "")
1469
+ return redirect(url_for("guest_dashboard"))
1470
+
1471
+
1472
+ @app.route("/guest-dashboard")
1473
+ def guest_dashboard():
1474
+ """Dashboard read-only per collaboratori senza account."""
1475
+ token = session.get("guest_team_token")
1476
+ if not token:
1477
+ return redirect(url_for("login"))
1478
+ rec = knowledge_manager.store.get_guest_token(token)
1479
+ if not rec or rec["plan_id"] != "__team__":
1480
+ session.pop("guest_team_token", None)
1481
+ return redirect(url_for("login"))
1482
+ if rec.get("expires_at") and rec["expires_at"] < datetime.now().isoformat():
1483
+ return render_template("error.html", message="Sessione scaduta."), 403
1484
+ # Carica tutti i piani del super_admin (owner_id del token)
1485
+ owner_id = rec["owner_id"]
1486
+ plans, _ = knowledge_manager.store.list_plans(owner_id=owner_id, limit=100)
1487
+ return render_template(
1488
+ "guest_dashboard.html",
1489
+ plans=plans,
1490
+ guest_label=rec.get("label", ""),
1491
+ token=token,
1492
+ )
1493
+
1494
+
1495
  @app.route("/preview/<token>")
1496
  def guest_preview(token: str):
1497
  """Visualizza piano senza login tramite guest token."""
 
1514
  @app.route("/view-public/<plan_id>")
1515
  def view_plan_public(plan_id: str):
1516
  """Viewer pubblico per guest token — nessun login."""
1517
+ token = request.args.get("token", "") or session.get("guest_team_token", "")
1518
  rec = knowledge_manager.store.get_guest_token(token) if token else None
1519
+ # Accetta sia token specifico per piano sia team token (__team__)
1520
+ if not rec or (rec["plan_id"] != plan_id and rec["plan_id"] != "__team__"):
1521
  return render_template("error.html", message="Accesso non autorizzato."), 403
1522
  if rec.get("expires_at") and rec["expires_at"] < datetime.now().isoformat():
1523
  return render_template("error.html", message="Link scaduto."), 403
static/js/plan_viewer.js CHANGED
@@ -338,41 +338,29 @@ document.addEventListener('DOMContentLoaded', function() {
338
  const formData = new FormData(shareForm);
339
 
340
  const data = {
 
341
  expires_days: parseInt(formData.get('expires_days')),
342
- password: formData.get('password') || null,
343
- allow_download: formData.get('allow_download') === 'on'
344
  };
345
 
346
- console.log('[SHARE] Creating share link...', data);
347
-
348
- fetch(`/share/${planId}`, {
349
  method: 'POST',
350
- headers: {
351
- 'Content-Type': 'application/json'
352
- },
353
  body: JSON.stringify(data)
354
  })
355
- .then(response => response.json())
356
  .then(result => {
357
- if (result.success) {
358
- console.log('[SHARE] Share link created:', result.share_url);
359
-
360
- // Show result
361
- document.getElementById('shareUrl').value = result.share_url;
362
  document.getElementById('shareResult').style.display = 'block';
363
-
364
- // Reload active shares
365
  loadActiveShares();
366
-
367
- // Scroll to result
368
  document.getElementById('shareResult').scrollIntoView({ behavior: 'smooth' });
369
  } else {
370
- alert('Errore durante la creazione del link: ' + (result.error || 'Errore sconosciuto'));
371
  }
372
  })
373
- .catch(error => {
374
- console.error('[SHARE] Error creating share:', error);
375
- alert('Errore durante la creazione del link di condivisione.');
376
  });
377
  });
378
  }
@@ -410,71 +398,40 @@ function loadActiveShares() {
410
 
411
  sharesList.innerHTML = '<p style="color: #6c757d; font-size: 14px;">Caricamento...</p>';
412
 
413
- fetch(`/api/shares/${planId}`)
414
- .then(response => response.json())
415
- .then(data => {
416
- const shares = data.shares || [];
417
-
418
- if (shares.length === 0) {
419
- sharesList.innerHTML = '<p style="color: #6c757d; font-size: 14px;">Nessun link attivo. Crea il primo!</p>';
420
  return;
421
  }
422
-
423
  let html = '';
424
- shares.forEach(share => {
425
- const expiresAt = new Date(share.expires_at);
426
- const createdAt = new Date(share.created_at);
427
-
428
- html += `
429
- <div class="share-item">
430
- <div class="share-item-info">
431
- <strong>${share.share_url.substring(0, 60)}...</strong>
432
- <small>
433
- Creato: ${createdAt.toLocaleDateString('it-IT')} |
434
- Scade: ${expiresAt.toLocaleDateString('it-IT')} |
435
- Visualizzazioni: ${share.view_count}
436
- ${share.password_hash ? ' | 🔐 Protetto' : ''}
437
- ${share.allow_download ? ' | 📥 Download' : ''}
438
- </small>
439
- </div>
440
- <button class="btn-revoke" onclick="revokeShare('${share.share_token}')">
441
- 🗑️ Revoca
442
- </button>
443
  </div>
444
- `;
 
445
  });
446
-
447
  sharesList.innerHTML = html;
448
- console.log('[SHARE] Loaded', shares.length, 'active shares');
449
  })
450
- .catch(error => {
451
- console.error('[SHARE] Error loading shares:', error);
452
- sharesList.innerHTML = '<p style="color: #dc2626; font-size: 14px;">Errore nel caricamento dei link.</p>';
453
  });
454
  }
455
 
456
- function revokeShare(shareToken) {
457
- if (!confirm('Sei sicuro di voler revocare questo link? Non sarà più accessibile.')) {
458
- return;
459
- }
460
-
461
- console.log('[SHARE] Revoking share:', shareToken);
462
-
463
- fetch(`/api/shares/${shareToken}/revoke`, {
464
- method: 'POST'
465
- })
466
- .then(response => response.json())
467
  .then(result => {
468
- if (result.success) {
469
- console.log('[SHARE] Share revoked successfully');
470
- loadActiveShares();
471
- } else {
472
- alert('Errore durante la revoca: ' + (result.error || 'Errore sconosciuto'));
473
- }
474
- })
475
- .catch(error => {
476
- console.error('[SHARE] Error revoking share:', error);
477
- alert('Errore durante la revoca del link.');
478
  });
479
  }
480
 
 
338
  const formData = new FormData(shareForm);
339
 
340
  const data = {
341
+ label: formData.get('label') || 'Collaboratore',
342
  expires_days: parseInt(formData.get('expires_days')),
 
 
343
  };
344
 
345
+ fetch(`/api/plans/${planId}/guest-tokens`, {
 
 
346
  method: 'POST',
347
+ headers: { 'Content-Type': 'application/json' },
 
 
348
  body: JSON.stringify(data)
349
  })
350
+ .then(r => r.json())
351
  .then(result => {
352
+ if (result.link) {
353
+ document.getElementById('shareUrl').value = result.link;
 
 
 
354
  document.getElementById('shareResult').style.display = 'block';
 
 
355
  loadActiveShares();
 
 
356
  document.getElementById('shareResult').scrollIntoView({ behavior: 'smooth' });
357
  } else {
358
+ alert('Errore: ' + (result.error || 'Sconosciuto'));
359
  }
360
  })
361
+ .catch(err => {
362
+ console.error('[SHARE] Error:', err);
363
+ alert('Errore durante la creazione del link.');
364
  });
365
  });
366
  }
 
398
 
399
  sharesList.innerHTML = '<p style="color: #6c757d; font-size: 14px;">Caricamento...</p>';
400
 
401
+ fetch(`/api/plans/${planId}/guest-tokens`)
402
+ .then(r => r.json())
403
+ .then(tokens => {
404
+ if (!tokens.length) {
405
+ sharesList.innerHTML = '<p style="color:#6c757d;font-size:14px;">Nessun link attivo. Crea il primo!</p>';
 
 
406
  return;
407
  }
 
408
  let html = '';
409
+ tokens.forEach(t => {
410
+ const created = new Date(t.created_at).toLocaleDateString('it-IT');
411
+ const expires = t.expires_at ? new Date(t.expires_at).toLocaleDateString('it-IT') : '∞';
412
+ html += `<div class="share-item">
413
+ <div class="share-item-info">
414
+ <strong>${t.label || 'Collaboratore'}</strong>
415
+ <small>Creato: ${created} | Scade: ${expires} | Accessi: ${t.access_count || 0}
416
+ ${t.last_access ? ' | Ultimo: ' + new Date(t.last_access).toLocaleString('it-IT') : ''}</small>
 
 
 
 
 
 
 
 
 
 
 
417
  </div>
418
+ <button class="btn-revoke" onclick="revokeShare('${t.token}','${planId}')">🗑️ Revoca</button>
419
+ </div>`;
420
  });
 
421
  sharesList.innerHTML = html;
 
422
  })
423
+ .catch(() => {
424
+ sharesList.innerHTML = '<p style="color:#dc2626;font-size:14px;">Errore caricamento link.</p>';
 
425
  });
426
  }
427
 
428
+ function revokeShare(shareToken, planId) {
429
+ if (!confirm('Revocare questo link? Non sarà più accessibile.')) return;
430
+ fetch(`/api/plans/${planId}/guest-tokens/${shareToken}`, { method: 'DELETE' })
431
+ .then(r => r.json())
 
 
 
 
 
 
 
432
  .then(result => {
433
+ if (result.ok) loadActiveShares();
434
+ else alert('Errore revoca link');
 
 
 
 
 
 
 
 
435
  });
436
  }
437
 
templates/guest_dashboard.html ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="it">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Rooting Future • Area Collaboratori</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
8
+ <style>
9
+ :root { --primary: #10b981; --bg: #0f172a; --card: #1e293b; --border: #334155; --text: #f1f5f9; --muted: #94a3b8; }
10
+ * { box-sizing: border-box; margin: 0; padding: 0; }
11
+ body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
12
+
13
+ .header { background: var(--card); border-bottom: 1px solid var(--border); padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between; }
14
+ .logo { font-size: 1.2rem; font-weight: 800; color: var(--primary); }
15
+ .guest-badge { background: rgba(16,185,129,.15); color: var(--primary); padding: 4px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 700; }
16
+
17
+ .container { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; }
18
+ h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.4rem; }
19
+ .subtitle { color: var(--muted); margin-bottom: 2rem; font-size: 0.9rem; }
20
+
21
+ .plans-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.2rem; }
22
+ .plan-card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 1.5rem; text-decoration: none; color: inherit; display: block; transition: all .2s; }
23
+ .plan-card:hover { border-color: var(--primary); transform: translateY(-3px); }
24
+ .plan-club { font-size: 1.1rem; font-weight: 800; margin-bottom: 0.3rem; }
25
+ .plan-cat { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 1rem; }
26
+ .plan-date { font-size: 0.75rem; color: var(--muted); }
27
+ .plan-btn { display: inline-block; margin-top: 1rem; background: var(--primary); color: #000; padding: 0.5rem 1.2rem; border-radius: 8px; font-size: 0.8rem; font-weight: 700; }
28
+
29
+ .empty { text-align: center; color: var(--muted); padding: 4rem 0; }
30
+
31
+ @media(max-width:600px) { .plans-grid { grid-template-columns: 1fr; } }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div class="header">
36
+ <div class="logo">🌱 Rooting Future</div>
37
+ <div class="guest-badge">👤 {{ guest_label or 'Collaboratore' }}</div>
38
+ </div>
39
+
40
+ <div class="container">
41
+ <h1>Piani Strategici</h1>
42
+ <p class="subtitle">Accesso in sola lettura. Per modifiche contatta l'amministratore.</p>
43
+
44
+ {% if plans %}
45
+ <div class="plans-grid">
46
+ {% for plan in plans %}
47
+ <a href="/view-public/{{ plan.id }}?token={{ token }}" class="plan-card">
48
+ <div class="plan-club">{{ plan.club_name or plan.id }}</div>
49
+ <div class="plan-cat">{{ plan.category or '—' }}</div>
50
+ <div class="plan-date">{{ plan.created_at[:10] if plan.created_at else '' }}</div>
51
+ <div class="plan-btn">Visualizza Piano →</div>
52
+ </a>
53
+ {% endfor %}
54
+ </div>
55
+ {% else %}
56
+ <div class="empty">
57
+ <div style="font-size:3rem;margin-bottom:1rem">📋</div>
58
+ <p>Nessun piano disponibile al momento.</p>
59
+ </div>
60
+ {% endif %}
61
+ </div>
62
+ </body>
63
+ </html>
templates/strategic_plan_viewer.html CHANGED
@@ -310,6 +310,11 @@
310
  </p>
311
 
312
  <form id="shareForm">
 
 
 
 
 
313
  <div class="form-group">
314
  <label>Scadenza Link</label>
315
  <select name="expires_days" class="form-control">
@@ -320,19 +325,6 @@
320
  </select>
321
  </div>
322
 
323
- <div class="form-group">
324
- <label>Password (opzionale)</label>
325
- <input type="password" name="password" class="form-control" placeholder="Lascia vuoto per nessuna password">
326
- <small>Aggiungi una password per maggiore sicurezza</small>
327
- </div>
328
-
329
- <div class="form-group checkbox-group">
330
- <label>
331
- <input type="checkbox" name="allow_download">
332
- Permetti download PDF/DOCX
333
- </label>
334
- </div>
335
-
336
  <button type="submit" class="btn btn-primary btn-block">
337
  ✨ Genera Link di Condivisione
338
  </button>
 
310
  </p>
311
 
312
  <form id="shareForm">
313
+ <div class="form-group">
314
+ <label>Nome collaboratore</label>
315
+ <input type="text" name="label" class="form-control" placeholder="es. Mario Rossi, Staff Tecnico…" required>
316
+ </div>
317
+
318
  <div class="form-group">
319
  <label>Scadenza Link</label>
320
  <select name="expires_days" class="form-control">
 
325
  </select>
326
  </div>
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  <button type="submit" class="btn btn-primary btn-block">
329
  ✨ Genera Link di Condivisione
330
  </button>