Files
leopost-full/frontend/src/components/AdminSettings.jsx
Michele 77ca70cd48 feat: multi-user SaaS, piani Freemium/Pro, Google OAuth, admin panel
BLOCCO 1 - Multi-user data model:
- User: email, display_name, avatar_url, auth_provider, google_id
- User: subscription_plan, subscription_expires_at, is_admin, post counters
- SubscriptionCode table per redeem codes
- user_id FK su Character, Post, AffiliateLink, EditorialPlan, SocialAccount, SystemSetting
- Migrazione SQLite-safe (ALTER TABLE) + preserva dati esistenti

BLOCCO 2 - Auth completo:
- Registrazione email/password + login multi-user
- Google OAuth 2.0 (httpx, no deps esterne)
- Callback flow: Google -> /auth/callback?token=JWT -> frontend
- Backward compat login admin con username

BLOCCO 3 - Piani e abbonamenti:
- Freemium: 1 character, 15 post/mese, FB+IG only, no auto-plans
- Pro: illimitato, tutte le piattaforme, tutte le feature
- Enforcement automatico in tutti i router
- Redeem codes con durate 1/3/6/12 mesi
- Admin panel: genera codici, lista utenti

BLOCCO 4 - Frontend completo:
- Login page design Leopost (split coral/cream, Google, social coming soon)
- AuthCallback per OAuth redirect
- PlanBanner, UpgradeModal con pricing
- AdminSettings per generazione codici
- CharacterForm con tab Account Social + guide setup

Deploy:
- Dockerfile con ARG VITE_BASE_PATH/VITE_API_BASE
- docker-compose.prod.yml per leopost.it (no subpath)
- docker-compose.yml aggiornato per lab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:01:07 +02:00

345 lines
12 KiB
JavaScript

