diff --git a/backend/apps/users/__pycache__/admin.cpython-314.pyc b/backend/apps/users/__pycache__/admin.cpython-314.pyc index 9daed99..0ddf4d2 100644 Binary files a/backend/apps/users/__pycache__/admin.cpython-314.pyc and b/backend/apps/users/__pycache__/admin.cpython-314.pyc differ diff --git a/backend/apps/users/__pycache__/models.cpython-314.pyc b/backend/apps/users/__pycache__/models.cpython-314.pyc index f9c8960..13e6b29 100644 Binary files a/backend/apps/users/__pycache__/models.cpython-314.pyc and b/backend/apps/users/__pycache__/models.cpython-314.pyc differ diff --git a/backend/apps/users/__pycache__/urls.cpython-314.pyc b/backend/apps/users/__pycache__/urls.cpython-314.pyc index 170fa35..804311d 100644 Binary files a/backend/apps/users/__pycache__/urls.cpython-314.pyc and b/backend/apps/users/__pycache__/urls.cpython-314.pyc differ diff --git a/backend/apps/users/__pycache__/views.cpython-314.pyc b/backend/apps/users/__pycache__/views.cpython-314.pyc index 3a6675c..963bdc7 100644 Binary files a/backend/apps/users/__pycache__/views.cpython-314.pyc and b/backend/apps/users/__pycache__/views.cpython-314.pyc differ diff --git a/backend/apps/users/admin.py b/backend/apps/users/admin.py index bf5842e..1c05f3f 100644 --- a/backend/apps/users/admin.py +++ b/backend/apps/users/admin.py @@ -1,6 +1,15 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from .models import User, RegistrationDomainRule, RegistrationRestrictionSetting +from django.contrib.auth.models import Group +from .models import User, RegistrationDomainRule, RegistrationRestrictionSetting, UserActivity + +SESSION_MONITOR_GROUP = "Session Monitor" + +# Ensure "Session Monitor" group exists in Django Admin +try: + Group.objects.get_or_create(name=SESSION_MONITOR_GROUP) +except Exception: + pass @admin.register(User) class UserAdmin(BaseUserAdmin): @@ -18,3 +27,9 @@ class RegistrationDomainRuleAdmin(admin.ModelAdmin): @admin.register(RegistrationRestrictionSetting) class RegistrationRestrictionSettingAdmin(admin.ModelAdmin): list_display = ['is_restriction_enabled'] + +@admin.register(UserActivity) +class UserActivityAdmin(admin.ModelAdmin): + list_display = ['user', 'guest_name', 'ip_address', 'last_path', 'last_seen'] + list_filter = ['last_seen'] + search_fields = ['ip_address', 'guest_name', 'user__username', 'user__email', 'user__display_name', 'last_path'] diff --git a/backend/apps/users/middleware.py b/backend/apps/users/middleware.py new file mode 100644 index 0000000..7309453 --- /dev/null +++ b/backend/apps/users/middleware.py @@ -0,0 +1,44 @@ +from .models import UserActivity + +class UserActivityMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + # Record activity for non-static requests + path = request.path + if not path.startswith('/static/') and not path.startswith('/media/'): + try: + # Ensure session exists + if not request.session.session_key: + request.session.create() + session_key = request.session.session_key + + # Extract Client IP handling reverse proxies (Traefik / Nginx) + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0].strip() + else: + ip = request.META.get('REMOTE_ADDR', '') + + user_agent = request.META.get('HTTP_USER_AGENT', '')[:245] + user = request.user if request.user.is_authenticated else None + guest_name = request.session.get('guest_name', '') or request.COOKIES.get('guest_display_name', '') + + if session_key: + UserActivity.objects.update_or_create( + session_key=session_key, + defaults={ + 'user': user, + 'guest_name': guest_name if not user else '', + 'ip_address': ip, + 'user_agent': user_agent, + 'last_path': path[:245], + } + ) + except Exception: + pass + + return response diff --git a/backend/apps/users/migrations/0007_useractivity.py b/backend/apps/users/migrations/0007_useractivity.py new file mode 100644 index 0000000..4db8a79 --- /dev/null +++ b/backend/apps/users/migrations/0007_useractivity.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.16 on 2026-08-01 10:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_registrationrestrictionsetting_require_email_verification'), + ] + + operations = [ + migrations.CreateModel( + name='UserActivity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('guest_name', models.CharField(blank=True, max_length=150, null=True)), + ('session_key', models.CharField(db_index=True, max_length=255)), + ('ip_address', models.CharField(blank=True, default='', max_length=50)), + ('user_agent', models.CharField(blank=True, default='', max_length=255)), + ('last_path', models.CharField(blank=True, default='', max_length=255)), + ('last_seen', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Benutzer-Aktivität / Sitzung', + 'verbose_name_plural': 'Benutzer-Aktivitäten / Sitzungen', + 'ordering': ['-last_seen'], + }, + ), + ] diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py index 0d2b5f3..de31b95 100644 --- a/backend/apps/users/models.py +++ b/backend/apps/users/models.py @@ -86,3 +86,21 @@ class GuestClaimToken(models.Model): def __str__(self): return f"Claim Token for {self.guest_name} ({self.token})" + +class UserActivity(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='activities') + guest_name = models.CharField(max_length=150, null=True, blank=True) + session_key = models.CharField(max_length=255, db_index=True) + ip_address = models.CharField(max_length=50, blank=True, default='') + user_agent = models.CharField(max_length=255, blank=True, default='') + last_path = models.CharField(max_length=255, blank=True, default='') + last_seen = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-last_seen'] + verbose_name = 'Benutzer-Aktivität / Sitzung' + verbose_name_plural = 'Benutzer-Aktivitäten / Sitzungen' + + def __str__(self): + name = self.user.get_display_name() if self.user else (self.guest_name or "Gast") + return f"{name} ({self.ip_address}) - {self.last_seen}" diff --git a/backend/apps/users/urls.py b/backend/apps/users/urls.py index 441cb6e..e6ff52f 100644 --- a/backend/apps/users/urls.py +++ b/backend/apps/users/urls.py @@ -5,7 +5,7 @@ from .views import ( RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView, PendingUsersView, ApproveUserView, AdminUserViewSet, GenerateClaimLinkView, ClaimInfoView, - SMTPSettingView, TestSMTPEmailView, UserListView + SMTPSettingView, TestSMTPEmailView, UserListView, ActiveSessionsView ) router = DefaultRouter() @@ -18,6 +18,7 @@ urlpatterns = [ path('login/', LoginView.as_view(), name='login'), path('me/', MeView.as_view(), name='me'), path('all/', UserListView.as_view(), name='all-users'), + path('active-sessions/', ActiveSessionsView.as_view(), name='active-sessions'), path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'), path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'), path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'), diff --git a/backend/apps/users/views.py b/backend/apps/users/views.py index ee7064c..b78f9e0 100644 --- a/backend/apps/users/views.py +++ b/backend/apps/users/views.py @@ -1,10 +1,14 @@ from rest_framework import views, viewsets, permissions, status +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.authtoken.models import Token from django.contrib.auth import authenticate, get_user_model from django.shortcuts import get_object_or_404 from django.core.mail import get_connection, EmailMultiAlternatives -from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken, EmailVerificationToken, SMTPSetting +from .models import ( + RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken, + EmailVerificationToken, SMTPSetting, UserActivity +) from .serializers import ( UserSerializer, AdminUserSerializer, RegisterSerializer, RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer, SMTPSettingSerializer @@ -80,6 +84,17 @@ class AdminUserViewSet(viewsets.ModelViewSet): user_to_delete.delete() return Response({'message': f'Benutzer "{name}" wurde erfolgreich gelöscht.'}) + @action(detail=True, methods=['post'], url_path='confirm-email') + def confirm_email(self, request, pk=None): + target_user = self.get_object() + target_user.is_email_verified = True + target_user.is_approved = True + target_user.save() + EmailVerificationToken.objects.filter(user=target_user).update(is_used=True) + return Response({ + 'message': f'E-Mail-Adresse von {target_user.get_display_name()} ({target_user.email}) wurde manuell von dir als bestätigt markiert.' + }) + class RegisterView(views.APIView): permission_classes = [permissions.AllowAny] @@ -406,3 +421,54 @@ class UserListView(views.APIView): def get(self, request): users = User.objects.filter(is_active=True).order_by('username') return Response(UserSerializer(users, many=True).data) + +SESSION_MONITOR_GROUP = "Session Monitor" + +class IsSessionMonitorGroupOrSuperuser(permissions.BasePermission): + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + if request.user.is_superuser: + return True + return request.user.groups.filter(name=SESSION_MONITOR_GROUP).exists() + +class ActiveSessionsView(views.APIView): + permission_classes = [IsSessionMonitorGroupOrSuperuser] + + def get(self, request): + from datetime import timedelta + from django.utils import timezone + cutoff = timezone.now() - timedelta(hours=24) + + activities = UserActivity.objects.filter(last_seen__gte=cutoff).select_related('user').order_by('-last_seen') + + data = [] + for act in activities: + user_info = None + if act.user: + user_info = { + 'id': act.user.id, + 'username': act.user.username, + 'email': act.user.email, + 'display_name': act.user.get_display_name(), + 'is_admin': act.user.is_admin_user or act.user.is_staff or act.user.is_superuser, + } + + data.append({ + 'id': act.id, + 'user': user_info, + 'guest_name': act.guest_name if not act.user else None, + 'display_name': act.user.get_display_name() if act.user else (act.guest_name or "Unangemeldeter Gast"), + 'session_key': act.session_key[:12] + '...', + 'ip_address': act.ip_address or '127.0.0.1', + 'user_agent': act.user_agent, + 'last_path': act.last_path, + 'last_seen': act.last_seen.isoformat(), + }) + + return Response({ + 'group_name': SESSION_MONITOR_GROUP, + 'is_authorized': True, + 'count': len(data), + 'sessions': data + }) diff --git a/backend/db.sqlite3 b/backend/db.sqlite3 index 2ded35b..71df3a1 100644 Binary files a/backend/db.sqlite3 and b/backend/db.sqlite3 differ diff --git a/backend/shiftplan_backend/__pycache__/settings.cpython-314.pyc b/backend/shiftplan_backend/__pycache__/settings.cpython-314.pyc index c47fe4e..0f844e6 100644 Binary files a/backend/shiftplan_backend/__pycache__/settings.cpython-314.pyc and b/backend/shiftplan_backend/__pycache__/settings.cpython-314.pyc differ diff --git a/backend/shiftplan_backend/settings.py b/backend/shiftplan_backend/settings.py index 54695bd..0ce2a90 100644 --- a/backend/shiftplan_backend/settings.py +++ b/backend/shiftplan_backend/settings.py @@ -71,6 +71,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'apps.users.middleware.UserActivityMiddleware', ] ROOT_URLCONF = 'shiftplan_backend.urls' diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 1407310..0c6b9ca 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -11,8 +11,8 @@ Schichtplaner — Veranstaltungsschichtpläne - - + +
diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx index 66b9d36..eebadab 100644 --- a/frontend/src/pages/AdminPage.jsx +++ b/frontend/src/pages/AdminPage.jsx @@ -4,13 +4,13 @@ import React, { useState, useEffect } from 'react'; import { Shield, Settings, Users, Calendar, Layers, Palette, Plus, Trash2, Edit3, UserCheck, UserX, Clock, CheckCircle2, AlertCircle, Layout, Search, Eye, EyeOff, - Mail, Send, Lock + Mail, Send, Lock, Activity } from 'lucide-react'; import { apiFetch } from '../api/client'; import { getRandomSkillColor } from '../utils/colors'; export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents, skills, onRefreshSkills }) { - const [activeAdminTab, setActiveAdminTab] = useState('users'); // 'users' | 'events' | 'templates' | 'branding' | 'smtp' + const [activeAdminTab, setActiveAdminTab] = useState('users'); // 'users' | 'events' | 'templates' | 'branding' | 'smtp' | 'sessions' const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -65,6 +65,11 @@ export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents const [savingSmtp, setSavingSmtp] = useState(false); const [testingSmtp, setTestingSmtp] = useState(false); + // 6. Active Sessions & IP Monitoring State (Restricted to Session Monitor Django Group or Superuser) + const [activeSessions, setActiveSessions] = useState([]); + const [isSessionMonitorAuthorized, setIsSessionMonitorAuthorized] = useState(false); + const [sessionMonitorGroupName, setSessionMonitorGroupName] = useState('Session Monitor'); + useEffect(() => { fetchAdminData(); }, []); @@ -118,6 +123,15 @@ export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents } } catch (e) {} + try { + const sessionData = await apiFetch('/users/active-sessions/'); + setActiveSessions(sessionData.sessions || []); + setIsSessionMonitorAuthorized(true); + setSessionMonitorGroupName(sessionData.group_name || 'Session Monitor'); + } catch (e) { + setIsSessionMonitorAuthorized(false); + } + } catch (err) { setError(err.message || 'Laden der Admin-Daten fehlgeschlagen.'); } finally { @@ -157,6 +171,18 @@ export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents } }; + const handleConfirmUserEmail = async (userId) => { + try { + const res = await apiFetch(`/users/manage-users/${userId}/confirm-email/`, { + method: 'POST' + }); + showNotif(res.message || 'E-Mail-Adresse manuell als bestätigt markiert.'); + fetchAdminData(); + } catch (err) { + setError(err.message || 'Fehler beim Bestätigen der E-Mail.'); + } + }; + const handleDeleteUser = async (userObj) => { if (!window.confirm(`Möchtest du das Benutzerkonto "${userObj.display_name || userObj.username}" (@${userObj.username}) wirklich dauerhaft löschen?`)) { return; @@ -178,9 +204,9 @@ export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents await apiFetch(`/users/${userId}/approve/`, { method: 'POST' }); setPendingUsers(pendingUsers.filter(u => u.id !== userId)); fetchAdminData(); - showNotif('Benutzer erfolgreich freigeschaltet.'); + showNotif('Benutzer wurde freigeschaltet!'); } catch (e) { - setError(e.message || 'Freischalten fehlgeschlagen.'); + setError(e.message || 'Fehler beim Freischalten.'); } }; @@ -188,45 +214,44 @@ export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents try { await apiFetch(`/users/${userId}/approve/`, { method: 'DELETE' }); setPendingUsers(pendingUsers.filter(u => u.id !== userId)); - showNotif('Benutzer abgelehnt und gelöscht.'); + showNotif('Registrierungsanfrage abgelehnt.'); } catch (e) { - setError(e.message || 'Ablehnen fehlgeschlagen.'); + setError(e.message || 'Fehler beim Ablehnen.'); } }; - const handleToggleRestriction = async (key, val) => { + // --- SECURITY SETTINGS --- + const handleSaveSecuritySettings = async (e) => { + e.preventDefault(); try { - const payload = { - is_restriction_enabled: key === 'restriction' ? val : restrictionEnabled, - require_admin_approval: key === 'approval' ? val : requireApproval, - require_email_verification: key === 'email_verify' ? val : requireEmailVerification - }; - const res = await apiFetch('/users/restriction-setting/', { + await apiFetch('/users/restriction-setting/', { method: 'POST', - body: JSON.stringify(payload) + body: JSON.stringify({ + is_restriction_enabled: restrictionEnabled, + require_admin_approval: requireApproval, + require_email_verification: requireEmailVerification + }) }); - setRestrictionEnabled(res.is_restriction_enabled); - setRequireApproval(res.require_admin_approval); - setRequireEmailVerification(res.require_email_verification); - showNotif('Sicherheitseinstellungen aktualisiert.'); - } catch (e) { - setError(e.message || 'Aktualisierung fehlgeschlagen.'); + showNotif('Sicherheitseinstellungen gespeichert!'); + } catch (err) { + setError(err.message || 'Speichern der Sicherheitseinstellungen fehlgeschlagen.'); } }; const handleAddDomainRule = async (e) => { e.preventDefault(); if (!newDomain.trim()) return; + try { - const rule = await apiFetch('/users/domain-rules/', { + const created = await apiFetch('/users/domain-rules/', { method: 'POST', - body: JSON.stringify({ domain: newDomain }) + body: JSON.stringify({ domain: newDomain.trim(), is_active: true }) }); - setDomainRules([...domainRules, rule]); + setDomainRules([...domainRules, created]); setNewDomain(''); - showNotif(`Domain-Regel "${rule.domain}" hinzugefügt.`); - } catch (e) { - setError(e.message || 'Fehler beim Hinzufügen.'); + showNotif(`Domain-Regel "${created.domain}" hinzugefügt.`); + } catch (err) { + setError(err.message || 'Hinzufügen der Domain-Regel fehlgeschlagen.'); } }; @@ -235,51 +260,68 @@ export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents await apiFetch(`/users/domain-rules/${id}/`, { method: 'DELETE' }); setDomainRules(domainRules.filter(r => r.id !== id)); showNotif('Domain-Regel entfernt.'); - } catch (e) { - setError(e.message || 'Fehler beim Löschen.'); + } catch (err) { + setError(err.message || 'Fehler beim Löschen der Regel.'); } }; - // --- SKILLS & TEMPLATES --- + // --- EVENTS ACTIONS --- + const handleDeleteEvent = async (eventObj) => { + if (!window.confirm(`Möchtest du die Veranstaltung "${eventObj.title}" unwiderruflich löschen?`)) return; + + try { + await apiFetch(`/events/${eventObj.id}/`, { method: 'DELETE' }); + setEventsList(eventsList.filter(e => e.id !== eventObj.id)); + if (onRefreshEvents) onRefreshEvents(); + showNotif('Veranstaltung wurde gelöscht.'); + } catch (err) { + setError(err.message || 'Löschen fehlgeschlagen.'); + } + }; + + // --- TEMPLATES & SKILLS ACTIONS --- const handleCreateSkill = async (e) => { e.preventDefault(); if (!newSkillName.trim()) return; + try { await apiFetch('/skills/', { method: 'POST', body: JSON.stringify({ - name: newSkillName, - description: newSkillDesc, + name: newSkillName.trim(), + description: newSkillDesc.trim(), color: newSkillColor }) }); setNewSkillName(''); setNewSkillDesc(''); setNewSkillColor(getRandomSkillColor()); - onRefreshSkills(); - showNotif('Qualifikation angelegt.'); - } catch (e) { - setError(e.message || 'Fehler beim Anlegen.'); + showNotif('Neue Qualifikation angelegt.'); + if (onRefreshSkills) onRefreshSkills(); + } catch (err) { + setError(err.message || 'Anlegen der Qualifikation fehlgeschlagen.'); } }; - const handleDeleteSkill = async (id) => { + const handleDeleteSkill = async (skillId) => { + if (!window.confirm('Fähigkeit wirklich löschen?')) return; try { - await apiFetch(`/skills/${id}/`, { method: 'DELETE' }); - onRefreshSkills(); + await apiFetch(`/skills/${skillId}/`, { method: 'DELETE' }); showNotif('Qualifikation gelöscht.'); - } catch (e) { - setError(e.message || 'Fehler beim Löschen.'); + if (onRefreshSkills) onRefreshSkills(); + } catch (err) { + setError(err.message || 'Löschen fehlgeschlagen.'); } }; - const handleDeleteTemplate = async (id) => { + const handleDeleteTemplate = async (templateId) => { + if (!window.confirm('Vorlage wirklich löschen?')) return; try { - await apiFetch(`/templates/${id}/`, { method: 'DELETE' }); - setTemplatesList(templatesList.filter(t => t.id !== id)); + await apiFetch(`/templates/${templateId}/`, { method: 'DELETE' }); + setTemplatesList(templatesList.filter(t => t.id !== templateId)); showNotif('Vorlage gelöscht.'); - } catch (e) { - setError(e.message || 'Fehler beim Löschen.'); + } catch (err) { + setError(err.message || 'Löschen der Vorlage fehlgeschlagen.'); } }; @@ -360,24 +402,36 @@ export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents ); return ( -
- {/* Header Banner */} -
-
-
- -
-
-

- System Administration -

-

- Verwaltung von Benutzern, Freischaltungen, Events, Vorlagen, Branding & SMTP-Mailserver -

+
+ {/* Header Panel */} +
+
+
+ [ SYSTEM-STEUERUNG & ADMINDISTRIBUTION ]
+

+ Verwaltungszentrale +

+

+ Verwalte Konten, Sicherheit, Domain-Regeln, Vorlagen, SMTP & Live-Sitzungen +

+ {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + {/* Atelier Stencil Tab Bar */}
@@ -435,681 +489,727 @@ export default function AdminPage({ branding, onRefreshBranding, onRefreshEvents > [ 05: E-MAIL & SMTP ] + + {isSessionMonitorAuthorized && ( + + )}
- {/* Notifications */} - {error && ( -
- - {error} + {loading ? ( +
+ Lade Admin-Daten...
- )} + ) : ( + <> + {/* TAB 01: BENUTZER & SICHERHEIT */} + {activeAdminTab === 'users' && ( +
+ {/* Pending Approvals Section */} + {pendingUsers.length > 0 && ( +
+

+ ⚠️ Ausstehende Registrierungen ({pendingUsers.length}) +

+
+ {pendingUsers.map(pu => ( +
+
+
{pu.display_name} (@{pu.username})
+
{pu.email}
+
+
+ + +
+
+ ))} +
+
+ )} - {success && ( -
- - {success} -
- )} + {/* User Filter Search Bar */} +
+ + setUserSearchQuery(e.target.value)} + placeholder="Nutzer suchen nach Name, Username oder E-Mail..." + className="w-full bg-subtle border border-grid px-3 py-1.5 rounded-sm text-xs font-mono text-main focus:outline-none focus:border-muted" + /> + {userSearchQuery && ( + + )} +
- {/* TAB 1: BENUTZER & SICHERHEIT */} - {activeAdminTab === 'users' && ( -
- {/* Pending Approval Section */} - {pendingUsers.length > 0 && ( -
-

- ⚠️ Ausstehende Registrierungen ({pendingUsers.length}) + {/* User List Table */} +
+

+ Benutzerkonten & Rechtestatus ({filteredUsers.length}) +

+
+ + + + + + + + + + + + + {filteredUsers.map(u => ( + + + + + + + + + ))} + +
NutzerE-MailFreigeschaltetE-Mail BestätigtAdmin-RolleAktionen
+ {u.display_name || u.username} + @{u.username} + {u.email} + + {u.is_approved ? 'JA' : 'NEIN'} + + +
+ + {u.is_email_verified ? 'VERIFIZIERT' : 'AUSSTEHEND'} + + {!u.is_email_verified && ( + + )} +
+
+ + {u.is_superuser ? 'SUPERUSER' : u.is_admin_user ? 'ADMIN' : 'USER'} + + + + + +
+
+
+ + {/* Security & Domain Restrictions */} +
+

+ Registrierungseinschränkungen & Domains +

+ +
+
+
+
Domain-Einschränkung aktivieren
+
Nur E-Mails aus erlaubten Domains zulassen
+
+ setRestrictionEnabled(e.target.checked)} + className="w-4 h-4 accent-indigo-500 cursor-pointer" + /> +
+ +
+
+
Manuelle Admin-Freischaltung erzwingen
+
Neue Konten sind erst nach Admin-Freigabe aktiv
+
+ setRequireApproval(e.target.checked)} + className="w-4 h-4 accent-indigo-500 cursor-pointer" + /> +
+ +
+
+
E-Mail-Bestätigung erzwingen
+
Benutzer müssen ihre E-Mail-Adresse per Link verifizieren
+
+ setRequireEmailVerification(e.target.checked)} + className="w-4 h-4 accent-indigo-500 cursor-pointer" + /> +
+ +
+ +
+
+ + {/* Domain Rules Manager */} +
+

Erlaubte E-Mail-Domains

+
+ setNewDomain(e.target.value)} + className="flex-1 bg-subtle border border-grid px-3 py-1.5 rounded-sm text-xs font-mono" + /> + +
+ +
+ {domainRules.length === 0 ? ( +

[ Keine Domain-Regeln definiert. Alle E-Mail-Domains sind erlaubt. ]

+ ) : ( + domainRules.map(r => ( +
+ {r.domain} + +
+ )) + )} +
+
+
+

+ )} + + {/* TAB 02: VERANSTALTUNGEN */} + {activeAdminTab === 'events' && ( +
+

+ Veranstaltungen verwalten ({eventsList.length})

- {pendingUsers.map(pu => ( -
+ {eventsList.map(evt => ( +
-
{pu.display_name} (@{pu.username})
-
{pu.email}
-
-
- - +
+ {evt.title} + {evt.is_active === false && ( + [ DEAKTIVIERT ] + )} +
+
+ {new Date(evt.start_date).toLocaleDateString('de-DE')} — {new Date(evt.end_date).toLocaleDateString('de-DE')} {evt.location ? `| Ort: ${evt.location}` : ''} +
+
))}
)} - {/* User Filter Search Bar */} -
- - setUserSearchQuery(e.target.value)} - placeholder="Nutzer suchen nach Name, Username oder E-Mail..." - className="w-full bg-subtle border border-grid px-3 py-1.5 rounded-sm text-xs font-mono text-main focus:outline-none focus:border-muted" - /> - {userSearchQuery && ( - - )} -
+ {/* TAB 03: VORLAGEN & SKILLS */} + {activeAdminTab === 'templates' && ( +
+ {/* Skills Management */} +
+

+ System-Qualifikationen & Fähigkeiten ({skills.length}) +

- {/* All Users List Table */} -
-

- Benutzerkonten & Rechtestatus ({filteredUsers.length}) -

-
- - - - - - - - - - - - - {filteredUsers.map(u => ( - - - - - - - - - ))} - -
NutzerE-MailFreigeschaltetE-Mail BestätigtAdmin-RolleAktionen
- {u.display_name || u.username} - @{u.username} - {u.email} - - {u.is_approved ? 'JA' : 'NEIN'} - - - - {u.is_email_verified ? 'VERIFIZIERT' : 'AUSSTEHEND'} - - - - {u.is_superuser ? 'SUPERUSER' : u.is_admin_user ? 'ADMIN' : 'USER'} - - - - - -
-
-
- - {/* Security & Domain Restrictions */} -
-

- Sicherheit & Registrierungs-Einschränkungen -

- -
-
-
- E-Mail Bestätigung (Opt-Out) - -
-

- Bei Pflicht muss die E-Mail vor der Anmeldung verifiziert werden. Bei Deaktivierung ist die Anmeldung direkt möglich. -

-
- -
-
- Domain-Einschränkung - -
-

- Erlaubt nur Registrierungen von festgelegten E-Mail-Domains (z. B. `@verein.de`). -

-
- -
-
- Admin-Freischaltpflicht - -
-

- Erfordert die manuelle Freischaltung durch einen Admin vor der ersten Anmeldung. -

-
-
- - {/* Allowed Domains List */} - {restrictionEnabled && ( -
-

Erlaubte E-Mail-Domains

-
+ setNewDomain(e.target.value)} - className="flex-1 px-3 py-1.5 rounded-sm input-field text-xs font-mono" + required + placeholder="Name der Qualifikation (z.B. Baristakurs)" + value={newSkillName} + onChange={(e) => setNewSkillName(e.target.value)} + className="sm:col-span-5 bg-subtle border border-grid px-3 py-1.5 rounded-sm text-xs font-mono" + /> + setNewSkillDesc(e.target.value)} + className="sm:col-span-4 bg-subtle border border-grid px-3 py-1.5 rounded-sm text-xs font-mono" /> - -
-
- {domainRules.map(r => ( - - {r.domain} - - - ))} -
-
- )} -
-
- )} - - {/* TAB 2: VERANSTALTUNGEN */} - {activeAdminTab === 'events' && ( -
-
-

- Alle Veranstaltungen ({eventsList.length}) -

-
- {eventsList.map(evt => ( -
-
-

{evt.title}

-

- {new Date(evt.start_date).toLocaleDateString('de-DE')} — {evt.location || 'Kein Ort'} | Erstellt von: {evt.created_by_name || 'Admin'} -

-
-
- - {evt.is_active !== false ? 'AKTIV' : 'DEAKTIVIERT'} - - -
-
- ))} -
-
-
- )} - - {/* TAB 3: VORLAGEN & QUALIFIKATIONEN */} - {activeAdminTab === 'templates' && ( -
- {/* Skills Management */} -
-

- Erforderliche Qualifikationen (Skills) -

- -
- setNewSkillName(e.target.value)} - className="px-3 py-1.5 rounded-sm input-field text-xs font-mono" - /> - setNewSkillDesc(e.target.value)} - className="px-3 py-1.5 rounded-sm input-field text-xs font-mono" - /> -
- setNewSkillColor(e.target.value)} - className="w-8 h-8 rounded-sm border-0 cursor-pointer bg-subtle p-0.5" - /> - -
-
- -
- {skills.map(s => ( -
-
- {s.name} - {s.description &&

{s.description}

} -
- -
- ))} -
-
- - {/* Event Templates */} -
-

- Gespeicherte Event-Vorlagen ({templatesList.length}) -

-
- {templatesList.map(t => ( -
-
-
{t.name}
-
{t.description || 'Keine Beschreibung'}
-
- -
- ))} -
-
-
- )} - - {/* TAB 4: BRANDING & SYSTEM */} - {activeAdminTab === 'branding' && ( -
-
-

- Portal Branding & Erscheinungsbild -

- -
-
- - setAppName(e.target.value)} - className="w-full px-3 py-1.5 rounded-sm input-field font-bold" - /> -
- -
- -
setPrimaryColor(e.target.value)} - className="w-9 h-9 rounded-sm border-0 cursor-pointer bg-subtle p-0.5" + value={newSkillColor} + onChange={(e) => setNewSkillColor(e.target.value)} + className="sm:col-span-1 h-8 w-full cursor-pointer bg-subtle p-0.5 border border-grid rounded-sm" /> + + + +
+ {skills.map(sk => ( +
+ {sk.name} + +
+ ))} +
+
+ + {/* Templates Management */} +
+

+ Veranstaltungsvorlagen ({templatesList.length}) +

+
+ {templatesList.map(t => ( +
+
+
{t.name}
+
{t.description || '[ Keine Beschreibung ]'}
+
+ +
+ ))} +
+
+
+ )} + + {/* TAB 04: BRANDING & SYSTEM */} + {activeAdminTab === 'branding' && ( +
+

+ Branding & Design-Einstellungen +

+ +
+
+ setPrimaryColor(e.target.value)} - className="flex-1 px-3 py-1.5 rounded-sm input-field font-mono" + value={appName} + onChange={(e) => setAppName(e.target.value)} + className="w-full px-3 py-2 rounded-sm input-field text-xs" + /> +
+ +
+ + setLogoUrl(e.target.value)} + placeholder="https://..." + className="w-full px-3 py-2 rounded-sm input-field text-xs" + /> +
+ +
+ +
+ setPrimaryColor(e.target.value)} + className="h-9 w-12 rounded-sm cursor-pointer bg-subtle p-1 border border-grid" + /> + setPrimaryColor(e.target.value)} + className="flex-1 px-3 py-2 rounded-sm input-field text-xs font-mono" + /> +
+
+ +
+ + setBannerText(e.target.value)} + placeholder="z. B. Willkommen zum Sommerfest-Schichtplan!" + className="w-full px-3 py-2 rounded-sm input-field text-xs" />
-
- - setLogoUrl(e.target.value)} - className="w-full px-3 py-1.5 rounded-sm input-field" - /> -
- -
- -