feat: Add email verification, owner shift deletion, Coolify Docker compose setup, and fixes
This commit is contained in:
+36
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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/', {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+267
File diff suppressed because one or more lines are too long
-267
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user