import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { useAuth } from '../AuthContext'
const INK = '#1A1A2E'
const MUTED = '#888'
const BORDER = '#E8E4DC'
const CORAL = '#FF6B4A'
const DURATIONS = [
{ value: 1, label: '1 mese', price: '€14.95' },
{ value: 3, label: '3 mesi', price: '€39.95' },
{ value: 6, label: '6 mesi', price: '€64.95' },
{ value: 12, label: '12 mesi (1 anno)', price: '€119.95' },
]
export default function AdminSettings() {
const { isAdmin, user } = useAuth()
const navigate = useNavigate()
const [users, setUsers] = useState([])
const [codes, setCodes] = useState([])
const [loadingUsers, setLoadingUsers] = useState(true)
const [loadingCodes, setLoadingCodes] = useState(true)
const [duration, setDuration] = useState(1)
const [generating, setGenerating] = useState(false)
const [generatedCode, setGeneratedCode] = useState(null)
const [copied, setCopied] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!isAdmin) {
navigate('/')
return
}
loadUsers()
loadCodes()
}, [isAdmin])
const loadUsers = async () => {
try {
const data = await api.get('/admin/users')
setUsers(data)
} catch {
// silently fail
} finally {
setLoadingUsers(false)
}
}
const loadCodes = async () => {
try {
const data = await api.get('/admin/codes')
setCodes(data)
} catch {
// silently fail
} finally {
setLoadingCodes(false)
}
}
const handleGenerateCode = async () => {
setGenerating(true)
setError('')
setGeneratedCode(null)
try {
const result = await api.post('/admin/codes/generate', { duration_months: duration })
setGeneratedCode(result)
loadCodes()
} catch (err) {
setError(err.message || 'Errore nella generazione del codice.')
} finally {
setGenerating(false)
}
}
const copyCode = async (code) => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// fallback
}
}
if (!isAdmin) return null
return (
<div>
<div style={{ marginBottom: '1.5rem' }}>
<h2 style={{ color: INK, fontSize: '1.5rem', fontWeight: 700, margin: 0 }}>
Admin Settings
</h2>
<p style={{ color: MUTED, fontSize: '0.875rem', margin: '0.25rem 0 0' }}>
Gestione utenti e codici abbonamento
</p>
</div>
{/* Generate Code Section */}
<div style={{
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '14px',
padding: '1.5rem',
marginBottom: '1.5rem',
}}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', color: INK, fontWeight: 600 }}>
Genera Codice Pro
</h3>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '0.75rem', flexWrap: 'wrap' }}>
<div>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 600, color: MUTED, marginBottom: '0.4rem' }}>
Durata abbonamento
</label>
<select
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
style={{
padding: '0.6rem 1rem',
border: `1px solid ${BORDER}`,
borderRadius: '8px',
fontSize: '0.875rem',
color: INK,
backgroundColor: 'white',
cursor: 'pointer',
outline: 'none',
}}
>
{DURATIONS.map((d) => (
<option key={d.value} value={d.value}>
{d.label} {d.price}
</option>
))}
</select>
</div>
<button
onClick={handleGenerateCode}
disabled={generating}
style={{
padding: '0.6rem 1.25rem',
backgroundColor: CORAL,
color: 'white',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
fontSize: '0.875rem',
cursor: generating ? 'not-allowed' : 'pointer',
opacity: generating ? 0.7 : 1,
}}
>
{generating ? 'Generazione...' : 'Genera Codice'}
</button>
</div>
{error && (
<p style={{ marginTop: '0.75rem', color: '#DC2626', fontSize: '0.875rem' }}>{error}</p>
)}
{generatedCode && (
<div style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: '#F0FDF4',
border: '1px solid #86EFAC',
borderRadius: '10px',
}}>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.8rem', color: '#065F46', fontWeight: 600 }}>
Codice generato ({DURATIONS.find(d => d.value === generatedCode.duration_months)?.label})
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<code style={{
flex: 1,
padding: '0.5rem 0.75rem',
backgroundColor: 'white',
border: '1px solid #A7F3D0',
borderRadius: '6px',
fontFamily: 'monospace',
fontSize: '1rem',
letterSpacing: '0.1em',
color: '#065F46',
}}>
{generatedCode.code}
</code>
<button
onClick={() => copyCode(generatedCode.code)}
style={{
padding: '0.5rem 0.75rem',
backgroundColor: copied ? '#16A34A' : INK,
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
transition: 'background-color 0.2s',
}}
>
{copied ? 'Copiato ✓' : 'Copia'}
</button>
</div>
</div>
)}
</div>
{/* Codes Table */}
<div style={{
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '14px',
padding: '1.5rem',
marginBottom: '1.5rem',
}}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', color: INK, fontWeight: 600 }}>
Codici Generati
</h3>
{loadingCodes ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Caricamento...</p>
) : codes.length === 0 ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Nessun codice generato ancora.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: `2px solid ${BORDER}` }}>
{['Codice', 'Durata', 'Stato', 'Usato da', 'Data uso'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: '0.5rem 0.75rem', color: MUTED, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{codes.map((code) => (
<tr key={code.id} style={{ borderBottom: `1px solid ${BORDER}` }}>
<td style={{ padding: '0.6rem 0.75rem', fontFamily: 'monospace', color: INK, fontWeight: 600 }}>
{code.code}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: INK }}>
{DURATIONS.find(d => d.value === code.duration_months)?.label || `${code.duration_months}m`}
</td>
<td style={{ padding: '0.6rem 0.75rem' }}>
<span style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '10px',
fontSize: '0.75rem',
fontWeight: 600,
backgroundColor: code.status === 'used' ? '#FEE2E2' : '#ECFDF5',
color: code.status === 'used' ? '#DC2626' : '#16A34A',
}}>
{code.status === 'used' ? 'Usato' : 'Attivo'}
</span>
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED }}>
{code.used_by || '—'}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED }}>
{code.used_at ? new Date(code.used_at).toLocaleDateString('it-IT') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Users Table */}
<div style={{
backgroundColor: 'white',
border: `1px solid ${BORDER}`,
borderRadius: '14px',
padding: '1.5rem',
}}>
<h3 style={{ margin: '0 0 1rem', fontSize: '1rem', color: INK, fontWeight: 600 }}>
Utenti ({users.length})
</h3>
{loadingUsers ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Caricamento...</p>
) : users.length === 0 ? (
<p style={{ color: MUTED, fontSize: '0.875rem' }}>Nessun utente.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: `2px solid ${BORDER}` }}>
{['Email / Username', 'Piano', 'Scadenza', 'Provider', 'Registrazione'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: '0.5rem 0.75rem', color: MUTED, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} style={{ borderBottom: `1px solid ${BORDER}`, backgroundColor: u.is_admin ? '#FFFBEB' : 'white' }}>
<td style={{ padding: '0.6rem 0.75rem' }}>
<div style={{ color: INK, fontWeight: 500 }}>{u.email || u.username}</div>
{u.display_name && u.display_name !== u.email && (
<div style={{ color: MUTED, fontSize: '0.78rem' }}>{u.display_name}</div>
)}
{u.is_admin && (
<span style={{ fontSize: '0.7rem', backgroundColor: '#FEF3C7', color: '#D97706', padding: '0.1rem 0.4rem', borderRadius: '4px', fontWeight: 600 }}>
admin
</span>
)}
</td>
<td style={{ padding: '0.6rem 0.75rem' }}>
<span style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '10px',
fontSize: '0.75rem',
fontWeight: 600,
backgroundColor: u.subscription_plan === 'pro' ? '#FFF5F3' : '#F5F5F5',
color: u.subscription_plan === 'pro' ? CORAL : MUTED,
}}>
{u.subscription_plan || 'freemium'}
</span>
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED, fontSize: '0.8rem' }}>
{u.subscription_expires_at
? new Date(u.subscription_expires_at).toLocaleDateString('it-IT')
: u.subscription_plan === 'pro' ? '∞' : '—'}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED }}>
{u.auth_provider || 'local'}
</td>
<td style={{ padding: '0.6rem 0.75rem', color: MUTED, fontSize: '0.8rem' }}>
{u.created_at ? new Date(u.created_at).toLocaleDateString('it-IT') : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}