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>
345 lines
12 KiB
JavaScript
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>
|
|
)
|
|
}
|