Fixed all reported bugs: 1. Initial values now correct (empty = no override, not defaults) 2. Save/Reset buttons always visible (fixed bottom bar) 3. Toggle buttons work correctly (can be toggled multiple times) 4. Simplified table columns (removed confusing Tier-Limit/Aktiv/Aktion) New logic: - Empty input = no override (user uses tier standard) - Value entered = override set - Change tracking with 3 actions: set, remove, toggle - Clear status display: "Override aktiv" vs "Tier-Standard" Simplified table structure: - Feature (name + type) - Override-Wert (input/toggle) - Status (has override yes/no) Better UX: - Placeholder text explains empty = tier standard - Status badge shows if override is active - Fixed bottom bar always present - Buttons disabled only when no changes - Legend explains all input options Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
469 lines
17 KiB
JavaScript
469 lines
17 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { Save, AlertCircle, X, RotateCcw } from 'lucide-react'
|
|
import { api } from '../utils/api'
|
|
|
|
export default function AdminUserRestrictionsPage() {
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [success, setSuccess] = useState('')
|
|
const [users, setUsers] = useState([])
|
|
const [features, setFeatures] = useState([])
|
|
const [selectedUserId, setSelectedUserId] = useState('')
|
|
const [selectedUser, setSelectedUser] = useState(null)
|
|
const [restrictions, setRestrictions] = useState([])
|
|
const [changes, setChanges] = useState({})
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
useEffect(() => {
|
|
loadInitialData()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (selectedUserId) {
|
|
loadUserData(selectedUserId)
|
|
} else {
|
|
setSelectedUser(null)
|
|
setRestrictions([])
|
|
setChanges({})
|
|
}
|
|
}, [selectedUserId])
|
|
|
|
async function loadInitialData() {
|
|
try {
|
|
setLoading(true)
|
|
const [usersData, featuresData] = await Promise.all([
|
|
api.adminListProfiles(),
|
|
api.listFeatures()
|
|
])
|
|
setUsers(usersData)
|
|
setFeatures(featuresData.filter(f => f.active))
|
|
setError('')
|
|
} catch (e) {
|
|
setError(e.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function loadUserData(userId) {
|
|
try {
|
|
const [user, restrictionsData] = await Promise.all([
|
|
api.adminListProfiles().then(users => users.find(u => u.id === userId)),
|
|
api.listUserRestrictions(userId)
|
|
])
|
|
setSelectedUser(user)
|
|
setRestrictions(restrictionsData)
|
|
setChanges({})
|
|
setError('')
|
|
setSuccess('')
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}
|
|
|
|
function handleChange(featureId, value) {
|
|
const newChanges = { ...changes }
|
|
|
|
// Empty string means: remove override (if exists) or do nothing
|
|
if (value === '') {
|
|
newChanges[featureId] = { action: 'remove' }
|
|
} else {
|
|
// Parse value
|
|
let parsedValue = null
|
|
if (value === 'unlimited' || value === '∞') {
|
|
parsedValue = null
|
|
} else if (value === '0') {
|
|
parsedValue = 0
|
|
} else {
|
|
const num = parseInt(value)
|
|
if (!isNaN(num) && num >= 0) {
|
|
parsedValue = num
|
|
} else {
|
|
return // invalid
|
|
}
|
|
}
|
|
newChanges[featureId] = { action: 'set', value: parsedValue }
|
|
}
|
|
|
|
setChanges(newChanges)
|
|
}
|
|
|
|
function handleToggle(featureId, currentEnabled) {
|
|
const newChanges = { ...changes }
|
|
newChanges[featureId] = { action: 'toggle', enabled: !currentEnabled }
|
|
setChanges(newChanges)
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!selectedUserId) return
|
|
|
|
try {
|
|
setSaving(true)
|
|
setError('')
|
|
setSuccess('')
|
|
|
|
let changeCount = 0
|
|
|
|
for (const [featureId, change] of Object.entries(changes)) {
|
|
const existingRestriction = restrictions.find(r => r.feature_id === featureId)
|
|
|
|
if (change.action === 'remove') {
|
|
// Remove restriction if exists
|
|
if (existingRestriction) {
|
|
await api.deleteUserRestriction(existingRestriction.id)
|
|
changeCount++
|
|
}
|
|
} else if (change.action === 'set') {
|
|
// Create or update
|
|
if (existingRestriction) {
|
|
await api.updateUserRestriction(existingRestriction.id, {
|
|
limit_value: change.value,
|
|
enabled: true
|
|
})
|
|
} else {
|
|
await api.createUserRestriction({
|
|
profile_id: selectedUserId,
|
|
feature_id: featureId,
|
|
limit_value: change.value,
|
|
enabled: true,
|
|
reason: 'Admin override'
|
|
})
|
|
}
|
|
changeCount++
|
|
} else if (change.action === 'toggle') {
|
|
// Toggle enabled state
|
|
if (existingRestriction) {
|
|
await api.updateUserRestriction(existingRestriction.id, {
|
|
enabled: change.enabled
|
|
})
|
|
changeCount++
|
|
} else {
|
|
// Create new restriction with toggle state
|
|
await api.createUserRestriction({
|
|
profile_id: selectedUserId,
|
|
feature_id: featureId,
|
|
limit_value: change.enabled ? 1 : 0,
|
|
enabled: true,
|
|
reason: 'Admin override'
|
|
})
|
|
changeCount++
|
|
}
|
|
}
|
|
}
|
|
|
|
setSuccess(`${changeCount} Änderung(en) gespeichert`)
|
|
await loadUserData(selectedUserId)
|
|
} catch (e) {
|
|
setError(e.message)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
function getDisplayValue(featureId) {
|
|
// Check pending changes first
|
|
if (featureId in changes) {
|
|
const change = changes[featureId]
|
|
if (change.action === 'remove') return ''
|
|
if (change.action === 'set') return change.value === null ? '' : change.value
|
|
if (change.action === 'toggle') return change.enabled ? 1 : 0
|
|
}
|
|
|
|
// Show existing restriction value (or empty if no restriction)
|
|
const restriction = restrictions.find(r => r.feature_id === featureId)
|
|
if (!restriction) return '' // No override = empty input
|
|
return restriction.limit_value === null ? '' : restriction.limit_value
|
|
}
|
|
|
|
function getToggleState(featureId) {
|
|
// Check pending changes first
|
|
if (featureId in changes && changes[featureId].action === 'toggle') {
|
|
return changes[featureId].enabled
|
|
}
|
|
|
|
// Check existing restriction
|
|
const restriction = restrictions.find(r => r.feature_id === featureId)
|
|
if (!restriction) return true // Default: enabled
|
|
|
|
// For boolean features: limit_value determines state
|
|
return restriction.limit_value !== 0
|
|
}
|
|
|
|
function hasOverride(featureId) {
|
|
return restrictions.some(r => r.feature_id === featureId)
|
|
}
|
|
|
|
function isChanged(featureId) {
|
|
return featureId in changes
|
|
}
|
|
|
|
if (loading) return (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
)
|
|
|
|
const hasChanges = Object.keys(changes).length > 0
|
|
const categoryGroups = {}
|
|
features.forEach(f => {
|
|
if (!categoryGroups[f.category]) categoryGroups[f.category] = []
|
|
categoryGroups[f.category].push(f)
|
|
})
|
|
|
|
const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' }
|
|
const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' }
|
|
|
|
return (
|
|
<div style={{ paddingBottom: 80 }}>
|
|
{/* Header */}
|
|
<div style={{ marginBottom: 20 }}>
|
|
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
|
|
User Feature-Overrides
|
|
</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
|
Individuelle Feature-Limits für einzelne User setzen
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info Box */}
|
|
<div style={{
|
|
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
|
|
marginBottom: 16, fontSize: 12, color: 'var(--accent-dark)',
|
|
display: 'flex', gap: 8, alignItems: 'flex-start'
|
|
}}>
|
|
<AlertCircle size={16} style={{ marginTop: 2, flexShrink: 0 }} />
|
|
<div>
|
|
<strong>Hinweis:</strong> User-Overrides überschreiben Tier-Limits.
|
|
Leere Felder = kein Override (User nutzt Tier-Standard).
|
|
Wert eingeben = Override setzen.
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
{error && (
|
|
<div style={{
|
|
padding: 12, background: 'var(--danger)', color: 'white',
|
|
borderRadius: 8, marginBottom: 16, fontSize: 14
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div style={{
|
|
padding: 12, background: 'var(--accent)', color: 'white',
|
|
borderRadius: 8, marginBottom: 16, fontSize: 14
|
|
}}>
|
|
{success}
|
|
</div>
|
|
)}
|
|
|
|
{/* User Selection */}
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|
<label className="form-label" style={{ display: 'block', marginBottom: 8 }}>
|
|
User auswählen
|
|
</label>
|
|
<select
|
|
className="form-input"
|
|
style={{ width: '100%' }}
|
|
value={selectedUserId}
|
|
onChange={(e) => setSelectedUserId(e.target.value)}
|
|
>
|
|
<option value="">-- User auswählen --</option>
|
|
{users.map(u => (
|
|
<option key={u.id} value={u.id}>
|
|
{u.name} ({u.email || u.id}) - Tier: {u.tier || 'free'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* User Info + Features */}
|
|
{selectedUser && (
|
|
<>
|
|
{/* User Info Card */}
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<div style={{
|
|
width: 40, height: 40, borderRadius: '50%',
|
|
background: selectedUser.avatar_color || 'var(--accent)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
color: 'white', fontWeight: 700, fontSize: 18
|
|
}}>
|
|
{selectedUser.name?.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{selectedUser.name}</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
|
{selectedUser.email || `ID: ${selectedUser.id}`}
|
|
</div>
|
|
</div>
|
|
<div style={{
|
|
padding: '4px 12px', borderRadius: 6,
|
|
background: 'var(--accent-light)', color: 'var(--accent-dark)',
|
|
fontSize: 12, fontWeight: 600
|
|
}}>
|
|
Tier: {selectedUser.tier || 'free'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Features Table */}
|
|
<div className="card" style={{ padding: 0, overflow: 'auto', marginBottom: 80 }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr style={{ background: 'var(--surface2)' }}>
|
|
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600, width: '40%' }}>
|
|
Feature
|
|
</th>
|
|
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600, width: '30%' }}>
|
|
Override-Wert
|
|
</th>
|
|
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600, width: '30%' }}>
|
|
Status
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Object.entries(categoryGroups).map(([category, categoryFeatures]) => (
|
|
<>
|
|
{/* Category Header */}
|
|
<tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}>
|
|
<td colSpan={3} style={{
|
|
padding: '8px 16px', fontWeight: 600, fontSize: 11,
|
|
textTransform: 'uppercase', letterSpacing: '0.5px',
|
|
color: 'var(--accent-dark)'
|
|
}}>
|
|
{categoryIcons[category]} {categoryNames[category] || category}
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Feature Rows */}
|
|
{categoryFeatures.map(feature => {
|
|
const displayValue = getDisplayValue(feature.id)
|
|
const toggleState = getToggleState(feature.id)
|
|
const override = hasOverride(feature.id)
|
|
const changed = isChanged(feature.id)
|
|
|
|
return (
|
|
<tr key={feature.id} style={{
|
|
borderBottom: '1px solid var(--border)',
|
|
background: changed ? 'var(--accent-light)' : 'transparent'
|
|
}}>
|
|
{/* Feature Name */}
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<div style={{ fontWeight: 500 }}>{feature.name}</div>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
|
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`}
|
|
</div>
|
|
</td>
|
|
|
|
{/* Override Input */}
|
|
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
|
{feature.limit_type === 'boolean' ? (
|
|
<button
|
|
onClick={() => handleToggle(feature.id, toggleState)}
|
|
style={{
|
|
padding: '6px 16px',
|
|
border: `2px solid ${toggleState ? 'var(--accent)' : 'var(--border)'}`,
|
|
borderRadius: 20,
|
|
background: toggleState ? 'var(--accent)' : 'var(--surface)',
|
|
color: toggleState ? 'white' : 'var(--text3)',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s',
|
|
minWidth: 80
|
|
}}
|
|
>
|
|
{toggleState ? '✓ AN' : '✗ AUS'}
|
|
</button>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={displayValue}
|
|
onChange={(e) => handleChange(feature.id, e.target.value)}
|
|
placeholder="leer = Tier-Standard"
|
|
style={{
|
|
width: '140px',
|
|
padding: '6px 8px',
|
|
border: `1.5px solid ${changed ? 'var(--accent)' : 'var(--border)'}`,
|
|
borderRadius: 6,
|
|
textAlign: 'center',
|
|
fontSize: 13,
|
|
fontWeight: changed ? 600 : 400,
|
|
background: 'var(--bg)',
|
|
color: displayValue === 0 || displayValue === '0' ? 'var(--danger)' : 'var(--text1)'
|
|
}}
|
|
/>
|
|
)}
|
|
</td>
|
|
|
|
{/* Status */}
|
|
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
|
{override ? (
|
|
<span style={{
|
|
padding: '4px 8px', borderRadius: 4, fontSize: 11,
|
|
background: 'var(--accent-light)', color: 'var(--accent-dark)',
|
|
fontWeight: 600
|
|
}}>
|
|
✓ Override aktiv
|
|
</span>
|
|
) : (
|
|
<span style={{ fontSize: 11, color: 'var(--text3)' }}>
|
|
Tier-Standard
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Fixed Bottom Bar */}
|
|
<div style={{
|
|
position: 'fixed', bottom: 0, left: 0, right: 0,
|
|
background: 'var(--bg)', borderTop: '1px solid var(--border)',
|
|
padding: 16, display: 'flex', gap: 8, zIndex: 100,
|
|
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)'
|
|
}}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => setChanges({})}
|
|
disabled={!hasChanges || saving}
|
|
style={{ flex: 1 }}
|
|
>
|
|
<RotateCcw size={14} /> Zurücksetzen
|
|
</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleSave}
|
|
disabled={!hasChanges || saving}
|
|
style={{ flex: 2 }}
|
|
>
|
|
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).length} Änderung(en) speichern` : 'Speichern'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div style={{
|
|
marginTop: 16, padding: 12, background: 'var(--surface2)',
|
|
borderRadius: 8, fontSize: 12, color: 'var(--text3)', marginBottom: 100
|
|
}}>
|
|
<strong>Eingabe:</strong>
|
|
<div style={{ marginTop: 8, display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
|
<span><strong>Leer</strong> = Tier-Standard nutzen (kein Override)</span>
|
|
<span><strong style={{ color: 'var(--danger)' }}>0</strong> = Feature deaktiviert</span>
|
|
<span><strong>∞ oder leer</strong> = Unbegrenzt (bei Count-Features)</span>
|
|
<span><strong>1-999999</strong> = Limit-Wert</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|