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='')
skills = models.ManyToManyField('events.Skill', blank=True, related_name='users')
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):
return self.display_name or self.username or self.email
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}"
class RegistrationDomainRule(models.Model):
@@ -47,6 +48,15 @@ class RegistrationRestrictionSetting(models.Model):
def __str__(self):
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):
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
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:
model = User
fields = ['id', 'username', 'email', 'display_name', 'is_admin_user', 'is_staff', 'is_superuser', 'is_approved', 'is_active', 'skills', 'skill_ids']
read_only_fields = ['id', 'is_staff', 'is_superuser', 'is_approved']
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', 'is_email_verified']
class AdminUserSerializer(serializers.ModelSerializer):
skills = SkillSimpleSerializer(many=True, read_only=True)
@@ -29,7 +29,7 @@ class AdminUserSerializer(serializers.ModelSerializer):
class Meta:
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']
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 rest_framework.test import APIClient
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
User = get_user_model()
@@ -38,7 +38,41 @@ class UserRegistrationTests(TestCase):
'display_name': 'Allowed User'
})
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):
# Create event, task area, shift
@@ -73,11 +107,6 @@ class UserRegistrationTests(TestCase):
self.assertIsNone(signup.guest_name)
def test_admin_approval_workflow(self):
# Enable admin approval requirement
setting = RegistrationRestrictionSetting.get_solo()
setting.require_admin_approval = True
setting.save()
# Register user
res_reg = self.client.post('/api/users/register/', {
'username': 'pending_user',
@@ -86,8 +115,6 @@ class UserRegistrationTests(TestCase):
'display_name': 'Pending User'
})
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)
res_login_fail = self.client.post('/api/users/login/', {
+2 -1
View File
@@ -1,7 +1,7 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
RegisterView, LoginView, MeView,
RegisterView, VerifyEmailView, LoginView, MeView,
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
PendingUsersView, ApproveUserView, AdminUserViewSet,
GenerateClaimLinkView, ClaimInfoView
@@ -13,6 +13,7 @@ router.register('manage-users', AdminUserViewSet, basename='admin-user')
urlpatterns = [
path('register/', RegisterView.as_view(), name='register'),
path('verify-email/', VerifyEmailView.as_view(), name='verify-email'),
path('login/', LoginView.as_view(), name='login'),
path('me/', MeView.as_view(), name='me'),
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 django.contrib.auth import authenticate, get_user_model
from django.shortcuts import get_object_or_404
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken, EmailVerificationToken
from .serializers import (
UserSerializer, AdminUserSerializer, RegisterSerializer,
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer
@@ -24,6 +24,11 @@ class RegisterView(views.APIView):
serializer = RegisterSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
user.is_approved = False
user.is_email_verified = False
user.is_active = True
user.save()
setting = RegistrationRestrictionSetting.get_solo()
claim_token_str = request.data.get('claim_token')
@@ -65,27 +70,48 @@ class RegisterView(views.APIView):
signup.save()
assigned_count += 1
if setting.require_admin_approval:
user.is_approved = False
user.is_active = False
user.save()
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)
v_token = EmailVerificationToken.objects.create(user=user)
print("==================================================")
print(f"[E-MAIL BESTÄTIGUNG]: http://localhost:3000/?verify_email={v_token.token}")
print("==================================================")
token, _ = Token.objects.get_or_create(user=user)
user_data = UserSerializer(user).data
return Response({
'token': token.key,
'user': user_data,
'requires_verification': True,
'verification_token': str(v_token.token),
'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)
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):
permission_classes = [permissions.IsAuthenticated]
@@ -147,9 +173,9 @@ class LoginView(views.APIView):
pass
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({
'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)
user = authenticate(username=username, password=password)
@@ -211,7 +237,7 @@ class PendingUsersView(views.APIView):
permission_classes = [permissions.IsAdminUser]
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)
class ApproveUserView(views.APIView):
+8 -11
View File
@@ -3,18 +3,17 @@ version: '3.8'
services:
db:
image: postgres:16-alpine
container_name: shiftplan_db_dev
environment:
POSTGRES_DB: shiftplan_db_dev
POSTGRES_USER: shiftplan_user
POSTGRES_PASSWORD: shiftplan_dev_password_123
POSTGRES_DB: ${POSTGRES_DB:-shiftplan_db_dev}
POSTGRES_USER: ${POSTGRES_USER:-shiftplan_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shiftplan_dev_password_123}
volumes:
- postgres_dev_data:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
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
timeout: 5s
retries: 5
@@ -23,19 +22,18 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: shiftplan_backend_dev
command: >
sh -c "python manage.py makemigrations --noinput &&
python manage.py migrate --noinput &&
python manage.py seed_data &&
python manage.py runserver 0.0.0.0:8000"
environment:
SECRET_KEY: "dev-secret-key-shiftplan"
SECRET_KEY: ${SECRET_KEY:-dev-secret-key-shiftplan}
DEBUG: "True"
ALLOWED_HOSTS: "*"
POSTGRES_DB: shiftplan_db_dev
POSTGRES_USER: shiftplan_user
POSTGRES_PASSWORD: shiftplan_dev_password_123
POSTGRES_DB: ${POSTGRES_DB:-shiftplan_db_dev}
POSTGRES_USER: ${POSTGRES_USER:-shiftplan_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shiftplan_dev_password_123}
POSTGRES_HOST: db
POSTGRES_PORT: "5432"
volumes:
@@ -49,7 +47,6 @@ services:
frontend:
image: node:20-alpine
container_name: shiftplan_frontend_dev
working_dir: /app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 3000"
volumes:
+21 -18
View File
@@ -1,20 +1,23 @@
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:
db:
image: postgres:16-alpine
container_name: shiftplan_db
environment:
POSTGRES_DB: shiftplan_db
POSTGRES_USER: shiftplan_user
POSTGRES_PASSWORD: shiftplan_secure_password_123
POSTGRES_DB: ${POSTGRES_DB:-shiftplan_db}
POSTGRES_USER: ${POSTGRES_USER:-shiftplan_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shiftplan_secure_password_123}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
expose:
- "5432"
restart: unless-stopped
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
timeout: 5s
retries: 5
@@ -23,35 +26,35 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: shiftplan_backend
command: >
sh -c "python manage.py makemigrations --noinput &&
python manage.py migrate --noinput &&
python manage.py seed_data &&
gunicorn --bind 0.0.0.0:8000 --workers 3 shiftplan_backend.wsgi:application"
environment:
SECRET_KEY: "prod-secret-key-change-in-env"
DEBUG: "False"
ALLOWED_HOSTS: "*"
POSTGRES_DB: shiftplan_db
POSTGRES_USER: shiftplan_user
POSTGRES_PASSWORD: shiftplan_secure_password_123
SECRET_KEY: ${SECRET_KEY:-prod-secret-key-change-in-coolify}
DEBUG: ${DEBUG:-False}
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-*}
POSTGRES_DB: ${POSTGRES_DB:-shiftplan_db}
POSTGRES_USER: ${POSTGRES_USER:-shiftplan_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shiftplan_secure_password_123}
POSTGRES_HOST: db
POSTGRES_PORT: "5432"
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
expose:
- "8000"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: shiftplan_frontend
expose:
- "80"
ports:
- "80:80"
- "${PORT:-80}:80"
depends_on:
- backend
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 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>
<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">
</head>
<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 {
const userData = await apiFetch('/users/me/');
setUser(userData);
if (!userData.is_admin_user && !userData.is_staff && !userData.is_superuser && activeTab === 'admin') {
setActiveTab('home');
}
} catch (e) {
setAuthToken(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 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 gName = urlParams.get('guest_name');
if (cToken && gName) {
@@ -190,12 +210,16 @@ export default function App() {
const handleLogout = () => {
setAuthToken(null);
setUser(null);
setActiveTab('home');
showNotification('Erfolgreich abgemeldet.');
if (selectedEventId) fetchEventMatrix(selectedEventId);
};
const handleAuthSuccess = (userData, message) => {
setUser(userData);
if (userData && !userData.is_admin_user && !userData.is_staff && !userData.is_superuser && activeTab === 'admin') {
setActiveTab('home');
}
showNotification(message || 'Erfolgreich angemeldet!');
if (selectedEventId) fetchEventMatrix(selectedEventId);
};
@@ -423,7 +447,7 @@ export default function App() {
onSelectEvent={handleSelectEvent}
branding={branding}
/>
) : activeTab === 'admin' ? (
) : activeTab === 'admin' && user && (user.is_admin_user || user.is_staff || user.is_superuser) ? (
<AdminPage
branding={branding}
onRefreshBranding={async () => {
+2 -2
View File
@@ -48,8 +48,8 @@ export default function AuthModal({ onClose, onSuccess, claimToken, prefilledGue
body: JSON.stringify(bodyObj)
});
if (data.requires_approval) {
onSuccess(null, data.message);
if (data.requires_verification || data.requires_approval || !data.token) {
onSuccess(null, data.message || 'Konto erfolgreich registriert! Bitte schau in deine E-Mail zur Bestätigung oder warte auf die Admin-Freischaltung.');
onClose();
return;
}