diff --git a/src/README.md b/src/README.md index 4cf10b49..d15e5a6f 100644 --- a/src/README.md +++ b/src/README.md @@ -75,7 +75,7 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxx ```bash pip install -r requirements.txt ``` -4. Open **4 terminals**, make sure the **venv is activated in each**, and run these commands (from `DomainX/src/backend`): +4. Open **5 terminals**, make sure the **venv is activated in each**, and run these commands (from `DomainX/src/backend`): **Terminal 1 — Redis** ```bash @@ -85,7 +85,7 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxx ```bash python manage_local.py dev ``` - **Terminal 3 — Celery worker (default queue)** + **Terminal 3 — Celery worker (analysis queue)** ```bash python manage_local.py worker ``` @@ -93,7 +93,10 @@ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxx ```bash python manage_local.py worker_gitstats ``` - + **Terminal 5 — Celery worker (email queue)** + ```bash + python manage_local.py email + ``` The API should now be running at: `http://localhost:8000/` diff --git a/src/backend/DomainX/settings.py b/src/backend/DomainX/settings.py index ca2ec2d1..21fd9d5e 100644 --- a/src/backend/DomainX/settings.py +++ b/src/backend/DomainX/settings.py @@ -206,6 +206,9 @@ def env_bool(name: str, default: bool = False) -> bool: { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, + { + 'NAME': 'users.password_validators.StrongPasswordValidator', + }, ] LOGGING = { "version": 1, @@ -272,7 +275,21 @@ def env_bool(name: str, default: bool = False) -> bool: "ROTATE_REFRESH_TOKENS": True, "BLACKLIST_AFTER_ROTATION": False, } +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") + +EMAIL_BACKEND = os.getenv( + "EMAIL_BACKEND", + "django.core.mail.backends.console.EmailBackend" +) + +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "accounts@domainx.local") +EMAIL_HOST = os.getenv("EMAIL_HOST", "") +EMAIL_PORT = int(os.getenv("EMAIL_PORT", 587)) +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") +EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", default=True) +EMAIL_USE_SSL = env_bool("EMAIL_USE_SSL", default=False) STATIC_URL = 'static/' diff --git a/src/backend/api/tasks.py b/src/backend/api/tasks.py index 17e5422b..9d2c6081 100644 --- a/src/backend/api/tasks.py +++ b/src/backend/api/tasks.py @@ -15,7 +15,7 @@ logger = get_task_logger("api.tasks.analyze_repo") -@shared_task(bind=True) +@shared_task(bind=True, queue="analysis") def analyze_repo_task(self, library_id: str, repo_url: str): task_id = getattr(self.request, "id", None) diff --git a/src/backend/manage_local.py b/src/backend/manage_local.py index efe8a154..81eddf7c 100644 --- a/src/backend/manage_local.py +++ b/src/backend/manage_local.py @@ -54,7 +54,7 @@ def main(): if cmd == "worker": if not os.getenv("CELERY_BROKER_URL"): print("CELERY_BROKER_URL is not set. Check backend/.env") - run(["celery", "-A", "DomainX", "worker", "-l", "info", "-P", "solo", "-Q", "celery"]) + run(["celery", "-A", "DomainX", "worker", "-l", "info", "-P", "solo", "-Q", "celery_analysis"]) return if cmd == "worker_gitstats": @@ -62,6 +62,17 @@ def main(): print("CELERY_BROKER_URL is not set. Check backend/.env") run(["celery", "-A", "DomainX", "worker", "-l", "info", "-P", "solo", "-Q", "gitstats"]) return + if cmd == "worker": + if not os.getenv("CELERY_BROKER_URL"): + print("CELERY_BROKER_URL is not set. Check backend/.env") + run(["celery", "-A", "DomainX", "worker", "-l", "info", "-P", "solo", "-Q", "analysis"]) + return + + if cmd == "email": + if not os.getenv("CELERY_BROKER_URL"): + print("CELERY_BROKER_URL is not set. Check backend/.env") + run(["celery", "-A", "DomainX", "worker", "-l", "info", "-P", "solo", "-Q", "email"]) + return if cmd == "loaddata": if len(sys.argv) < 3: diff --git a/src/backend/users/backends.py b/src/backend/users/backends.py index 36551b0a..d398a0e9 100644 --- a/src/backend/users/backends.py +++ b/src/backend/users/backends.py @@ -5,16 +5,22 @@ User = get_user_model() class EmailOrUsernameBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): - if username is None or password is None: + + if not username or not password: return None + username = username.lower() + try: - user = User.objects.get(Q(email__iexact=username) | Q(username__iexact=username)) + user = User.objects.get( + Q(email__iexact=username) | Q(username__iexact=username) + ) except User.DoesNotExist: return None - if user.check_password(password): + if user.check_password(password) and self.user_can_authenticate(user): return user - return None + return None \ No newline at end of file diff --git a/src/backend/users/migrations/0002_alter_customuser_role.py b/src/backend/users/migrations/0002_alter_customuser_role.py new file mode 100644 index 00000000..95f13278 --- /dev/null +++ b/src/backend/users/migrations/0002_alter_customuser_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-03-12 23:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customuser', + name='role', + field=models.CharField(choices=[('user', 'User'), ('admin', 'Admin'), ('superadmin', 'Superadmin')], default='admin', max_length=20), + ), + ] diff --git a/src/backend/users/migrations/0003_userinvite.py b/src/backend/users/migrations/0003_userinvite.py new file mode 100644 index 00000000..b38b6956 --- /dev/null +++ b/src/backend/users/migrations/0003_userinvite.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2026-03-12 23:05 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_alter_customuser_role'), + ] + + operations = [ + migrations.CreateModel( + name='UserInvite', + fields=[ + ('invite_ID', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('token_hash', models.CharField(max_length=64, unique=True)), + ('expires_at', models.DateTimeField()), + ('used_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('invited_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_invites', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/backend/users/migrations/0004_customuser_is_deleted.py b/src/backend/users/migrations/0004_customuser_is_deleted.py new file mode 100644 index 00000000..0f4b3245 --- /dev/null +++ b/src/backend/users/migrations/0004_customuser_is_deleted.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2026-03-14 22:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_userinvite'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='is_deleted', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/backend/users/migrations/0005_passwordresettoken.py b/src/backend/users/migrations/0005_passwordresettoken.py new file mode 100644 index 00000000..14d99749 --- /dev/null +++ b/src/backend/users/migrations/0005_passwordresettoken.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.7 on 2026-03-15 01:26 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_customuser_is_deleted'), + ] + + operations = [ + migrations.CreateModel( + name='PasswordResetToken', + fields=[ + ('reset_ID', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('token_hash', models.CharField(max_length=64, unique=True)), + ('expires_at', models.DateTimeField()), + ('used_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_reset_tokens', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/backend/users/models.py b/src/backend/users/models.py index 572292d6..869f4abc 100644 --- a/src/backend/users/models.py +++ b/src/backend/users/models.py @@ -1,5 +1,11 @@ from django.contrib.auth.models import AbstractUser from django.db import models +import uuid +import secrets +import hashlib +from datetime import timedelta +from django.conf import settings +from django.utils import timezone class CustomUser(AbstractUser): ROLE_CHOICES = ( @@ -9,15 +15,91 @@ class CustomUser(AbstractUser): ) email = models.EmailField(unique=True) - role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user') - + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='admin') + is_deleted = models.BooleanField(default=False) USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['username'] + def save(self, *args, **kwargs): + if self.email: + self.email = self.email.lower() + if self.username: + self.username = self.username.lower() + super().save(*args, **kwargs) + def __str__(self): return self.email - + def get_full_name(self) -> str | None: if self.first_name and self.last_name: - return str( self.first_name + " " + self.last_name ) + return str(self.first_name + " " + self.last_name) return None + + +class UserInvite(models.Model): + invite_ID = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="invites" + ) + invited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sent_invites" + ) + token_hash = models.CharField(max_length=64, unique=True) + expires_at = models.DateTimeField() + used_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def is_expired(self): + return timezone.now() > self.expires_at + + def is_used(self): + return self.used_at is not None + + @classmethod + def create_for_user(cls, user, invited_by=None, hours_valid=24): + raw_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + + invite = cls.objects.create( + user=user, + invited_by=invited_by, + token_hash=token_hash, + expires_at=timezone.now() + timedelta(hours=hours_valid), + ) + return invite, raw_token + +class PasswordResetToken(models.Model): + reset_ID = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="password_reset_tokens" + ) + token_hash = models.CharField(max_length=64, unique=True) + expires_at = models.DateTimeField() + used_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def is_expired(self): + return timezone.now() > self.expires_at + + def is_used(self): + return self.used_at is not None + + @classmethod + def create_for_user(cls, user, hours_valid=1): + raw_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + + reset = cls.objects.create( + user=user, + token_hash=token_hash, + expires_at=timezone.now() + timedelta(hours=hours_valid), + ) + return reset, raw_token diff --git a/src/backend/users/password_validators.py b/src/backend/users/password_validators.py new file mode 100644 index 00000000..3285781c --- /dev/null +++ b/src/backend/users/password_validators.py @@ -0,0 +1,23 @@ +import re +from django.core.exceptions import ValidationError + + +class StrongPasswordValidator: + def validate(self, password, user=None): + if not re.search(r"[A-Z]", password): + raise ValidationError("Password must contain at least one uppercase letter.") + + if not re.search(r"[a-z]", password): + raise ValidationError("Password must contain at least one lowercase letter.") + + if not re.search(r"\d", password): + raise ValidationError("Password must contain at least one number.") + + if not re.search(r"[^\w\s]", password): + raise ValidationError("Password must contain at least one special character.") + + def get_help_text(self): + return ( + "Your password must contain at least 8 characters, including one uppercase letter, " + "one lowercase letter, one number, and one special character." + ) \ No newline at end of file diff --git a/src/backend/users/serializers.py b/src/backend/users/serializers.py index e7935b1f..3f68560a 100644 --- a/src/backend/users/serializers.py +++ b/src/backend/users/serializers.py @@ -1,34 +1,89 @@ from rest_framework import serializers from .models import CustomUser +from api.database.domain.models import Domain -class SignupSerializer(serializers.ModelSerializer): + +class InviteUserSerializer(serializers.Serializer): + email = serializers.EmailField() username = serializers.CharField(required=True) - password = serializers.CharField(write_only=True, min_length=6) - class Meta: - model = CustomUser - fields = ["email", "username", "password"] - def create(self, validated_data): - return CustomUser.objects.create_user(**validated_data) + first_name = serializers.CharField(required=False, allow_blank=True) + last_name = serializers.CharField(required=False, allow_blank=True) + role = serializers.ChoiceField(choices=["admin", "superadmin"]) + domain_ids = serializers.ListField( + child=serializers.CharField(), + required=False, + default=[] + ) + + def validate_email(self, value): + value = value.lower() + if CustomUser.objects.filter(email__iexact=value, is_deleted=False).exists(): + raise serializers.ValidationError("A user with this email already exists.") + return value + + def validate_username(self, value): + value = value.lower() + if CustomUser.objects.filter(username__iexact=value, is_deleted=False).exists(): + raise serializers.ValidationError("A user with this username already exists.") + return value + + +class AcceptInviteSerializer(serializers.Serializer): + token = serializers.CharField(required=True) + password = serializers.CharField(write_only=True, min_length=8) + class UserProfileSerializer(serializers.ModelSerializer): - full_name = serializers.CharField(source='get_full_name', read_only=True, allow_null=True) - + full_name = serializers.CharField(source="get_full_name", read_only=True, allow_null=True) + class Meta: model = CustomUser - fields = ["id", "email", "username", "role", "first_name", "last_name", "full_name"] - read_only_fields = ["email", "role"] + fields = [ + "id", + "email", + "username", + "role", + "first_name", + "last_name", + "full_name", + "is_active", + ] + class UserWithDomainsSerializer(serializers.ModelSerializer): - full_name = serializers.CharField(source='get_full_name', read_only=True, allow_null=True) + full_name = serializers.CharField(source="get_full_name", read_only=True, allow_null=True) domains = serializers.SerializerMethodField() - + class Meta: model = CustomUser - fields = ["id", "email", "username", "role", "first_name", "last_name", "full_name", "domains"] - read_only_fields = ["email", "role"] - + fields = [ + "id", + "email", + "username", + "role", + "first_name", + "last_name", + "full_name", + "is_active", + "domains", + ] + def get_domains(self, obj): - if obj.role in ['admin', 'superadmin']: + if obj.role in ["admin", "superadmin"]: domains = obj.created_domains.all() - return [{'domain_ID': str(d.domain_ID), 'domain_name': d.domain_name} for d in domains] - return [] \ No newline at end of file + return [ + {"domain_ID": str(d.domain_ID), "domain_name": d.domain_name} + for d in domains + ] + return [] + +class ForgotPasswordSerializer(serializers.Serializer): + email = serializers.EmailField() + + def validate_email(self, value): + return value.lower() + + +class ResetPasswordSerializer(serializers.Serializer): + token = serializers.CharField(required=True) + password = serializers.CharField(write_only=True, min_length=8) diff --git a/src/backend/users/tasks.py b/src/backend/users/tasks.py new file mode 100644 index 00000000..b20ec135 --- /dev/null +++ b/src/backend/users/tasks.py @@ -0,0 +1,14 @@ +from celery import shared_task +from django.conf import settings +from django.core.mail import send_mail + + +@shared_task(queue="email") +def send_email_task(to_email, subject, body): + send_mail( + subject=subject, + message=body, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[to_email], + fail_silently=False, + ) \ No newline at end of file diff --git a/src/backend/users/urls.py b/src/backend/users/urls.py index b52d43e5..824230e8 100644 --- a/src/backend/users/urls.py +++ b/src/backend/users/urls.py @@ -1,17 +1,40 @@ from django.urls import path -from .views.auth_views import LoginView, LogoutView, MeView, SignupView, UserListView, UserUpdateView from .views.profile import ProfileView -from .views.auth_views import ChangePasswordView -from .views.auth_views import UserDomainListView +from .views.auth_views import ( + LoginView, + LogoutView, + MeView, + InviteUserView, + AcceptInviteView, + UserListView, + UserUpdateView, + ChangePasswordView, + UserDomainListView, + ValidateInviteView, + DeactivateUserView, + ForgotPasswordView, + ResetPasswordView, + ValidateResetPasswordView, + ResendInviteView +) urlpatterns = [ - path("signup/", SignupView.as_view(), name="signup"), path("login/", LoginView.as_view(), name="login"), path("logout/", LogoutView.as_view(), name="logout"), path("me/", MeView.as_view(), name="me"), + path("users/invite/", InviteUserView.as_view(), name="invite-user"), + path("users//resend-invite/", ResendInviteView.as_view(), name="resend-invite"), + path("validate-invite/", ValidateInviteView.as_view()), + path("auth/accept-invite/", AcceptInviteView.as_view(), name="accept-invite"), path("profile/", ProfileView.as_view(), name="profile"), path("users/", UserListView.as_view(), name="users"), path("users//", UserUpdateView.as_view(), name="user-update"), path("users//change-password/", ChangePasswordView.as_view(), name="change-password"), path("users//domains/", UserDomainListView.as_view(), name="user-domains"), + path("users//deactivate/", DeactivateUserView.as_view()), + path("forgot-password/", ForgotPasswordView.as_view(), name="forgot-password"), + path("reset-password/", ResetPasswordView.as_view(), name="reset-password"), + path("validate-reset-password/", ValidateResetPasswordView.as_view(), name="validate-reset-password"), + + ] diff --git a/src/backend/users/views/auth_views.py b/src/backend/users/views/auth_views.py index aa803c26..c2b4f127 100644 --- a/src/backend/users/views/auth_views.py +++ b/src/backend/users/views/auth_views.py @@ -1,42 +1,55 @@ +import hashlib +from django.conf import settings +from django.contrib.auth import authenticate +from django.db import transaction +from django.utils import timezone from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from django.contrib.auth import authenticate -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework_simplejwt.tokens import RefreshToken -from ..serializers import UserProfileSerializer, SignupSerializer, UserWithDomainsSerializer -from django.conf import settings -from rest_framework.permissions import AllowAny -from ..models import CustomUser +from ..models import CustomUser, UserInvite, PasswordResetToken from api.database.domain.models import Domain +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from ..tasks import send_email_task from ..serializers import ( - UserProfileSerializer, - SignupSerializer, - UserWithDomainsSerializer + InviteUserSerializer, + AcceptInviteSerializer, + UserProfileSerializer, + UserWithDomainsSerializer, + ForgotPasswordSerializer, + ResetPasswordSerializer, ) -class SignupView(APIView): - permission_classes = [AllowAny] - def post(self, request): - serializer = SignupSerializer(data=request.data) - if not serializer.is_valid(): - return Response( - {"errors": serializer.errors}, - status=status.HTTP_400_BAD_REQUEST - ) +def send_invitation_for_user(user, invited_by): + invite, raw_token = UserInvite.create_for_user( + user=user, + invited_by=invited_by, + hours_valid=24 * 7, + ) - user = serializer.save() + invite_url = f"{settings.FRONTEND_URL}/accept-invite?token={raw_token}" + + subject = "You're invited to DomainX" + body = ( + f"Hi {user.first_name or user.username},\n\n" + f"You've been invited to join DomainX.\n\n" + f"To activate your account, please set your password using the link below:\n\n" + f"{invite_url}\n\n" + f"This invitation link will expire in 7 days.\n" + f"If it expires before you use it, please contact your administrator to request a new invitation.\n\n" + f"Welcome to DomainX,\n" + f"The DomainX Team" + ) + + send_email_task.delay(user.email, subject, body) - return Response( - { - "message": "Account created successfully.", - "user": UserProfileSerializer(user).data, - }, - status=status.HTTP_201_CREATED - ) class LoginView(APIView): + permission_classes = [AllowAny] + def post(self, request): login_value = request.data.get("login") password = request.data.get("password") @@ -49,93 +62,225 @@ def post(self, request): response = Response({"user": UserProfileSerializer(user).data}, status=status.HTTP_200_OK) - response.set_cookie("access_token", str(refresh.access_token), - httponly=True, secure=not settings.DEBUG, samesite="Lax") - response.set_cookie("refresh_token", str(refresh), - httponly=True, secure=not settings.DEBUG, samesite="Lax") + response.set_cookie( + "access_token", + str(refresh.access_token), + httponly=True, + secure=not settings.DEBUG, + samesite="Lax", + ) + response.set_cookie( + "refresh_token", + str(refresh), + httponly=True, + secure=not settings.DEBUG, + samesite="Lax", + ) return response class MeView(APIView): permission_classes = [IsAuthenticated] + def get(self, request): return Response({"user": UserProfileSerializer(request.user).data}) class LogoutView(APIView): + permission_classes = [IsAuthenticated] + def post(self, request): response = Response({"message": "Logged out"}, status=200) - response.delete_cookie("access_token") - response.delete_cookie("refresh_token") + response.delete_cookie("access_token", path="/") + response.delete_cookie("refresh_token", path="/") return response +class InviteUserView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + if request.user.role != "superadmin": + return Response( + {"error": "You do not have permission to invite users."}, + status=status.HTTP_403_FORBIDDEN + ) + + serializer = InviteUserSerializer(data=request.data) + if not serializer.is_valid(): + return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + data = serializer.validated_data + + with transaction.atomic(): + user = CustomUser.objects.filter(email__iexact=data["email"].lower(), is_deleted=True).first() + + if user: + user.email = data["email"].lower() + user.username = data["username"].lower() + user.first_name = data.get("first_name", "") + user.last_name = data.get("last_name", "") + user.role = data["role"] + user.is_active = False + user.is_deleted = False + user.set_unusable_password() + user.save() + else: + user = CustomUser.objects.create_user( + email=data["email"].lower(), + username=data["username"].lower(), + first_name=data.get("first_name", ""), + last_name=data.get("last_name", ""), + role=data["role"], + is_active=False, + ) + user.set_unusable_password() + user.save() + + domain_ids = data.get("domain_ids", []) + if domain_ids: + domains = Domain.objects.filter(domain_ID__in=domain_ids) + user.created_domains.set(domains) + else: + user.created_domains.clear() + + send_invitation_for_user(user=user, invited_by=request.user) + + return Response( + {"message": "Invitation sent successfully."}, + status=status.HTTP_201_CREATED + ) + + +class ResendInviteView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, user_id): + if request.user.role != "superadmin": + return Response( + {"error": "You do not have permission to resend invites."}, + status=status.HTTP_403_FORBIDDEN + ) + + try: + user = CustomUser.objects.get(id=user_id, is_deleted=False) + except CustomUser.DoesNotExist: + return Response({"error": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + if user.is_active: + return Response({"error": "User is already active."}, status=status.HTTP_400_BAD_REQUEST) + + send_invitation_for_user(user=user, invited_by=request.user) + + return Response({"message": "Invitation resent successfully."}, status=status.HTTP_200_OK) + + +class AcceptInviteView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = AcceptInviteSerializer(data=request.data) + if not serializer.is_valid(): + return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + token = serializer.validated_data["token"] + password = serializer.validated_data["password"] + + token_hash = hashlib.sha256(token.encode()).hexdigest() + + try: + invite = UserInvite.objects.select_related("user").get(token_hash=token_hash) + except UserInvite.DoesNotExist: + return Response({"error": "Invalid invite link."}, status=status.HTTP_400_BAD_REQUEST) + + if invite.is_used(): + return Response({"error": "This invite link has already been used."}, status=400) + + if invite.user.is_active: + return Response({"error": "Account already activated."}, status=400) + + if invite.is_expired(): + return Response( + {"error": "This invite link has expired. Please contact your administrator to request a new invitation."}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + validate_password(password, user=invite.user) + except ValidationError as e: + return Response({"errors": {"password": e.messages}}, status=status.HTTP_400_BAD_REQUEST) + + user = invite.user + user.set_password(password) + user.is_active = True + user.save() + + invite.used_at = timezone.now() + invite.save(update_fields=["used_at"]) + + return Response({"message": "Account activated successfully."}, status=status.HTTP_200_OK) + + class UserListView(APIView): permission_classes = [IsAuthenticated] - + def get(self, request): - # Restrict access to superadmin only - if request.user.role != 'superadmin': + if request.user.role != "superadmin": return Response( {"error": "You do not have permission to access this resource."}, status=status.HTTP_403_FORBIDDEN ) - - from ..models import CustomUser - role = request.query_params.get('role', None) - include_domains = request.query_params.get('include_domains', 'false').lower() == 'true' - - users = CustomUser.objects.all() - + + role = request.query_params.get("role", None) + include_domains = request.query_params.get("include_domains", "false").lower() == "true" + + users = CustomUser.objects.filter(is_deleted=False) + if role: - roles = [r.strip() for r in role.split(',')] + roles = [r.strip() for r in role.split(",")] users = users.filter(role__in=roles) - + if include_domains: - users = users.prefetch_related('created_domains') + users = users.prefetch_related("created_domains") return Response(UserWithDomainsSerializer(users, many=True).data) + return Response(UserProfileSerializer(users, many=True).data) class UserUpdateView(APIView): permission_classes = [IsAuthenticated] - + def patch(self, request, user_id): - from ..models import CustomUser - is_self = str(request.user.id) == str(user_id) - is_admin = request.user.role == 'admin' - is_super = request.user.role == 'superadmin' + is_admin = request.user.role == "admin" + is_super = request.user.role == "superadmin" - # Only self, admin, or superadmin can even enter if not is_self and not (is_admin or is_super): return Response({"error": "Forbidden"}, status=403) - + try: user_to_update = CustomUser.objects.get(id=user_id) except CustomUser.DoesNotExist: return Response({"error": "User not found."}, status=404) - - # Basic Info (Only Self or Superadmin) + if is_self or is_super: - fields = ['first_name', 'last_name', 'username', 'email'] + fields = ["first_name", "last_name", "username", "email"] for field in fields: - val = request.data.get(field if field != 'username' else 'user_name') + val = request.data.get(field if field != "username" else "user_name") if val is not None: + if field in ["email", "username"] and isinstance(val, str): + val = val.lower() setattr(user_to_update, field, val) - - # Role Management (Only Superadmin) + if is_super: - role = request.data.get('role') - if role in ['user', 'admin', 'superadmin']: + role = request.data.get("role") + if role in ["admin", "superadmin"]: user_to_update.role = role - - # Domain Management (Admin or Superadmin) - domain_ids = request.data.get('domain_ids') + + domain_ids = request.data.get("domain_ids") if domain_ids is not None and (is_admin or is_super): if is_admin: - # Admin can only assign domains they themselves are in - my_domains = set(request.user.created_domains.values_list('domain_ID', flat=True)) + my_domains = set(request.user.created_domains.values_list("domain_ID", flat=True)) valid_ids = [d_id for d_id in domain_ids if d_id in my_domains] else: valid_ids = domain_ids @@ -145,21 +290,25 @@ def patch(self, request, user_id): try: domain = Domain.objects.get(domain_ID=d_id) user_to_update.created_domains.add(domain) - except Domain.DoesNotExist: continue + except Domain.DoesNotExist: + continue + + if CustomUser.objects.exclude(id=user_to_update.id).filter(email__iexact=user_to_update.email).exists(): + return Response({"error": "A user with this email already exists."}, status=400) + + if CustomUser.objects.exclude(id=user_to_update.id).filter(username__iexact=user_to_update.username).exists(): + return Response({"error": "A user with this username already exists."}, status=400) user_to_update.save() return Response({"message": "Profile updated successfully"}, status=200) - + + class ChangePasswordView(APIView): permission_classes = [IsAuthenticated] def post(self, request, user_id): - from ..models import CustomUser - - # 1. Strict Ownership Check - # Convert user_id to int to ensure the comparison works correctly is_owner = request.user.id == int(user_id) - is_superadmin = request.user.role == 'superadmin' + is_superadmin = request.user.role == "superadmin" if not is_owner and not is_superadmin: return Response( @@ -168,7 +317,6 @@ def post(self, request, user_id): ) try: - # Fetch the actual user being targeted target_user = CustomUser.objects.get(id=user_id) except CustomUser.DoesNotExist: return Response({"error": "User not found."}, status=404) @@ -176,33 +324,195 @@ def post(self, request, user_id): old_password = request.data.get("old_password") new_password = request.data.get("new_password") - # 2. Validation if is_owner: if not old_password: return Response({"error": "Current password is required."}, status=400) if not target_user.check_password(old_password): return Response({"error": "Current password is incorrect."}, status=400) - if not new_password or len(new_password) < 8: - return Response({"error": "New password must be at least 8 characters long."}, status=400) + try: + validate_password(new_password, user=target_user) + except ValidationError as e: + return Response({"errors": {"new_password": e.messages}}, status=400) - # 3. Save to the TARGET user, not request.user target_user.set_password(new_password) target_user.save() return Response({"message": "Password updated successfully."}, status=status.HTTP_200_OK) + class UserDomainListView(APIView): permission_classes = [IsAuthenticated] def get(self, request, user_id): try: user = CustomUser.objects.get(id=user_id) - # Fetching domains based on your Serializer logic - if user.role in ['admin', 'superadmin']: + if user.role in ["admin", "superadmin"]: domains = user.created_domains.all() - data = [{'domain_ID': str(d.domain_ID), 'domain_name': d.domain_name} for d in domains] + data = [{"domain_ID": str(d.domain_ID), "domain_name": d.domain_name} for d in domains] return Response(data, status=200) return Response([], status=200) except CustomUser.DoesNotExist: return Response({"error": "User not found"}, status=404) + + +class ValidateInviteView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + token = request.query_params.get("token") + + if not token: + return Response({"error": "Invalid token"}, status=400) + + token_hash = hashlib.sha256(token.encode()).hexdigest() + + try: + invite = UserInvite.objects.select_related("user").get(token_hash=token_hash) + except UserInvite.DoesNotExist: + return Response({"valid": False}, status=200) + + if invite.is_used() or invite.user.is_active: + return Response({"valid": False}, status=200) + + if invite.is_expired(): + return Response({"valid": False}, status=200) + + return Response({"valid": True}, status=200) + + +class DeactivateUserView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, user_id): + if request.user.role != "superadmin": + return Response( + {"error": "You do not have permission to deactivate users."}, + status=403, + ) + + try: + target_user = CustomUser.objects.get(id=user_id, is_deleted=False) + except CustomUser.DoesNotExist: + return Response({"error": "User not found."}, status=404) + + if target_user.id == request.user.id: + return Response( + {"error": "You cannot deactivate yourself."}, + status=400, + ) + + if target_user.role == "superadmin": + remaining = CustomUser.objects.filter( + role="superadmin", + is_deleted=False + ).exclude(id=target_user.id).count() + + if remaining == 0: + return Response( + {"error": "You cannot deactivate the last superadmin."}, + status=400, + ) + + target_user.is_active = False + target_user.is_deleted = True + target_user.save(update_fields=["is_active", "is_deleted"]) + + return Response({"message": "User deactivated successfully."}, status=200) + + +class ForgotPasswordView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = ForgotPasswordSerializer(data=request.data) + if not serializer.is_valid(): + return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + email = serializer.validated_data["email"] + + user = CustomUser.objects.filter(email__iexact=email, is_deleted=False).first() + + if user and user.is_active: + reset, raw_token = PasswordResetToken.create_for_user(user=user, hours_valid=1) + + reset_url = f"{settings.FRONTEND_URL}/reset-password?token={raw_token}" + + subject = "Reset your DomainX password" + body = ( + f"Hi {user.first_name or user.username},\n\n" + f"We received a request to reset your DomainX password.\n\n" + f"Use the link below to set a new password:\n\n" + f"{reset_url}\n\n" + f"This link will expire in 1 hour.\n" + f"If you did not request this, you can ignore this email.\n\n" + f"The DomainX Team" + ) + + send_email_task.delay(user.email, subject, body) + + return Response( + {"message": "If an account with that email exists, a password reset link has been sent."}, + status=status.HTTP_200_OK + ) + + +class ResetPasswordView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = ResetPasswordSerializer(data=request.data) + if not serializer.is_valid(): + return Response({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + token = serializer.validated_data["token"] + password = serializer.validated_data["password"] + + token_hash = hashlib.sha256(token.encode()).hexdigest() + + try: + reset = PasswordResetToken.objects.select_related("user").get(token_hash=token_hash) + except PasswordResetToken.DoesNotExist: + return Response({"error": "Invalid reset link."}, status=status.HTTP_400_BAD_REQUEST) + + if reset.is_used(): + return Response({"error": "This reset link has already been used."}, status=status.HTTP_400_BAD_REQUEST) + + if reset.is_expired(): + return Response({"error": "This reset link has expired."}, status=status.HTTP_400_BAD_REQUEST) + + try: + validate_password(password, user=reset.user) + except ValidationError as e: + return Response({"errors": {"password": e.messages}}, status=status.HTTP_400_BAD_REQUEST) + + user = reset.user + user.set_password(password) + user.save() + + reset.used_at = timezone.now() + reset.save(update_fields=["used_at"]) + + return Response({"message": "Password reset successfully."}, status=status.HTTP_200_OK) + + +class ValidateResetPasswordView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + token = request.query_params.get("token") + + if not token: + return Response({"valid": False}, status=200) + + token_hash = hashlib.sha256(token.encode()).hexdigest() + + try: + reset = PasswordResetToken.objects.select_related("user").get(token_hash=token_hash) + except PasswordResetToken.DoesNotExist: + return Response({"valid": False}, status=200) + + if reset.is_used() or reset.is_expired(): + return Response({"valid": False}, status=200) + + return Response({"valid": True}, status=200) \ No newline at end of file diff --git a/src/docker-compose.yml b/src/docker-compose.yml index decc84cc..4921d4e5 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -35,11 +35,11 @@ services: extra_hosts: - "host.docker.internal:host-gateway" - celery: + celery_analysis: build: ./backend env_file: - .env - command: celery -A DomainX worker -l info -Q celery + command: celery -A DomainX worker -l info -Q analysis --concurrency=1 depends_on: backend: condition: service_started @@ -91,6 +91,24 @@ services: volumes: - gitstats_data:/data/gitstats + celery_email: + build: ./backend + env_file: + - .env + command: celery -A DomainX worker -l info -Q email --concurrency=1 + depends_on: + backend: + condition: service_started + redis: + condition: service_started + restart: always + networks: + - domainx-net + secrets: + - github_app_pem + extra_hosts: + - "host.docker.internal:host-gateway" + web: build: ./frontend depends_on: diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 8e906ec4..ac7851bd 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -5,7 +5,7 @@ import Home from './pages/Home'; import Login from './pages/Login'; import Visualize from './pages/Visualize'; import Main from "./pages/Main"; -import Signup from "./pages/Signup"; +import AcceptInvite from "./pages/AcceptInvite"; import Metrics from "./pages/Metrics"; import ComparisonTool from "./pages/ComparisonTool"; import Edit from "./pages/Edit"; @@ -19,14 +19,16 @@ import "./styles/auth.css"; import "./styles/components.css"; import "./styles/visualize.css"; import UserProfilePage from './pages/UserProfile'; +import ForgotPassword from "./pages/ForgotPassword"; +import ResetPassword from "./pages/ResetPassword"; const App: React.FC = () => { return ( - } /> + } /> } /> - } /> + } /> } /> {/* } /> */} } /> @@ -39,9 +41,11 @@ const App: React.FC = () => { } /> } /> } /> - } /> + {/* } />*/} } /> } /> + } /> + } /> ); diff --git a/src/frontend/src/components/DomainInfo.tsx b/src/frontend/src/components/DomainInfo.tsx index 23e0df99..80b86ab8 100644 --- a/src/frontend/src/components/DomainInfo.tsx +++ b/src/frontend/src/components/DomainInfo.tsx @@ -12,7 +12,6 @@ const DomainInfo: React.FC = ({ selectedDomain, sidebarOpen, se const navigate = useNavigate(); const { user } = useAuthStore(); - // Check if current user is a creator or superadmin const isCreator = user && selectedDomain?.creators?.some((c: any) => c.id === user.id); const isSuperAdmin = user?.role === "superadmin"; const canEditDomain = isCreator || isSuperAdmin; @@ -33,9 +32,9 @@ const DomainInfo: React.FC = ({ selectedDomain, sidebarOpen, se }} >
= ({ selectedDomain, sidebarOpen, se <>

Details

Name: {selectedDomain?.domain_name || "N/A"}
- {/* commenting this field out since our versioning is not implemented yet -
Version: {selectedDomain?.description || "No version available"}
- */}
Authors: