mitai-jinkendo/frontend/src/pages/AdminUserRestrictionsPage.jsx
Lars 4e592dddc5
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
fix: AdminUserRestrictionsPage - show effective values, auto-remove redundant overrides
Major UX improvements:
- Display effective value in input (override if set, otherwise tier limit)
- Format NULL as "unlimited" (easy to type, no special char needed)
- Auto-remove override when value equals tier default
- "Zurück" button resets to tier default value
- Wider input field (120px) for "unlimited" text

This solves:
- User can now see and edit current effective values
- "unlimited" can be typed and saved
- Redundant overrides (value = tier default) are prevented
- No more confusion with empty fields vs actual values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:08:29 +01:00

539 lines
20 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 [tierLimits, setTierLimits] = 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, limitsMatrix] = await Promise.all([
api.adminListProfiles().then(users => users.find(u => u.id === userId)),
api.listUserRestrictions(userId),
api.getTierLimitsMatrix()
])
setSelectedUser(user)
setRestrictions(restrictionsData)
// Build tier limits lookup for this user's tier
const userTier = user.tier || 'free'
const limits = {}
features.forEach(feature => {
const key = `${userTier}:${feature.id}`
// Use same fallback logic as TierLimitsPage: undefined → null (unlimited)
limits[feature.id] = limitsMatrix.limits[key] ?? null
})
setTierLimits(limits)
setChanges({})
setError('')
setSuccess('')
} catch (e) {
setError(e.message)
}
}
function handleChange(featureId, value) {
const newChanges = { ...changes }
const tierLimit = tierLimits[featureId]
// Parse value (EXACTLY like TierLimitsPage)
let parsedValue = null
if (value === 'unlimited' || value === '∞') {
parsedValue = null // unlimited
} else if (value === '0' || value === 'disabled') {
parsedValue = 0 // disabled
} else if (value === '') {
parsedValue = null // empty → unlimited
} else {
const num = parseInt(value)
if (!isNaN(num) && num >= 0) {
parsedValue = num
} else {
return // invalid input, ignore
}
}
// Check if value equals tier limit → remove override
if (parsedValue === tierLimit) {
newChanges[featureId] = { action: 'remove', tempValue: value }
} else {
// Different from tier default → set override
newChanges[featureId] = { action: 'set', value: parsedValue, tempValue: value }
}
setChanges(newChanges)
}
function handleToggle(featureId) {
// Get current state
const restriction = restrictions.find(r => r.feature_id === featureId)
let currentValue = restriction?.limit_value ?? null
// Check if there's a pending change
if (featureId in changes && changes[featureId].action === 'set') {
currentValue = changes[featureId].value
}
// Toggle between 1 (enabled) and 0 (disabled)
const isCurrentlyEnabled = currentValue !== 0 && currentValue !== '0'
const newValue = isCurrentlyEnabled ? 0 : 1
const newChanges = { ...changes }
newChanges[featureId] = { action: 'set', value: newValue, tempValue: newValue.toString() }
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++
}
}
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') {
// Returning to tier default
return formatValue(tierLimits[featureId])
}
if (change.action === 'set') {
// Use tempValue for display if available, otherwise format the value
return change.tempValue !== undefined ? change.tempValue : formatValue(change.value)
}
}
// Show override if exists, otherwise tier limit (= effective value)
const restriction = restrictions.find(r => r.feature_id === featureId)
if (restriction) {
return formatValue(restriction.limit_value)
}
// No override: show tier limit as default
return formatValue(tierLimits[featureId])
}
function formatValue(val) {
if (val === null || val === undefined) return 'unlimited'
if (val === '' ) return ''
if (val === '∞' || val === 'unlimited') return 'unlimited'
if (val === 0 || val === '0') return '0'
return val.toString()
}
function getToggleState(featureId) {
// Check pending changes first
if (featureId in changes && changes[featureId].action === 'set') {
const val = changes[featureId].value
return val !== 0 && val !== '0'
}
// Check existing restriction
const restriction = restrictions.find(r => r.feature_id === featureId)
if (!restriction) {
// No override: use tier default
const tierLimit = tierLimits[featureId]
return tierLimit !== 0 && tierLimit !== '0'
}
// For boolean features: limit_value determines state
return restriction.limit_value !== 0 && 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> Felder zeigen effektive Werte (Override falls gesetzt, sonst Tier-Standard).
Wert ändern Override wird gesetzt. Wert = Tier-Standard Override wird entfernt.
</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 && (
<>
{/* Action Buttons */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
Feature-Overrides für {selectedUser.name}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{hasChanges && (
<button
className="btn btn-secondary"
onClick={() => setChanges({})}
disabled={saving}
>
<X size={14} /> Abbrechen
</button>
)}
<button
className="btn btn-primary"
onClick={handleSave}
disabled={!hasChanges || saving}
>
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).length} Änderung(en) speichern` : 'Keine Änderungen'}
</button>
</div>
</div>
{/* 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: 16 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--surface2)' }}>
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>
Feature
</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>
Tier-Limit
</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>
Override-Wert
</th>
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>
Aktion
</th>
</tr>
</thead>
<tbody>
{Object.entries(categoryGroups).map(([category, categoryFeatures]) => (
<>
{/* Category Header */}
<tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}>
<td colSpan={4} 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>
{/* Tier-Limit */}
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
{feature.limit_type === 'boolean' ? (
<span style={{
padding: '6px 12px', borderRadius: 20,
background: tierLimits[feature.id] !== 0 ? 'var(--accent-light)' : 'var(--surface2)',
color: tierLimits[feature.id] !== 0 ? 'var(--accent-dark)' : 'var(--text3)',
fontSize: 12, fontWeight: 600
}}>
{tierLimits[feature.id] !== 0 ? '✓ AN' : '✗ AUS'}
</span>
) : (
<span style={{ fontWeight: 500, color: 'var(--text2)' }}>
{tierLimits[feature.id] === null ? '∞' : tierLimits[feature.id]}
</span>
)}
</td>
{/* Override Input */}
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
{feature.limit_type === 'boolean' ? (
<button
onClick={() => handleToggle(feature.id)}
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=""
style={{
width: '120px',
padding: '6px 8px',
border: `1.5px solid ${changed ? 'var(--accent)' : override ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 6,
textAlign: 'center',
fontSize: 13,
fontWeight: override || changed ? 600 : 400,
background: override || changed ? 'var(--accent-light)' : 'var(--bg)',
color: displayValue === '0' ? 'var(--danger)' :
displayValue === 'unlimited' ? 'var(--accent)' : 'var(--text1)'
}}
/>
)}
</td>
{/* Action */}
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
<button
className="btn btn-secondary"
onClick={() => {
// Reset to tier default
const tierValue = tierLimits[feature.id]
handleChange(feature.id, formatValue(tierValue))
}}
disabled={!override}
style={{
padding: '4px 8px',
fontSize: 11,
opacity: override ? 1 : 0.4,
cursor: override ? 'pointer' : 'not-allowed'
}}
>
Zurück
</button>
</td>
</tr>
)
})}
</>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Eingabe:</strong>
<div style={{ marginTop: 8, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span><strong>unlimited</strong> = Unbegrenzt</span>
<span><strong style={{ color: 'var(--danger)' }}>0</strong> = Feature deaktiviert</span>
<span><strong>1+</strong> = Limit-Wert</span>
</div>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.8 }}>
Feld zeigt effektiven Wert (Override falls gesetzt, sonst Tier-Standard)<br />
Wert ändern Override wird gesetzt<br />
Wert = Tier-Standard Override wird entfernt
</div>
</div>
</>
)}
</div>
)
}