feat: Add email verification, owner shift deletion, Coolify Docker compose setup, and fixes

This commit is contained in:
Richard
2026-07-31 10:16:22 +02:00
parent a69f31649c
commit 6ffcc58a4c
13 changed files with 458 additions and 334 deletions
@@ -0,0 +1,36 @@
# Generated by Django 6.0.7 on 2026-07-31 08:14
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_guestclaimtoken'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_email_verified',
field=models.BooleanField(default=False, help_text='E-Mail Bestätigungslink angeklickt'),
),
migrations.AlterField(
model_name='user',
name='is_approved',
field=models.BooleanField(default=False, help_text='Per Admin oder E-Mail-Bestätigung freigeschaltet'),
),
migrations.CreateModel(
name='EmailVerificationToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('is_used', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='verification_tokens', to=settings.AUTH_USER_MODEL)),
],
),
]
+12 -2
View File
@@ -8,13 +8,14 @@ class User(AbstractUser):
display_name = models.CharField(max_length=150, blank=True, default='') display_name = models.CharField(max_length=150, blank=True, default='')
skills = models.ManyToManyField('events.Skill', blank=True, related_name='users') skills = models.ManyToManyField('events.Skill', blank=True, related_name='users')
is_admin_user = models.BooleanField(default=False) is_admin_user = models.BooleanField(default=False)
is_approved = models.BooleanField(default=True, help_text="Vom Admin freigeschaltet") is_approved = models.BooleanField(default=False, help_text="Per Admin oder E-Mail-Bestätigung freigeschaltet")
is_email_verified = models.BooleanField(default=False, help_text="E-Mail Bestätigungslink angeklickt")
def get_display_name(self): def get_display_name(self):
return self.display_name or self.username or self.email return self.display_name or self.username or self.email
def __str__(self): def __str__(self):
status_str = " (Ausstehend)" if not self.is_approved else "" status_str = " (Ausstehend)" if not (self.is_approved or self.is_email_verified) else ""
return f"{self.get_display_name()} ({self.email}){status_str}" return f"{self.get_display_name()} ({self.email}){status_str}"
class RegistrationDomainRule(models.Model): class RegistrationDomainRule(models.Model):
@@ -47,6 +48,15 @@ class RegistrationRestrictionSetting(models.Model):
def __str__(self): def __str__(self):
return f"Domain-Einschränkung: {self.is_restriction_enabled}, Admin-Freischaltung: {self.require_admin_approval}" return f"Domain-Einschränkung: {self.is_restriction_enabled}, Admin-Freischaltung: {self.require_admin_approval}"
class EmailVerificationToken(models.Model):
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='verification_tokens')
is_used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Verification Token for {self.user.username} ({self.token})"
class GuestClaimToken(models.Model): class GuestClaimToken(models.Model):
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
signup = models.ForeignKey('events.ShiftSignup', on_delete=models.CASCADE, related_name='claim_tokens') signup = models.ForeignKey('events.ShiftSignup', on_delete=models.CASCADE, related_name='claim_tokens')
+3 -3
View File
@@ -18,8 +18,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User model = User
fields = ['id', 'username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser', 'is_approved', 'is_active', 'skills', 'skill_ids'] fields = ['id', 'username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser', 'is_approved', 'is_email_verified', 'is_active', 'skills', 'skill_ids']
read_only_fields = ['id', 'is_staff', 'is_superuser', 'is_approved'] read_only_fields = ['id', 'is_staff', 'is_superuser', 'is_approved', 'is_email_verified']
class AdminUserSerializer(serializers.ModelSerializer): class AdminUserSerializer(serializers.ModelSerializer):
skills = SkillSimpleSerializer(many=True, read_only=True) skills = SkillSimpleSerializer(many=True, read_only=True)
@@ -29,7 +29,7 @@ class AdminUserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User model = User
fields = ['id', 'username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser', 'is_approved', 'is_active', 'skills', 'skill_ids', 'date_joined'] fields = ['id', 'username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser', 'is_approved', 'is_email_verified', 'is_active', 'skills', 'skill_ids', 'date_joined']
read_only_fields = ['id', 'date_joined'] read_only_fields = ['id', 'date_joined']
class RegisterSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer):
+36 -9
View File
@@ -2,7 +2,7 @@ from django.test import TestCase
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework import status from rest_framework import status
from .models import RegistrationDomainRule, RegistrationRestrictionSetting from .models import RegistrationDomainRule, RegistrationRestrictionSetting, EmailVerificationToken
from apps.events.models import Event, TaskArea, Shift, ShiftSignup from apps.events.models import Event, TaskArea, Shift, ShiftSignup
User = get_user_model() User = get_user_model()
@@ -38,7 +38,41 @@ class UserRegistrationTests(TestCase):
'display_name': 'Allowed User' 'display_name': 'Allowed User'
}) })
self.assertEqual(res_ok.status_code, status.HTTP_201_CREATED) self.assertEqual(res_ok.status_code, status.HTTP_201_CREATED)
self.assertIn('token', res_ok.data) self.assertTrue(res_ok.data.get('requires_verification'))
def test_email_verification_flow(self):
# Register new user
res_reg = self.client.post('/api/users/register/', {
'username': 'verify_user',
'email': 'verify@example.com',
'password': 'password123',
'display_name': 'Verify User'
})
self.assertEqual(res_reg.status_code, status.HTTP_201_CREATED)
self.assertTrue(res_reg.data.get('requires_verification'))
v_token_str = res_reg.data.get('verification_token')
# Login should be blocked prior to verification or admin approval
res_login_blocked = self.client.post('/api/users/login/', {
'username': 'verify_user',
'password': 'password123'
})
self.assertEqual(res_login_blocked.status_code, status.HTTP_403_FORBIDDEN)
# Verify email using verification token
res_verify = self.client.post('/api/users/verify-email/', {
'token': v_token_str
})
self.assertEqual(res_verify.status_code, status.HTTP_200_OK)
self.assertIn('token', res_verify.data)
# Login should now succeed
res_login_ok = self.client.post('/api/users/login/', {
'username': 'verify_user',
'password': 'password123'
})
self.assertEqual(res_login_ok.status_code, status.HTTP_200_OK)
self.assertIn('token', res_login_ok.data)
def test_guest_shift_claiming_on_registration(self): def test_guest_shift_claiming_on_registration(self):
# Create event, task area, shift # Create event, task area, shift
@@ -73,11 +107,6 @@ class UserRegistrationTests(TestCase):
self.assertIsNone(signup.guest_name) self.assertIsNone(signup.guest_name)
def test_admin_approval_workflow(self): def test_admin_approval_workflow(self):
# Enable admin approval requirement
setting = RegistrationRestrictionSetting.get_solo()
setting.require_admin_approval = True
setting.save()
# Register user # Register user
res_reg = self.client.post('/api/users/register/', { res_reg = self.client.post('/api/users/register/', {
'username': 'pending_user', 'username': 'pending_user',
@@ -86,8 +115,6 @@ class UserRegistrationTests(TestCase):
'display_name': 'Pending User' 'display_name': 'Pending User'
}) })
self.assertEqual(res_reg.status_code, status.HTTP_201_CREATED) self.assertEqual(res_reg.status_code, status.HTTP_201_CREATED)
self.assertTrue(res_reg.data.get('requires_approval'))
self.assertNotIn('token', res_reg.data)
# Attempt login before approval (should be blocked) # Attempt login before approval (should be blocked)
res_login_fail = self.client.post('/api/users/login/', { res_login_fail = self.client.post('/api/users/login/', {
+2 -1
View File
@@ -1,7 +1,7 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import ( from .views import (
RegisterView, LoginView, MeView, RegisterView, VerifyEmailView, LoginView, MeView,
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView, RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
PendingUsersView, ApproveUserView, AdminUserViewSet, PendingUsersView, ApproveUserView, AdminUserViewSet,
GenerateClaimLinkView, ClaimInfoView GenerateClaimLinkView, ClaimInfoView
@@ -13,6 +13,7 @@ router.register('manage-users', AdminUserViewSet, basename='admin-user')
urlpatterns = [ urlpatterns = [
path('register/', RegisterView.as_view(), name='register'), path('register/', RegisterView.as_view(), name='register'),
path('verify-email/', VerifyEmailView.as_view(), name='verify-email'),
path('login/', LoginView.as_view(), name='login'), path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'), path('me/', MeView.as_view(), name='me'),
path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'), path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'),
+44 -18
View File
@@ -3,7 +3,7 @@ from rest_framework.response import Response
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import authenticate, get_user_model
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken, EmailVerificationToken
from .serializers import ( from .serializers import (
UserSerializer, AdminUserSerializer, RegisterSerializer, UserSerializer, AdminUserSerializer, RegisterSerializer,
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer
@@ -24,6 +24,11 @@ class RegisterView(views.APIView):
serializer = RegisterSerializer(data=request.data) serializer = RegisterSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
user = serializer.save() user = serializer.save()
user.is_approved = False
user.is_email_verified = False
user.is_active = True
user.save()
setting = RegistrationRestrictionSetting.get_solo() setting = RegistrationRestrictionSetting.get_solo()
claim_token_str = request.data.get('claim_token') claim_token_str = request.data.get('claim_token')
@@ -65,27 +70,48 @@ class RegisterView(views.APIView):
signup.save() signup.save()
assigned_count += 1 assigned_count += 1
if setting.require_admin_approval: v_token = EmailVerificationToken.objects.create(user=user)
user.is_approved = False print("==================================================")
user.is_active = False print(f"[E-MAIL BESTÄTIGUNG]: http://localhost:3000/?verify_email={v_token.token}")
user.save() print("==================================================")
return Response({
'requires_approval': True,
'claimed_shifts_count': assigned_count,
'message': 'Konto erfolgreich registriert! Ein Administrator muss dein Konto vor der ersten Anmeldung freischalten.'
}, status=status.HTTP_201_CREATED)
token, _ = Token.objects.get_or_create(user=user)
user_data = UserSerializer(user).data
return Response({ return Response({
'token': token.key, 'requires_verification': True,
'user': user_data, 'verification_token': str(v_token.token),
'claimed_shifts_count': assigned_count, 'claimed_shifts_count': assigned_count,
'message': f'Konto erfolgreich erstellt! {assigned_count} Gast-Schichten wurden deinem Konto zugewiesen.' if assigned_count > 0 else 'Konto erfolgreich erstellt!' 'message': 'Konto erfolgreich erstellt! Bitte bestätige deine E-Mail-Adresse über den Link in der Bestätigungs-E-Mail oder warte auf die Admin-Freischaltung.'
}, status=status.HTTP_201_CREATED) }, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class VerifyEmailView(views.APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
token_str = request.data.get('token')
if not token_str:
return Response({'error': 'Token erforderlich.'}, status=status.HTTP_400_BAD_REQUEST)
v_token = EmailVerificationToken.objects.filter(token=token_str, is_used=False).first()
if not v_token:
return Response({'error': 'Ungültiger oder bereits verwendeter Bestätigungslink.'}, status=status.HTTP_400_BAD_REQUEST)
v_token.is_used = True
v_token.save()
user = v_token.user
user.is_email_verified = True
user.is_approved = True
user.is_active = True
user.save()
token, _ = Token.objects.get_or_create(user=user)
return Response({
'token': token.key,
'user': UserSerializer(user).data,
'message': f'E-Mail-Adresse für {user.get_display_name()} wurde erfolgreich bestätigt! Du bist jetzt angemeldet.'
}, status=status.HTTP_200_OK)
class GenerateClaimLinkView(views.APIView): class GenerateClaimLinkView(views.APIView):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
@@ -147,9 +173,9 @@ class LoginView(views.APIView):
pass pass
if user_obj: if user_obj:
if not user_obj.is_approved or not user_obj.is_active: if not (user_obj.is_approved or user_obj.is_email_verified or user_obj.is_superuser or user_obj.is_admin_user):
return Response({ return Response({
'error': 'Dein Konto wurde noch nicht von einem Administrator freigeschaltet. Bitte gedulde dich.' 'error': 'Anmeldung nicht möglich: Deine E-Mail-Adresse wurde noch nicht bestätigt und dein Konto wurde noch nicht vom Admin freigeschaltet.'
}, status=status.HTTP_403_FORBIDDEN) }, status=status.HTTP_403_FORBIDDEN)
user = authenticate(username=username, password=password) user = authenticate(username=username, password=password)
@@ -211,7 +237,7 @@ class PendingUsersView(views.APIView):
permission_classes = [permissions.IsAdminUser] permission_classes = [permissions.IsAdminUser]
def get(self, request): def get(self, request):
pending_users = User.objects.filter(is_approved=False).order_by('-date_joined') pending_users = User.objects.filter(is_approved=False, is_email_verified=False, is_superuser=False).order_by('-date_joined')
return Response(UserSerializer(pending_users, many=True).data) return Response(UserSerializer(pending_users, many=True).data)
class ApproveUserView(views.APIView): class ApproveUserView(views.APIView):
+8 -11
View File
@@ -3,18 +3,17 @@ version: '3.8'
services: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: shiftplan_db_dev
environment: environment:
POSTGRES_DB: shiftplan_db_dev POSTGRES_DB: ${POSTGRES_DB:-shiftplan_db_dev}
POSTGRES_USER: shiftplan_user POSTGRES_USER: ${POSTGRES_USER:-shiftplan_user}
POSTGRES_PASSWORD: shiftplan_dev_password_123 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shiftplan_dev_password_123}
volumes: volumes:
- postgres_dev_data:/var/lib/postgresql/data - postgres_dev_data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5432:5432"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U shiftplan_user -d shiftplan_db_dev"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-shiftplan_user} -d ${POSTGRES_DB:-shiftplan_db_dev}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -23,19 +22,18 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: shiftplan_backend_dev
command: > command: >
sh -c "python manage.py makemigrations --noinput && sh -c "python manage.py makemigrations --noinput &&
python manage.py migrate --noinput && python manage.py migrate --noinput &&
python manage.py seed_data && python manage.py seed_data &&
python manage.py runserver 0.0.0.0:8000" python manage.py runserver 0.0.0.0:8000"
environment: environment:
SECRET_KEY: "dev-secret-key-shiftplan" SECRET_KEY: ${SECRET_KEY:-dev-secret-key-shiftplan}
DEBUG: "True" DEBUG: "True"
ALLOWED_HOSTS: "*" ALLOWED_HOSTS: "*"
POSTGRES_DB: shiftplan_db_dev POSTGRES_DB: ${POSTGRES_DB:-shiftplan_db_dev}
POSTGRES_USER: shiftplan_user POSTGRES_USER: ${POSTGRES_USER:-shiftplan_user}
POSTGRES_PASSWORD: shiftplan_dev_password_123 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shiftplan_dev_password_123}
POSTGRES_HOST: db POSTGRES_HOST: db
POSTGRES_PORT: "5432" POSTGRES_PORT: "5432"
volumes: volumes:
@@ -49,7 +47,6 @@ services:
frontend: frontend:
image: node:20-alpine image: node:20-alpine
container_name: shiftplan_frontend_dev
working_dir: /app working_dir: /app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 3000" command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 3000"
volumes: volumes:
+21 -18
View File
@@ -1,20 +1,23 @@
version: '3.8' version: '3.8'
# Coolify & Production Ready Docker Compose Configuration
# Traefik in Coolify automatically routes traffic to the 'frontend' service on port 80.
# Container names and port bindings are intentionally managed by Coolify to avoid collisions.
services: services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: shiftplan_db
environment: environment:
POSTGRES_DB: shiftplan_db POSTGRES_DB: ${POSTGRES_DB:-shiftplan_db}
POSTGRES_USER: shiftplan_user POSTGRES_USER: ${POSTGRES_USER:-shiftplan_user}
POSTGRES_PASSWORD: shiftplan_secure_password_123 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shiftplan_secure_password_123}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: expose:
- "5432:5432" - "5432"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U shiftplan_user -d shiftplan_db"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-shiftplan_user} -d ${POSTGRES_DB:-shiftplan_db}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -23,35 +26,35 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: shiftplan_backend
command: > command: >
sh -c "python manage.py makemigrations --noinput && sh -c "python manage.py makemigrations --noinput &&
python manage.py migrate --noinput && python manage.py migrate --noinput &&
python manage.py seed_data && python manage.py seed_data &&
gunicorn --bind 0.0.0.0:8000 --workers 3 shiftplan_backend.wsgi:application" gunicorn --bind 0.0.0.0:8000 --workers 3 shiftplan_backend.wsgi:application"
environment: environment:
SECRET_KEY: "prod-secret-key-change-in-env" SECRET_KEY: ${SECRET_KEY:-prod-secret-key-change-in-coolify}
DEBUG: "False" DEBUG: ${DEBUG:-False}
ALLOWED_HOSTS: "*" ALLOWED_HOSTS: ${ALLOWED_HOSTS:-*}
POSTGRES_DB: shiftplan_db POSTGRES_DB: ${POSTGRES_DB:-shiftplan_db}
POSTGRES_USER: shiftplan_user POSTGRES_USER: ${POSTGRES_USER:-shiftplan_user}
POSTGRES_PASSWORD: shiftplan_secure_password_123 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shiftplan_secure_password_123}
POSTGRES_HOST: db POSTGRES_HOST: db
POSTGRES_PORT: "5432" POSTGRES_PORT: "5432"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
ports: expose:
- "8000:8000" - "8000"
restart: unless-stopped restart: unless-stopped
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: shiftplan_frontend expose:
- "80"
ports: ports:
- "80:80" - "${PORT:-80}:80"
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<title>Schichtplaner — Veranstaltungsschichtpläne</title> <title>Schichtplaner — Veranstaltungsschichtpläne</title>
<script type="module" crossorigin src="/assets/index-D9iEQytx.js"></script> <script type="module" crossorigin src="/assets/index-BHd9ZwUB.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-VOnznzhX.css"> <link rel="stylesheet" crossorigin href="/assets/index-VOnznzhX.css">
</head> </head>
<body class="bg-paper text-main font-sans antialiased min-h-screen"> <body class="bg-paper text-main font-sans antialiased min-h-screen">
+26 -2
View File
@@ -106,14 +106,34 @@ export default function App() {
try { try {
const userData = await apiFetch('/users/me/'); const userData = await apiFetch('/users/me/');
setUser(userData); setUser(userData);
if (!userData.is_admin_user && !userData.is_staff && !userData.is_superuser && activeTab === 'admin') {
setActiveTab('home');
}
} catch (e) { } catch (e) {
setAuthToken(null); setAuthToken(null);
setUser(null); setUser(null);
setActiveTab('home');
} }
} }
// 3. Check URL parameters for guest account claim link // 3. Check URL parameters for email verification and claim link
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const verifyToken = urlParams.get('verify_email');
if (verifyToken) {
try {
const data = await apiFetch('/users/verify-email/', {
method: 'POST',
body: JSON.stringify({ token: verifyToken })
});
setAuthToken(data.token);
setUser(data.user);
showNotification(data.message || '✅ E-Mail-Adresse erfolgreich bestätigt!');
window.history.replaceState({}, document.title, window.location.pathname);
} catch (err) {
showNotification(`⚠️ E-Mail Bestätigung: ${err.message}`);
}
}
const cToken = urlParams.get('claim_token'); const cToken = urlParams.get('claim_token');
const gName = urlParams.get('guest_name'); const gName = urlParams.get('guest_name');
if (cToken && gName) { if (cToken && gName) {
@@ -190,12 +210,16 @@ export default function App() {
const handleLogout = () => { const handleLogout = () => {
setAuthToken(null); setAuthToken(null);
setUser(null); setUser(null);
setActiveTab('home');
showNotification('Erfolgreich abgemeldet.'); showNotification('Erfolgreich abgemeldet.');
if (selectedEventId) fetchEventMatrix(selectedEventId); if (selectedEventId) fetchEventMatrix(selectedEventId);
}; };
const handleAuthSuccess = (userData, message) => { const handleAuthSuccess = (userData, message) => {
setUser(userData); setUser(userData);
if (userData && !userData.is_admin_user && !userData.is_staff && !userData.is_superuser && activeTab === 'admin') {
setActiveTab('home');
}
showNotification(message || 'Erfolgreich angemeldet!'); showNotification(message || 'Erfolgreich angemeldet!');
if (selectedEventId) fetchEventMatrix(selectedEventId); if (selectedEventId) fetchEventMatrix(selectedEventId);
}; };
@@ -423,7 +447,7 @@ export default function App() {
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
branding={branding} branding={branding}
/> />
) : activeTab === 'admin' ? ( ) : activeTab === 'admin' && user && (user.is_admin_user || user.is_staff || user.is_superuser) ? (
<AdminPage <AdminPage
branding={branding} branding={branding}
onRefreshBranding={async () => { onRefreshBranding={async () => {
+2 -2
View File
@@ -48,8 +48,8 @@ export default function AuthModal({ onClose, onSuccess, claimToken, prefilledGue
body: JSON.stringify(bodyObj) body: JSON.stringify(bodyObj)
}); });
if (data.requires_approval) { if (data.requires_verification || data.requires_approval || !data.token) {
onSuccess(null, data.message); onSuccess(null, data.message || 'Konto erfolgreich registriert! Bitte schau in deine E-Mail zur Bestätigung oder warte auf die Admin-Freischaltung.');
onClose(); onClose();
return; return;
} }