From 013705b58eb5c2a1ee914f47a08f423d663e7edb Mon Sep 17 00:00:00 2001 From: hamidizf Date: Thu, 12 Mar 2026 22:02:46 -0400 Subject: [PATCH 01/31] add user invite to create user and token --- src/backend/users/models.py | 58 +++++++++- src/backend/users/password_validators.py | 0 .../pages/{Signup.tsx => AcceptInvite.tsx} | 104 +++++++++--------- 3 files changed, 105 insertions(+), 57 deletions(-) create mode 100644 src/backend/users/password_validators.py rename src/frontend/src/pages/{Signup.tsx => AcceptInvite.tsx} (51%) diff --git a/src/backend/users/models.py b/src/backend/users/models.py index 572292d6..fe554f1f 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,61 @@ 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') 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 \ No newline at end of file diff --git a/src/backend/users/password_validators.py b/src/backend/users/password_validators.py new file mode 100644 index 00000000..e69de29b diff --git a/src/frontend/src/pages/Signup.tsx b/src/frontend/src/pages/AcceptInvite.tsx similarity index 51% rename from src/frontend/src/pages/Signup.tsx rename to src/frontend/src/pages/AcceptInvite.tsx index 19ee64fd..f55bfdaa 100644 --- a/src/frontend/src/pages/Signup.tsx +++ b/src/frontend/src/pages/AcceptInvite.tsx @@ -1,40 +1,59 @@ import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { apiUrl } from "../config/api"; -const Signup: React.FC = () => { - const [email, setEmail] = useState(""); - const [username, setUsername] = useState(""); + +const AcceptInvite: React.FC = () => { + const [searchParams] = useSearchParams(); + const token = searchParams.get("token") || ""; const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); const [loading, setLoading] = useState(false); const navigate = useNavigate(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setLoading(true); setError(null); + setSuccess(null); + + if (!token) { + setError("Invalid or missing invite token."); + return; + } + + if (password.length < 8) { + setError("Password must be at least 8 characters long."); + return; + } + + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } try { - const result = await fetch(apiUrl("/signup/"), { + setLoading(true); + + const result = await fetch(apiUrl("/auth/accept-invite/"), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, username, password }), + body: JSON.stringify({ token, password }), }); const data = await result.json(); if (!result.ok) { - setError(data.errors?.password || data.errors?.email || data.errors?.username || "Signup failed"); - setLoading(false); - return; + throw new Error(data.error || data.errors?.password?.[0] || "Failed to activate account."); } - navigate("/login"); - } catch (err: any) { - setError("Something went wrong."); + setSuccess("Account activated successfully. Redirecting to login..."); + setTimeout(() => navigate("/login"), 1500); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setLoading(false); } - - setLoading(false); }; return ( @@ -42,78 +61,55 @@ const Signup: React.FC = () => {
-

Create Your Account

+

Set Your Password

- Join DomainX and access the evaluation tools and dashboards. + Complete your DomainX account setup.

- -

Sign up

+

Activate account

- - - {error &&
{error}
} + {success &&
{success}
} - -
- Already have an account?{" "} - navigate("/login")} - > - Login - -
-
-
); }; -export default Signup; +export default AcceptInvite; \ No newline at end of file From 745a6771c3a9ecf17e6a5f18b1586f40f0637a6c Mon Sep 17 00:00:00 2001 From: hamidizf Date: Thu, 12 Mar 2026 22:07:12 -0400 Subject: [PATCH 02/31] migration --- .../migrations/0002_alter_customuser_role.py | 18 ++++++++++++ .../users/migrations/0003_userinvite.py | 28 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/backend/users/migrations/0002_alter_customuser_role.py create mode 100644 src/backend/users/migrations/0003_userinvite.py 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)), + ], + ), + ] From dc70a570171074810490138be00bf2a05dc06ee3 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Thu, 12 Mar 2026 22:08:05 -0400 Subject: [PATCH 03/31] password validator, check if user can authenticate --- src/backend/users/backends.py | 14 ++++++++++---- src/backend/users/password_validators.py | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) 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/password_validators.py b/src/backend/users/password_validators.py index e69de29b..3285781c 100644 --- a/src/backend/users/password_validators.py +++ 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 From 5237d97c88fc66eb03055d0ab2e7bd21b07ff5e7 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Thu, 12 Mar 2026 23:12:05 -0400 Subject: [PATCH 04/31] change sign up page to accept invite --- src/frontend/src/App.tsx | 4 +- src/frontend/src/pages/AcceptInvite.tsx | 159 ++++++++++++++++++++++-- 2 files changed, 154 insertions(+), 9 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 8e906ec4..0a3c9629 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"; @@ -26,7 +26,7 @@ const App: React.FC = () => { } /> } /> - } /> + } /> } /> {/* } /> */} } /> diff --git a/src/frontend/src/pages/AcceptInvite.tsx b/src/frontend/src/pages/AcceptInvite.tsx index f55bfdaa..3c4f9b86 100644 --- a/src/frontend/src/pages/AcceptInvite.tsx +++ b/src/frontend/src/pages/AcceptInvite.tsx @@ -1,19 +1,92 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { apiUrl } from "../config/api"; +const validatePassword = (password: string) => { + return { + length: password.length >= 8, + upper: /[A-Z]/.test(password), + lower: /[a-z]/.test(password), + number: /\d/.test(password), + special: /[^\w\s]/.test(password), + }; +}; + const AcceptInvite: React.FC = () => { const [searchParams] = useSearchParams(); const token = searchParams.get("token") || ""; + const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [loading, setLoading] = useState(false); + const [checkingInvite, setCheckingInvite] = useState(true); + const navigate = useNavigate(); + const passwordRules = validatePassword(password); + + const passwordValid = + passwordRules.length && + passwordRules.upper && + passwordRules.lower && + passwordRules.number && + passwordRules.special; + + const passwordsMatch = password === confirmPassword; + + const getInputBorder = (type: "password" | "confirm") => { + if (type === "confirm" && confirmPassword.length > 0 && !passwordsMatch) { + return "2px solid #ff4d4f"; + } + if (type === "password" && password.length > 0 && !passwordValid) { + return "2px solid #ff4d4f"; + } + return undefined; + }; + + useEffect(() => { + const checkInvite = async () => { + if (!token) { + navigate("/login"); + return; + } + + try { + await fetch(apiUrl("/logout/"), { + method: "POST", + credentials: "include", + }); + } catch {} + + try { + const res = await fetch( + apiUrl(`/validate-invite/?token=${encodeURIComponent(token)}`), + { + credentials: "include", + } + ); + const data = await res.json(); + + if (!data.valid) { + navigate("/login"); + return; + } + } catch { + navigate("/login"); + return; + } finally { + setCheckingInvite(false); + } + }; + + checkInvite(); + }, [token, navigate]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setError(null); setSuccess(null); @@ -22,12 +95,12 @@ const AcceptInvite: React.FC = () => { return; } - if (password.length < 8) { - setError("Password must be at least 8 characters long."); + if (!passwordValid) { + setError("Password does not meet the required security rules."); return; } - if (password !== confirmPassword) { + if (!passwordsMatch) { setError("Passwords do not match."); return; } @@ -44,7 +117,22 @@ const AcceptInvite: React.FC = () => { const data = await result.json(); if (!result.ok) { - throw new Error(data.error || data.errors?.password?.[0] || "Failed to activate account."); + const message = + data.error || + data.errors?.password?.[0] || + "Failed to activate account."; + + if ( + message.toLowerCase().includes("expired") || + message.toLowerCase().includes("already been used") || + message.toLowerCase().includes("invalid invite") + ) { + setError(message); + setTimeout(() => navigate("/login"), 1800); + return; + } + + throw new Error(message); } setSuccess("Account activated successfully. Redirecting to login..."); @@ -56,9 +144,25 @@ const AcceptInvite: React.FC = () => { } }; + if (checkingInvite) { + return ( +
+
+
+
+
+

Checking invitation...

+
+
+
+
+ ); + } + return (
+

Set Your Password

@@ -69,7 +173,9 @@ const AcceptInvite: React.FC = () => {
-

Activate account

+

+ Activate account +

+ {confirmPassword && !passwordsMatch && ( + + Passwords do not match + + )} + +
+ Password must contain: +
    +
  • + At least 8 characters +
  • +
  • + One uppercase letter +
  • +
  • + One lowercase letter +
  • +
  • + One number +
  • +
  • + One special character +
  • +
+
+ {error &&
{error}
} {success &&
{success}
} -
From a5fd5a7df79bf705fe2be1f114a5e175b75cc50a Mon Sep 17 00:00:00 2001 From: hamidizf Date: Thu, 12 Mar 2026 23:12:28 -0400 Subject: [PATCH 05/31] add password validation --- src/frontend/src/pages/UserProfile.tsx | 538 ++++++++++++++++++------- 1 file changed, 384 insertions(+), 154 deletions(-) diff --git a/src/frontend/src/pages/UserProfile.tsx b/src/frontend/src/pages/UserProfile.tsx index 89ec2514..4902595c 100644 --- a/src/frontend/src/pages/UserProfile.tsx +++ b/src/frontend/src/pages/UserProfile.tsx @@ -20,12 +20,23 @@ interface User { domains?: Domain[]; } +const validatePassword = (password: string) => { + return { + length: password.length >= 8, + upper: /[A-Z]/.test(password), + lower: /[a-z]/.test(password), + number: /\d/.test(password), + special: /[^\w\s]/.test(password), + }; +}; + const UserProfilePage: React.FC = () => { const navigate = useNavigate(); const { user, logout } = useAuthStore(); const [sidebarOpen, setSidebarOpen] = useState(true); - const [activeTab, setActiveTab] = useState<'details' | 'password'>('details'); + const [activeTab, setActiveTab] = useState<"details" | "password">("details"); const [isModalOpen, setIsModalOpen] = useState(false); + const getSidebarItemStyle = (isActive: boolean) => ({ padding: "12px 16px", cursor: "pointer", @@ -39,9 +50,10 @@ const UserProfilePage: React.FC = () => { border: isActive ? "1px solid rgba(255, 255, 255, 0.1)" : "1px solid transparent", color: isActive ? "var(--accent)" : "var(--text-main)", fontWeight: isActive ? 600 : 400, - width: "calc(100% - 4px)", + width: "calc(100% - 4px)", boxSizing: "border-box" as const, }); + const [formData, setFormData] = useState({ first_name: "", last_name: "", @@ -50,50 +62,47 @@ const UserProfilePage: React.FC = () => { role: "", domain_ids: [] as string[], }); + const [assignedDomains, setAssignedDomains] = useState([]); -const fetchAssignedDomains = async () => { - if (!user?.id) return; - try { - const response = await fetch(apiUrl(`/users/${user.id}/domains/`), { - method: "GET", - credentials: "include", - }); - - if (response.ok) { - const data = await response.json(); - // Ensure the data is assigned to the state used in your JSX - setAssignedDomains(data); - - // Update form data and FORCE IDs to be strings to match checkbox logic - setFormData(prev => ({ - ...prev, - domain_ids: data.map((d: any) => String(d.domain_ID)) - })); + const fetchAssignedDomains = async () => { + if (!user?.id) return; + try { + const response = await fetch(apiUrl(`/users/${user.id}/domains/`), { + method: "GET", + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + setAssignedDomains(data); + setFormData((prev) => ({ + ...prev, + domain_ids: data.map((d: any) => String(d.domain_ID)), + })); + } + } catch (err) { + console.error("Failed to fetch assigned domains:", err); } - } catch (err) { - console.error("Failed to fetch assigned domains:", err); - } -}; + }; -useEffect(() => { + useEffect(() => { document.title = "DomainX – Profile"; - if (user?.id) fetchAssignedDomains(); -}, [user?.id]); + if (user?.id) fetchAssignedDomains(); + }, [user?.id]); + const fetchUserDomains = async () => { if (!user?.id) return; try { - const response = await fetch(apiUrl(`/users/${user.id}/domains/`), { - credentials: "include" + const response = await fetch(apiUrl(`/users/${user.id}/domains/`), { + credentials: "include", }); if (response.ok) { const data = await response.json(); - setAssignedDomains(data); // These are the tags you see in the table - - // Also update the form state so checkboxes are checked - setFormData(prev => ({ + setAssignedDomains(data); + setFormData((prev) => ({ ...prev, - domain_ids: data.map((d: any) => String(d.domain_ID)) + domain_ids: data.map((d: any) => String(d.domain_ID)), })); } } catch (err) { @@ -110,7 +119,9 @@ useEffect(() => { fetchAssignedDomains(); } }, [user?.id]); + const [allDomains, setAllDomains] = useState([]); + useEffect(() => { const fetchAllDomains = async () => { try { @@ -130,16 +141,14 @@ useEffect(() => { }, []); const toggleDomain = (domainId: string) => { - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, domain_ids: prev.domain_ids.includes(domainId) - ? prev.domain_ids.filter(id => id !== domainId) - : [...prev.domain_ids, domainId] + ? prev.domain_ids.filter((id) => id !== domainId) + : [...prev.domain_ids, domainId], })); }; - - const [passwordData, setPasswordData] = useState({ current_password: "", new_password: "", @@ -150,22 +159,35 @@ useEffect(() => { const [error, setError] = useState(null); const [showSuccess, setShowSuccess] = useState(false); const [successMsg, setSuccessMsg] = useState(""); + const passwordsMatch = passwordData.new_password === passwordData.confirm_password; const newPasswordNotEmpty = passwordData.new_password.length > 0; - const isFormValid = passwordData.current_password.length > 0 && - newPasswordNotEmpty && - passwordsMatch; - - const getInputBorder = (type: 'current' | 'new' | 'confirm') => { - if (passwordData.confirm_password.length > 0 && passwordData.new_password !== passwordData.confirm_password) { - return "2px solid #ff4d4f"; // Increased thickness to make it obvious - } + const passwordRules = validatePassword(passwordData.new_password); + const passwordValid = + passwordRules.length && + passwordRules.upper && + passwordRules.lower && + passwordRules.number && + passwordRules.special; + const isFormValid = + passwordData.current_password.length > 0 && + newPasswordNotEmpty && + passwordsMatch && + passwordValid; + + const getInputBorder = (type: "current" | "new" | "confirm") => { + if (type === "confirm" && passwordData.confirm_password.length > 0 && passwordData.new_password !== passwordData.confirm_password) { + return "2px solid #ff4d4f"; + } + if (type === "new" && passwordData.new_password.length > 0 && !passwordValid) { + return "2px solid #ff4d4f"; + } return undefined; }; useEffect(() => { if (user) { - const u = user as User; + const u = user as User; setFormData({ first_name: u.first_name || "", last_name: u.last_name || "", @@ -201,7 +223,6 @@ useEffect(() => { setTimeout(() => { setShowSuccess(false); }, 1000); - } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); } finally { @@ -209,12 +230,19 @@ useEffect(() => { } }; - const handleChangePassword = async (e: React.FormEvent) => { - e.preventDefault(); + const handleChangePassword = async (e?: React.FormEvent) => { + e?.preventDefault(); + + if (!passwordValid) { + setError("Password does not meet the required security rules."); + return; + } + if (passwordData.new_password !== passwordData.confirm_password) { setError("New passwords do not match"); return; } + try { setLoading(true); setError(null); @@ -228,9 +256,15 @@ useEffect(() => { }), }); + const data = await response.json(); + if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to change password"); + throw new Error( + data.error || + data.errors?.new_password?.[0] || + data.errors?.password?.[0] || + "Failed to change password" + ); } setSuccessMsg("Password changed successfully!"); @@ -245,35 +279,50 @@ useEffect(() => { setLoading(false); } }; + const openEditDomains = () => { - const u = user as unknown as User; - const currentIds = assignedDomains.map(d => String(d.domain_ID)); - setFormData(prev => ({ + const currentIds = assignedDomains.map((d) => String(d.domain_ID)); + setFormData((prev) => ({ ...prev, - domain_ids: currentIds + domain_ids: currentIds, })); setIsModalOpen(true); }; - const allFieldsFilled = passwordData.current_password && passwordData.new_password && passwordData.confirm_password; - const isPasswordFormValid = allFieldsFilled && (passwordData.new_password === passwordData.confirm_password); + const allFieldsFilled = + passwordData.current_password && passwordData.new_password && passwordData.confirm_password; + const isPasswordFormValid = + allFieldsFilled && passwordData.new_password === passwordData.confirm_password && passwordValid; + if (!user) return null; + const handleLogout = async () => { - try { - await fetch(apiUrl("/logout/"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - }); - logout(); - navigate("/login"); - } catch (err: any) { - console.log(err); - } - }; + try { + await fetch(apiUrl("/logout/"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }); + logout(); + navigate("/login"); + } catch (err: any) { + console.log(err); + } + }; return ( -
+
{ }} >
setSidebarOpen(!sidebarOpen)} > {sidebarOpen ? "⟨" : "⟩"}
-
+
{ setActiveTab('details'); setError(null); }} - style={getSidebarItemStyle(activeTab === 'details')} + onClick={() => { + setActiveTab("details"); + setError(null); + }} + style={getSidebarItemStyle(activeTab === "details")} > {sidebarOpen ? ( <> @@ -314,8 +379,11 @@ useEffect(() => {
{ setActiveTab('password'); setError(null); }} - style={getSidebarItemStyle(activeTab === 'password')} + onClick={() => { + setActiveTab("password"); + setError(null); + }} + style={getSidebarItemStyle(activeTab === "password")} > {sidebarOpen ? ( <> @@ -329,8 +397,11 @@ useEffect(() => {
- -
@@ -351,169 +426,324 @@ useEffect(() => {

- {activeTab === 'details' ? 'Account Settings' : 'Security Settings'} + {activeTab === "details" ? "Account Settings" : "Security Settings"}

- {error && ( -
+
⚠️ {error}
)} - {activeTab === 'details' ? ( + {activeTab === "details" ? (
- setFormData({ ...formData, first_name: e.target.value })} /> + setFormData({ ...formData, first_name: e.target.value })} + />
- setFormData({ ...formData, last_name: e.target.value })} /> + setFormData({ ...formData, last_name: e.target.value })} + />
- setFormData({ ...formData, user_name: e.target.value })} /> + setFormData({ ...formData, user_name: e.target.value })} + />
- setFormData({ ...formData, email: e.target.value })} /> -
+ setFormData({ ...formData, email: e.target.value })} + /> +
-
-
-
- +
+
+ {(formData.role === "admin" || formData.role === "superadmin") && ( - )}
- -
+ +
{assignedDomains.length > 0 ? ( assignedDomains.map((domain) => ( - {domain.domain_name} )) ) : ( - No domains assigned + + No domains assigned + )}
-
) : ( -
+
- setPasswordData({ ...passwordData, current_password: e.target.value })} + setPasswordData({ ...passwordData, current_password: e.target.value })} />
- setPasswordData({ ...passwordData, new_password: e.target.value })} + setPasswordData({ ...passwordData, new_password: e.target.value })} />
+
+ Password must contain: +
    +
  • + At least 8 characters +
  • +
  • + One uppercase letter +
  • +
  • + One lowercase letter +
  • +
  • + One number +
  • +
  • + One special character +
  • +
+
+
- - setPasswordData({ ...passwordData, confirm_password: e.target.value })} + + setPasswordData({ ...passwordData, confirm_password: e.target.value })} /> {passwordData.confirm_password && !passwordsMatch && ( - Passwords do not match + + Passwords do not match + )}
- -
+ )}
{isModalOpen && ( -
-
-

Manage Domains

-

Select the domains associated with this account.

- -
- {allDomains.map(domain => ( -
@@ -530,9 +674,235 @@ const AdminPage: React.FC = () => {
)} - + {isInviteModalOpen && ( +
+
e.stopPropagation()} + > + + +

+ Invite New User +

+ + {inviteError && ( +
+ {inviteError} +
+ )} + +
+
+ + + setInviteFormData({ ...inviteFormData, first_name: e.target.value }) + } + placeholder="First name" + /> +
+ +
+ + + setInviteFormData({ ...inviteFormData, last_name: e.target.value }) + } + placeholder="Last name" + /> +
+ +
+ + + setInviteFormData({ ...inviteFormData, user_name: e.target.value }) + } + placeholder="Username" + /> +
+ +
+ + + setInviteFormData({ ...inviteFormData, email: e.target.value }) + } + placeholder="Email" + /> +
+ +
+ + +
+ +
+ +
+ {allDomains.length === 0 ? ( +
No domains available
+ ) : ( + allDomains.map((domain) => ( + + )) + )} +
+
+ +
+ + +
+
+
+
+ )} + + <> + + +
); }; -export default AdminPage; +export default AdminPage; \ No newline at end of file diff --git a/src/frontend/src/pages/EditCategoryWeights.tsx b/src/frontend/src/pages/EditCategoryWeights.tsx index 116e71b3..94debb8f 100644 --- a/src/frontend/src/pages/EditCategoryWeights.tsx +++ b/src/frontend/src/pages/EditCategoryWeights.tsx @@ -25,6 +25,8 @@ const EditCategoryWeights: React.FC = () => { 1: 0, 2: 0, 3: 0.58, 4: 0.9, 5: 1.12, 6: 1.24, 7: 1.32, 8: 1.41, 9: 1.45, 10: 1.49 }; useEffect(() => { + document.title = "DomainX - Edit Weight"; + const fetchData = async () => { if (!domainId) return; try { From a889f79e2f259af43e1cbfe413109a40a402b386 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 18:59:33 -0400 Subject: [PATCH 07/31] show if a user is active --- src/backend/users/tasks.py | 0 src/frontend/src/pages/Admin.tsx | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/backend/users/tasks.py diff --git a/src/backend/users/tasks.py b/src/backend/users/tasks.py new file mode 100644 index 00000000..e69de29b diff --git a/src/frontend/src/pages/Admin.tsx b/src/frontend/src/pages/Admin.tsx index 7d047fcc..805042a8 100644 --- a/src/frontend/src/pages/Admin.tsx +++ b/src/frontend/src/pages/Admin.tsx @@ -18,6 +18,7 @@ interface User { first_name: string; last_name: string; full_name: string | null; + is_active: boolean; domains?: Domain[]; } @@ -333,6 +334,7 @@ const AdminPage: React.FC = () => { Last Name Role Associated Domains + Status @@ -426,6 +428,22 @@ const AdminPage: React.FC = () => { N/A )} + + + {u.is_active ? "Active" : "Pending Invite"} + + )) )} From 59a7504bbd747da9f65326d4a3bf55f7d5dc9792 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 19:26:28 -0400 Subject: [PATCH 08/31] send email on celery --- src/backend/users/tasks.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/backend/users/tasks.py b/src/backend/users/tasks.py index e69de29b..e7b67e95 100644 --- a/src/backend/users/tasks.py +++ 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 +def send_invite_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 From 636dd3b3f2afb53ee0971e8855e196d8772c0503 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 19:27:09 -0400 Subject: [PATCH 09/31] added is deleted to deactivate account --- .../migrations/0004_customuser_is_deleted.py | 18 ++++++++++++++++++ src/backend/users/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 src/backend/users/migrations/0004_customuser_is_deleted.py 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/models.py b/src/backend/users/models.py index fe554f1f..3bc92b72 100644 --- a/src/backend/users/models.py +++ b/src/backend/users/models.py @@ -16,7 +16,7 @@ class CustomUser(AbstractUser): email = models.EmailField(unique=True) role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='admin') - + is_deleted = models.BooleanField(default=False) USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['username'] From 627361a244fe823da9a4cc540146b50bde07147a Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 19:27:52 -0400 Subject: [PATCH 10/31] deactivate and send email --- src/backend/users/serializers.py | 80 +++++-- src/backend/users/urls.py | 23 +- src/backend/users/views/auth_views.py | 322 ++++++++++++++++++++------ 3 files changed, 333 insertions(+), 92 deletions(-) diff --git a/src/backend/users/serializers.py b/src/backend/users/serializers.py index e7935b1f..138211b8 100644 --- a/src/backend/users/serializers.py +++ b/src/backend/users/serializers.py @@ -1,34 +1,78 @@ 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 [ + {"domain_ID": str(d.domain_ID), "domain_name": d.domain_name} + for d in domains + ] return [] \ No newline at end of file diff --git a/src/backend/users/urls.py b/src/backend/users/urls.py index b52d43e5..a52093e9 100644 --- a/src/backend/users/urls.py +++ b/src/backend/users/urls.py @@ -1,17 +1,30 @@ 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 +) 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("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()), + ] diff --git a/src/backend/users/views/auth_views.py b/src/backend/users/views/auth_views.py index aa803c26..9f04ac1c 100644 --- a/src/backend/users/views/auth_views.py +++ b/src/backend/users/views/auth_views.py @@ -1,42 +1,30 @@ +import hashlib +from django.conf import settings +from django.contrib.auth import authenticate +from django.core.mail import send_mail +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 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_invite_email_task from ..serializers import ( - UserProfileSerializer, - SignupSerializer, - UserWithDomainsSerializer + InviteUserSerializer, + AcceptInviteSerializer, + UserProfileSerializer, + UserWithDomainsSerializer, ) -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 - ) - - user = serializer.save() - - 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") @@ -45,96 +33,227 @@ def post(self, request): if not user: return Response({"error": "Invalid credentials"}, status=status.HTTP_401_UNAUTHORIZED) + refresh = RefreshToken.for_user(user) 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() + + invite, raw_token = UserInvite.create_for_user( + user=user, + invited_by=request.user, + hours_valid=24 * 7, + ) + + 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_invite_email_task.delay(user.email, subject, body) + + return Response( + {"message": "Invitation sent successfully."}, + status=status.HTTP_201_CREATED + ) + + +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': 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() - + + 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') 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' - # 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'] for field in fields: 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']: + if role in ['admin', 'superadmin']: user_to_update.role = role - - # Domain Management (Admin or Superadmin) + 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)) valid_ids = [d_id for d_id in domain_ids if d_id in my_domains] else: @@ -145,19 +264,21 @@ 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' @@ -168,7 +289,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,29 +296,29 @@ 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']: domains = user.created_domains.all() data = [{'domain_ID': str(d.domain_ID), 'domain_name': d.domain_name} for d in domains] @@ -206,3 +326,67 @@ def get(self, request, user_id): 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) From 3c8c7fdbecc83585dd3fbf0446f2a37fe20f8a02 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 19:29:02 -0400 Subject: [PATCH 11/31] update settings --- src/backend/DomainX/settings.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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/' From c3171f41cab3482fb13b419cdf7abf5d78268a9b Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 19:29:20 -0400 Subject: [PATCH 12/31] deactivate --- src/frontend/src/pages/Admin.tsx | 538 +++++++++++++++++++++++-------- 1 file changed, 408 insertions(+), 130 deletions(-) diff --git a/src/frontend/src/pages/Admin.tsx b/src/frontend/src/pages/Admin.tsx index 805042a8..d6175696 100644 --- a/src/frontend/src/pages/Admin.tsx +++ b/src/frontend/src/pages/Admin.tsx @@ -25,6 +25,7 @@ interface User { const AdminPage: React.FC = () => { const navigate = useNavigate(); const { user } = useAuthStore(); + const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -50,6 +51,10 @@ const AdminPage: React.FC = () => { domain_ids: [] as string[], }); + const [isDeactivateModalOpen, setIsDeactivateModalOpen] = useState(false); + const [userToDeactivate, setUserToDeactivate] = useState(null); + const [deactivateLoading, setDeactivateLoading] = useState(false); + const [allDomains, setAllDomains] = useState([]); const [updateLoading, setUpdateLoading] = useState(false); const [updateError, setUpdateError] = useState(null); @@ -74,15 +79,14 @@ const AdminPage: React.FC = () => { }, [showInviteSuccess]); useEffect(() => { - if (user === undefined) { - return; - } + if (user === undefined) return; + if (!user || user.role !== "superadmin") { navigate("/main"); return; } - document.title = "DomainX – Admin"; + document.title = "DomainX – Admin"; fetchUsers(); fetchAllDomains(); }, [user, navigate]); @@ -164,6 +168,17 @@ const AdminPage: React.FC = () => { setInviteError(null); }; + const openDeactivateModal = (u: User) => { + setUserToDeactivate(u); + setIsDeactivateModalOpen(true); + }; + + const closeDeactivateModal = () => { + if (deactivateLoading) return; + setUserToDeactivate(null); + setIsDeactivateModalOpen(false); + }; + const handleUpdateUser = async () => { if (!editingUser) return; @@ -228,9 +243,9 @@ const AdminPage: React.FC = () => { ); } - await fetchUsers(); setShowInviteSuccess(true); closeInviteModal(); + void fetchUsers(); } catch (err) { setInviteError(err instanceof Error ? err.message : "An error occurred"); } finally { @@ -238,6 +253,37 @@ const AdminPage: React.FC = () => { } }; + const handleDeactivateUser = async () => { + if (!userToDeactivate) return; + + try { + setDeactivateLoading(true); + setError(null); + + const response = await fetch( + apiUrl(`/users/${userToDeactivate.id}/deactivate/`), + { + method: "PATCH", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to deactivate user"); + } + + await fetchUsers(); + setShowUpdateSuccess(true); + closeDeactivateModal(); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setDeactivateLoading(false); + } + }; + const toggleDomain = (domainId: string) => { setEditFormData((prev) => ({ ...prev, @@ -260,7 +306,9 @@ const AdminPage: React.FC = () => { return (
-
Loading...
+
+ Loading... +
); } @@ -269,9 +317,19 @@ const AdminPage: React.FC = () => { return (
-
+
Error: {error} -
@@ -280,7 +338,10 @@ const AdminPage: React.FC = () => { } return ( -
+
{ >
-
+
-

Manage Users

- +

+ Manage Users +

{ flexDirection: "column", }} > +
+ +
-
- -
@@ -341,111 +411,214 @@ const AdminPage: React.FC = () => { {users.length === 0 ? ( - ) : ( - users.map((u) => ( - - - - {u.email} - - - - - + + + + + + - + + + + + + - + + - - )) + + {u.is_active ? "Active" : "Pending Invite"} + + + + ); + }) )}
+ No users found
- - { + const canDeactivate = + !!user && + u.is_active && + u.id !== user.id; + + return ( +
- {u.username} - - {u.first_name || "—"} - - {u.last_name || "—"} - - +
+ + + {canDeactivate && ( + + )} +
+
+ {u.email} + + {u.username} + - {u.role} - - - {u.role === "admin" || u.role === "superadmin" ? ( - u.domains && u.domains.length > 0 ? ( -
- {u.domains.map((domain) => ( - - {domain.domain_name} - - ))} -
+ {u.first_name || "—"} +
+ {u.last_name || "—"} + + + {u.role} + + + {u.role === "admin" || u.role === "superadmin" ? ( + u.domains && u.domains.length > 0 ? ( +
+ {u.domains.map((domain) => ( + + {domain.domain_name} + + ))} +
+ ) : ( + + No domains + + ) ) : ( - No domains - ) - ) : ( - N/A - )} -
- + N/A + + )} + - {u.is_active ? "Active" : "Pending Invite"} - -
@@ -463,7 +636,9 @@ const AdminPage: React.FC = () => {
Total Users: {users.length} Admins: {users.filter((u) => u.role === "admin").length} - Superadmins: {users.filter((u) => u.role === "superadmin").length} + + Superadmins: {users.filter((u) => u.role === "superadmin").length} +
@@ -484,7 +659,6 @@ const AdminPage: React.FC = () => { justifyContent: "center", zIndex: 1000, }} - >
{ }} onClick={(e) => e.stopPropagation()} > - +

Edit User: {editingUser.email}

@@ -693,21 +868,20 @@ const AdminPage: React.FC = () => { )} {isInviteModalOpen && ( -
+
{ border: "none", color: "white", fontSize: "20px", - cursor: "pointer" + cursor: "pointer", }} > × @@ -915,6 +1089,110 @@ const AdminPage: React.FC = () => {
)} + {isDeactivateModalOpen && userToDeactivate && ( +
+
e.stopPropagation()} + > + + +

+ Deactivate User +

+ +
+ Are you sure you want to deactivate: +
+ {userToDeactivate.email} +
+
+ This user will be removed from the active admin list. +
+
+ +
+ + + +
+
+
+ )} + <> From 976e2107895715024bf73df5243a6ea16f12a92b Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 19:46:59 -0400 Subject: [PATCH 13/31] name celery so they don't get mixed up --- src/backend/api/tasks.py | 2 +- src/backend/users/tasks.py | 4 ++-- src/docker-compose.yml | 22 ++++++++++++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) 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/users/tasks.py b/src/backend/users/tasks.py index e7b67e95..039d5929 100644 --- a/src/backend/users/tasks.py +++ b/src/backend/users/tasks.py @@ -3,7 +3,7 @@ from django.core.mail import send_mail -@shared_task +@shared_task(queue="email") def send_invite_email_task(to_email, subject, body): send_mail( subject=subject, @@ -11,4 +11,4 @@ def send_invite_email_task(to_email, subject, body): from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[to_email], fail_silently=False, - ) \ 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: From 830da7d9223e8840b0bc2d12c89faa88e700f4d8 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 19:47:15 -0400 Subject: [PATCH 14/31] update manage_local --- src/backend/manage_local.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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: From d4c6fd74972aec24cb984b0c3fefc488e480e29b Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 21:38:37 -0400 Subject: [PATCH 15/31] passowrd reset --- .../migrations/0005_passwordresettoken.py | 27 ++++++++++++++++ src/backend/users/models.py | 32 ++++++++++++++++++- src/frontend/src/pages/ResetPassword.tsx | 0 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/backend/users/migrations/0005_passwordresettoken.py create mode 100644 src/frontend/src/pages/ResetPassword.tsx 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 3bc92b72..869f4abc 100644 --- a/src/backend/users/models.py +++ b/src/backend/users/models.py @@ -72,4 +72,34 @@ def create_for_user(cls, user, invited_by=None, hours_valid=24): token_hash=token_hash, expires_at=timezone.now() + timedelta(hours=hours_valid), ) - return invite, raw_token \ No newline at end of file + 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/frontend/src/pages/ResetPassword.tsx b/src/frontend/src/pages/ResetPassword.tsx new file mode 100644 index 00000000..e69de29b From 323659fe31f514c4f3e48d75a3bb6e293427a5c8 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 21:38:58 -0400 Subject: [PATCH 16/31] reset page --- src/frontend/src/pages/ResetPassword.tsx | 253 +++++++++++++++++++++++ 1 file changed, 253 insertions(+) diff --git a/src/frontend/src/pages/ResetPassword.tsx b/src/frontend/src/pages/ResetPassword.tsx index e69de29b..b38855ba 100644 --- a/src/frontend/src/pages/ResetPassword.tsx +++ b/src/frontend/src/pages/ResetPassword.tsx @@ -0,0 +1,253 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { apiUrl } from "../config/api"; + +const validatePassword = (password: string) => { + return { + length: password.length >= 8, + upper: /[A-Z]/.test(password), + lower: /[a-z]/.test(password), + number: /\d/.test(password), + special: /[^\w\s]/.test(password), + }; +}; + +const ResetPassword: React.FC = () => { + const [searchParams] = useSearchParams(); + const token = searchParams.get("token") || ""; + + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [loading, setLoading] = useState(false); + const [checkingToken, setCheckingToken] = useState(true); + + const navigate = useNavigate(); + + const passwordRules = validatePassword(password); + + const passwordValid = + passwordRules.length && + passwordRules.upper && + passwordRules.lower && + passwordRules.number && + passwordRules.special; + + const passwordsMatch = password === confirmPassword; + + const getInputBorder = (type: "password" | "confirm") => { + if (type === "confirm" && confirmPassword.length > 0 && !passwordsMatch) { + return "2px solid #ff4d4f"; + } + if (type === "password" && password.length > 0 && !passwordValid) { + return "2px solid #ff4d4f"; + } + return undefined; + }; + + useEffect(() => { + const checkToken = async () => { + if (!token) { + navigate("/login"); + return; + } + + try { + const res = await fetch( + apiUrl(`/validate-reset-password/?token=${encodeURIComponent(token)}`), + { + credentials: "include", + } + ); + const data = await res.json(); + + if (!data.valid) { + navigate("/login"); + return; + } + } catch { + navigate("/login"); + return; + } finally { + setCheckingToken(false); + } + }; + + checkToken(); + }, [token, navigate]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + setError(null); + setSuccess(null); + + if (!token) { + setError("Invalid or missing reset token."); + return; + } + + if (!passwordValid) { + setError("Password does not meet the required security rules."); + return; + } + + if (!passwordsMatch) { + setError("Passwords do not match."); + return; + } + + try { + setLoading(true); + + const result = await fetch(apiUrl("/reset-password/"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password }), + }); + + const data = await result.json(); + + if (!result.ok) { + const message = + data.error || + data.errors?.password?.[0] || + "Failed to reset password."; + + if ( + message.toLowerCase().includes("expired") || + message.toLowerCase().includes("already been used") || + message.toLowerCase().includes("invalid reset") + ) { + setError(message); + setTimeout(() => navigate("/login"), 1800); + return; + } + + throw new Error(message); + } + + setSuccess("Password reset successfully. Redirecting to login..."); + setTimeout(() => navigate("/login"), 1500); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setLoading(false); + } + }; + + if (checkingToken) { + return ( +
+
+
+
+
+

Checking reset link...

+
+
+
+
+ ); + } + + return ( +
+
+ +
+
+

Reset Your Password

+

+ Choose a new password for your DomainX account. +

+
+ +
+
+

+ Reset password +

+ +
+ + + + + {confirmPassword && !passwordsMatch && ( + + Passwords do not match + + )} + +
+ Password must contain: +
    +
  • + At least 8 characters +
  • +
  • + One uppercase letter +
  • +
  • + One lowercase letter +
  • +
  • + One number +
  • +
  • + One special character +
  • +
+
+ + {error &&
{error}
} + {success &&
{success}
} + + +
+
+
+
+
+ ); +}; + +export default ResetPassword; \ No newline at end of file From 2bf7a2f76e37ddeaaffd280b2ba1583d13093187 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 21:39:39 -0400 Subject: [PATCH 17/31] reset password backend --- src/backend/users/serializers.py | 13 +++- src/backend/users/tasks.py | 2 +- src/backend/users/urls.py | 4 + src/backend/users/views/auth_views.py | 104 +++++++++++++++++++++++++- 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/src/backend/users/serializers.py b/src/backend/users/serializers.py index 138211b8..3f68560a 100644 --- a/src/backend/users/serializers.py +++ b/src/backend/users/serializers.py @@ -75,4 +75,15 @@ def get_domains(self, obj): {"domain_ID": str(d.domain_ID), "domain_name": d.domain_name} for d in domains ] - return [] \ No newline at end of file + 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 index 039d5929..c507d7ba 100644 --- a/src/backend/users/tasks.py +++ b/src/backend/users/tasks.py @@ -4,7 +4,7 @@ @shared_task(queue="email") -def send_invite_email_task(to_email, subject, body): +def send_email_task(to_email, subject, body): send_mail( subject=subject, message=body, diff --git a/src/backend/users/urls.py b/src/backend/users/urls.py index a52093e9..c95d2eff 100644 --- a/src/backend/users/urls.py +++ b/src/backend/users/urls.py @@ -26,5 +26,9 @@ 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 9f04ac1c..2fc2ed93 100644 --- a/src/backend/users/views/auth_views.py +++ b/src/backend/users/views/auth_views.py @@ -1,7 +1,6 @@ import hashlib from django.conf import settings from django.contrib.auth import authenticate -from django.core.mail import send_mail from django.db import transaction from django.utils import timezone from rest_framework.views import APIView @@ -9,16 +8,18 @@ from rest_framework import status from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework_simplejwt.tokens import RefreshToken -from ..models import CustomUser, UserInvite +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_invite_email_task +from ..tasks import send_email_task from ..serializers import ( InviteUserSerializer, AcceptInviteSerializer, UserProfileSerializer, UserWithDomainsSerializer, + ForgotPasswordSerializer, + ResetPasswordSerializer, ) @@ -140,7 +141,7 @@ def post(self, request): f"The DomainX Team" ) - send_invite_email_task.delay(user.email, subject, body) + send_email_task.delay(user.email, subject, body) return Response( {"message": "Invitation sent successfully."}, @@ -390,3 +391,98 @@ def patch(self, request, user_id): 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) From 7f27a6169beced62dbe5b518134c534406d62b29 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 21:39:56 -0400 Subject: [PATCH 18/31] remove signup --- src/frontend/src/pages/Login.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/pages/Login.tsx b/src/frontend/src/pages/Login.tsx index acee04cf..fd14baa8 100644 --- a/src/frontend/src/pages/Login.tsx +++ b/src/frontend/src/pages/Login.tsx @@ -79,12 +79,12 @@ const Login: React.FC = () => { {loading ? "Signing in..." : "Sign in"}
- Don’t have an account?{" "} + ForgotPassword?{" "} navigate("/signup")} > - Sign up + Change Password
From 23f619147a9422e56f9fc72362e626665f7daa3d Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 22:10:52 -0400 Subject: [PATCH 19/31] import functions --- src/backend/users/urls.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/backend/users/urls.py b/src/backend/users/urls.py index c95d2eff..466b460e 100644 --- a/src/backend/users/urls.py +++ b/src/backend/users/urls.py @@ -11,7 +11,11 @@ ChangePasswordView, UserDomainListView, ValidateInviteView, - DeactivateUserView + DeactivateUserView, + ForgotPasswordView, + ResetPasswordView, + ValidateResetPasswordView + ) urlpatterns = [ path("login/", LoginView.as_view(), name="login"), From a109fe3b738c315b018fdc48e38837b546dfcb23 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 22:11:13 -0400 Subject: [PATCH 20/31] remove sign up link --- src/frontend/src/pages/Login.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/pages/Login.tsx b/src/frontend/src/pages/Login.tsx index fd14baa8..ac1fd41a 100644 --- a/src/frontend/src/pages/Login.tsx +++ b/src/frontend/src/pages/Login.tsx @@ -82,7 +82,7 @@ const Login: React.FC = () => { ForgotPassword?{" "} navigate("/signup")} + onClick={() => navigate("/")} > Change Password From fea9c37d473fd080cda231c500c214dbaa9558d4 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 22:47:05 -0400 Subject: [PATCH 21/31] updated readme --- src/README.md | 9 ++++++--- src/frontend/src/pages/ForgotPassword.tsx | 0 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 src/frontend/src/pages/ForgotPassword.tsx 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/frontend/src/pages/ForgotPassword.tsx b/src/frontend/src/pages/ForgotPassword.tsx new file mode 100644 index 00000000..e69de29b From 6e60a4f08d214e934984d99d839dd5f5ba2fefab Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sat, 14 Mar 2026 23:27:06 -0400 Subject: [PATCH 22/31] forgot password and reset password --- src/backend/users/tasks.py | 2 +- src/frontend/src/App.tsx | 4 + src/frontend/src/pages/AcceptInvite.tsx | 274 +++++++++++++--------- src/frontend/src/pages/ForgotPassword.tsx | 126 ++++++++++ src/frontend/src/pages/Login.tsx | 17 +- src/frontend/src/pages/ResetPassword.tsx | 193 ++++++++------- 6 files changed, 418 insertions(+), 198 deletions(-) diff --git a/src/backend/users/tasks.py b/src/backend/users/tasks.py index c507d7ba..b20ec135 100644 --- a/src/backend/users/tasks.py +++ b/src/backend/users/tasks.py @@ -11,4 +11,4 @@ def send_email_task(to_email, subject, body): from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[to_email], fail_silently=False, - ) + ) \ No newline at end of file diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 0a3c9629..1b252042 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -19,6 +19,8 @@ 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 ( @@ -42,6 +44,8 @@ const App: React.FC = () => { } /> } /> } /> + } /> + } /> ); diff --git a/src/frontend/src/pages/AcceptInvite.tsx b/src/frontend/src/pages/AcceptInvite.tsx index 3c4f9b86..ac44f4a0 100644 --- a/src/frontend/src/pages/AcceptInvite.tsx +++ b/src/frontend/src/pages/AcceptInvite.tsx @@ -19,6 +19,8 @@ const AcceptInvite: React.FC = () => { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(null); + const [tokenError, setTokenError] = useState(null); + const [tokenUsed, setTokenUsed] = useState(false); const [success, setSuccess] = useState(null); const [loading, setLoading] = useState(false); const [checkingInvite, setCheckingInvite] = useState(true); @@ -47,42 +49,45 @@ const AcceptInvite: React.FC = () => { }; useEffect(() => { - const checkInvite = async () => { - if (!token) { - navigate("/login"); - return; - } + const checkInvite = async () => { + if (!token) { + setTokenError("This invitation link is invalid."); + setCheckingInvite(false); + return; + } - try { - await fetch(apiUrl("/logout/"), { - method: "POST", + try { + await fetch(apiUrl("/logout/"), { + method: "POST", + credentials: "include", + }); + } catch {} + + try { + const res = await fetch( + apiUrl(`/validate-invite/?token=${encodeURIComponent(token)}`), + { credentials: "include", - }); - } catch {} - - try { - const res = await fetch( - apiUrl(`/validate-invite/?token=${encodeURIComponent(token)}`), - { - credentials: "include", - } - ); - const data = await res.json(); - - if (!data.valid) { - navigate("/login"); - return; } - } catch { - navigate("/login"); - return; - } finally { - setCheckingInvite(false); + ); + const data = await res.json(); + + if (!data.valid) { + setTokenError( + "This invitation link is invalid, expired, or has already been used." + ); } - }; + } catch { + setTokenError( + "We could not verify this invitation link. Please contact your administrator." + ); + } finally { + setCheckingInvite(false); + } + }; - checkInvite(); - }, [token, navigate]); + checkInvite(); + }, [token]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -122,13 +127,25 @@ const AcceptInvite: React.FC = () => { data.errors?.password?.[0] || "Failed to activate account."; + const lowerMessage = message.toLowerCase(); + + if ( + lowerMessage.includes("already been used") || + lowerMessage.includes("account already activated") + ) { + setTokenUsed(true); + setTokenError(message); + setError(null); + return; + } + if ( - message.toLowerCase().includes("expired") || - message.toLowerCase().includes("already been used") || - message.toLowerCase().includes("invalid invite") + lowerMessage.includes("expired") || + lowerMessage.includes("invalid invite") ) { - setError(message); - setTimeout(() => navigate("/login"), 1800); + setTokenUsed(false); + setTokenError(message); + setError(null); return; } @@ -177,79 +194,120 @@ const AcceptInvite: React.FC = () => { Activate account -
- - - - - {confirmPassword && !passwordsMatch && ( - - Passwords do not match - - )} - -
- Password must contain: -
    -
  • - At least 8 characters -
  • -
  • - One uppercase letter -
  • -
  • - One lowercase letter -
  • -
  • - One number -
  • -
  • - One special character -
  • -
+ {tokenError ? ( +
+
{tokenError}
+ + {tokenUsed ? ( + + ) : ( + <> + + +
+ navigate("/login")} + > + Contact your administrator for a new invite + +
+ + )}
- - {error &&
{error}
} - {success &&
{success}
} - - - + ) : ( +
+ + + + + {confirmPassword && !passwordsMatch && ( + + Passwords do not match + + )} + +
+ Password must contain: +
    +
  • + At least 8 characters +
  • +
  • + One uppercase letter +
  • +
  • + One lowercase letter +
  • +
  • + One number +
  • +
  • + One special character +
  • +
+
+ + {error &&
{error}
} + {success &&
{success}
} + + +
+ )}
diff --git a/src/frontend/src/pages/ForgotPassword.tsx b/src/frontend/src/pages/ForgotPassword.tsx index e69de29b..16e9eb77 100644 --- a/src/frontend/src/pages/ForgotPassword.tsx +++ b/src/frontend/src/pages/ForgotPassword.tsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { apiUrl } from "../config/api"; + +const ForgotPassword: React.FC = () => { + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [loading, setLoading] = useState(false); + + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + setError(null); + setSuccess(null); + + if (!email.trim()) { + setError("Email is required."); + return; + } + + try { + setLoading(true); + + const result = await fetch(apiUrl("/forgot-password/"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + const data = await result.json(); + + if (!result.ok) { + throw new Error( + data.error || + data.errors?.email?.[0] || + "Failed to send password reset email." + ); + } + + setSuccess( + "If an account with that email exists, a password reset link has been sent." + ); + setEmail(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+
+

Forgot Your Password?

+

+ Enter your email address and we’ll send you a link to reset your + password. +

+
+ +
+
+

+ Reset password +

+ + {!success ? ( +
+ + + {error &&
{error}
} + + + +
+ navigate("/login")} + > + Back to Login + +
+
+ ) : ( +
+
{success}
+ + +
+ )} +
+
+
+
+ ); +}; + +export default ForgotPassword; \ No newline at end of file diff --git a/src/frontend/src/pages/Login.tsx b/src/frontend/src/pages/Login.tsx index ac1fd41a..f5b6a51c 100644 --- a/src/frontend/src/pages/Login.tsx +++ b/src/frontend/src/pages/Login.tsx @@ -78,15 +78,14 @@ const Login: React.FC = () => { -
- ForgotPassword?{" "} - navigate("/")} - > - Change Password - -
+
+ navigate("/forgot-password")} + > + Forgot password? + +
diff --git a/src/frontend/src/pages/ResetPassword.tsx b/src/frontend/src/pages/ResetPassword.tsx index b38855ba..db73311d 100644 --- a/src/frontend/src/pages/ResetPassword.tsx +++ b/src/frontend/src/pages/ResetPassword.tsx @@ -19,6 +19,7 @@ const ResetPassword: React.FC = () => { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(null); + const [tokenError, setTokenError] = useState(null); const [success, setSuccess] = useState(null); const [loading, setLoading] = useState(false); const [checkingToken, setCheckingToken] = useState(true); @@ -49,7 +50,8 @@ const ResetPassword: React.FC = () => { useEffect(() => { const checkToken = async () => { if (!token) { - navigate("/login"); + setTokenError("This password reset link is invalid."); + setCheckingToken(false); return; } @@ -63,19 +65,21 @@ const ResetPassword: React.FC = () => { const data = await res.json(); if (!data.valid) { - navigate("/login"); - return; + setTokenError( + "This password reset link is invalid, expired, or has already been used." + ); } } catch { - navigate("/login"); - return; + setTokenError( + "We could not verify this password reset link. Please request a new one." + ); } finally { setCheckingToken(false); } }; checkToken(); - }, [token, navigate]); + }, [token]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -120,8 +124,8 @@ const ResetPassword: React.FC = () => { message.toLowerCase().includes("already been used") || message.toLowerCase().includes("invalid reset") ) { - setError(message); - setTimeout(() => navigate("/login"), 1800); + setTokenError(message); + setError(null); return; } @@ -170,79 +174,108 @@ const ResetPassword: React.FC = () => { Reset password -
- - - - - {confirmPassword && !passwordsMatch && ( - - Passwords do not match - - )} - -
- Password must contain: -
    -
  • - At least 8 characters -
  • -
  • - One uppercase letter -
  • -
  • - One lowercase letter -
  • -
  • - One number -
  • -
  • - One special character -
  • -
+ {tokenError ? ( +
+
{tokenError}
+ + + +
+ navigate("/login")} + > + Back to Login + +
- - {error &&
{error}
} - {success &&
{success}
} - - - + ) : ( +
+ + + + + {confirmPassword && !passwordsMatch && ( + + Passwords do not match + + )} + +
+ Password must contain: +
    +
  • + At least 8 characters +
  • +
  • + One uppercase letter +
  • +
  • + One lowercase letter +
  • +
  • + One number +
  • +
  • + One special character +
  • +
+
+ + {error &&
{error}
} + {success &&
{success}
} + + +
+ )}
From 1280d495235140b8db1dba8e6c9fe70613907bd2 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sun, 15 Mar 2026 11:24:48 -0400 Subject: [PATCH 23/31] remove home page and add main as default --- src/frontend/src/App.tsx | 4 +-- src/frontend/src/components/DomainInfo.tsx | 27 +++++++++++-------- src/frontend/src/pages/Admin.tsx | 4 +-- src/frontend/src/pages/ComparisonTool.tsx | 2 +- .../src/pages/EditCategoryWeights.tsx | 4 +-- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 1b252042..ac7851bd 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -26,7 +26,7 @@ const App: React.FC = () => { return ( - } /> + } /> } /> } /> } /> @@ -41,7 +41,7 @@ const App: React.FC = () => { } /> } /> } /> - } /> + {/* } />*/} } /> } /> } /> diff --git a/src/frontend/src/components/DomainInfo.tsx b/src/frontend/src/components/DomainInfo.tsx index 23e0df99..389d7908 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:
); }; From 800160284baac9b0ab5f10b15dd2a6fe65a4f82e Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sun, 15 Mar 2026 12:21:40 -0400 Subject: [PATCH 27/31] update tests --- test/backend/users/test_user_models.py | 34 +- test/backend/users/views/test_auth_views.py | 362 +------------------- test/frontend/user_registration.spec.ts | 96 +++++- 3 files changed, 135 insertions(+), 357 deletions(-) diff --git a/test/backend/users/test_user_models.py b/test/backend/users/test_user_models.py index a3e62a40..dbf4ec27 100644 --- a/test/backend/users/test_user_models.py +++ b/test/backend/users/test_user_models.py @@ -40,14 +40,14 @@ def test_get_full_name_concatenates_first_and_last(): @pytest.mark.django_db -def test_role_default_is_user(): +def test_role_default_is_admin(): User = get_user_model() user = User.objects.create_user( username="dave", email="dave@example.com", password="pass1234", ) - assert user.role == "user" + assert user.role == "admin" @pytest.mark.django_db @@ -60,7 +60,31 @@ def test_email_unique_constraint(): ) with pytest.raises(IntegrityError): User.objects.create_user( - username="eve2", - email="eve@example.com", - password="pass1234", + username="eve2", + email="eve@example.com", + password="pass1234", ) + + +@pytest.mark.django_db +def test_email_and_username_are_lowercased_on_save(): + User = get_user_model() + user = User.objects.create_user( + username="AliceUser", + email="Alice@Example.com", + password="pass1234", + ) + + assert user.email == "alice@example.com" + assert user.username == "aliceuser" + + +@pytest.mark.django_db +def test_is_deleted_defaults_to_false(): + User = get_user_model() + user = User.objects.create_user( + username="frank", + email="frank@example.com", + password="pass1234", + ) + assert user.is_deleted is False \ No newline at end of file diff --git a/test/backend/users/views/test_auth_views.py b/test/backend/users/views/test_auth_views.py index 610e359c..b20ec135 100644 --- a/test/backend/users/views/test_auth_views.py +++ b/test/backend/users/views/test_auth_views.py @@ -1,348 +1,14 @@ -import pytest -from unittest.mock import MagicMock, patch, Mock -from rest_framework import status -from rest_framework.test import APIClient -from users.models import CustomUser -from api.database.domain.models import Domain - - -@pytest.fixture -def api_client(): - return APIClient() - - -@pytest.fixture -def mock_user(): - user = MagicMock(spec=CustomUser) - user.id = 1 - user.email = 'testuser@example.com' - user.username = 'testuser' - user.role = 'user' - user.first_name = 'Test' - user.last_name = 'User' - user.is_authenticated = True - return user - - -@pytest.fixture -def mock_superadmin(): - user = MagicMock(spec=CustomUser) - user.id = 1 - user.email = 'superadmin@example.com' - user.username = 'superadmin' - user.role = 'superadmin' - user.is_authenticated = True - return user - - -@pytest.fixture -def mock_admin(): - user = MagicMock(spec=CustomUser) - user.id = 2 - user.email = 'admin@example.com' - user.username = 'admin' - user.role = 'admin' - user.first_name = 'Admin' - user.last_name = 'User' - user.is_authenticated = True - user.created_domains = MagicMock() - user.created_domains.all.return_value = [] - user.created_domains.count.return_value = 0 - user.created_domains.add = MagicMock() - user.created_domains.clear = MagicMock() - return user - - -@pytest.fixture -def mock_domain1(): - domain = MagicMock(spec=Domain) - domain.domain_ID = 'domain-1' - domain.domain_name = 'Domain 1' - domain.description = 'First Domain' - return domain - - -@pytest.fixture -def mock_domain2(): - domain = MagicMock(spec=Domain) - domain.domain_ID = 'domain-2' - domain.domain_name = 'Domain 2' - domain.description = 'Second Domain' - return domain - - -class TestSignupView: - @patch('users.views.auth_views.SignupSerializer') - @patch('users.views.auth_views.UserProfileSerializer') - def test_signup_success(self, mock_profile_serializer, mock_serializer, api_client): - """Test successful user registration""" - mock_user = MagicMock(spec=CustomUser) - mock_user.email = 'testuser@example.com' - mock_user.role = 'user' - - mock_serializer_instance = MagicMock() - mock_serializer_instance.is_valid.return_value = True - mock_serializer_instance.save.return_value = mock_user - mock_serializer.return_value = mock_serializer_instance - - mock_profile_serializer.return_value.data = { - 'email': 'testuser@example.com', - 'role': 'user' - } - - payload = { - 'email': 'testuser@example.com', - 'username': 'testuser', - 'password': 'SecurePass123!', - 'password2': 'SecurePass123!', - 'first_name': 'Test', - 'last_name': 'User' - } - - response = api_client.post('/api/signup/', payload, format='json') - # Note: response code depends on your URL configuration - assert response.status_code in [status.HTTP_201_CREATED, status.HTTP_404_NOT_FOUND] - - @patch('users.views.auth_views.SignupSerializer') - def test_signup_invalid(self, mock_serializer, api_client): - """Test signup with invalid data""" - mock_serializer_instance = MagicMock() - mock_serializer_instance.is_valid.return_value = False - mock_serializer_instance.errors = {'email': ['Invalid email']} - mock_serializer.return_value = mock_serializer_instance - - payload = { - 'email': 'invalid-email', - 'username': 'testuser', - 'password': 'Pass123!', - 'password2': 'Pass123!' - } - response = api_client.post('/api/signup/', payload, format='json') - - assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_404_NOT_FOUND] - - -class TestLoginView: - @patch('users.views.auth_views.RefreshToken') - @patch('users.views.auth_views.authenticate') - @patch('users.views.auth_views.UserProfileSerializer') - def test_login_success(self, mock_profile_serializer, mock_authenticate, mock_refresh_token, api_client): - """Test successful login""" - mock_user = MagicMock(spec=CustomUser) - mock_user.email = 'testuser@example.com' - mock_authenticate.return_value = mock_user - - mock_token = MagicMock() - mock_token.access_token = 'access_token_value' - mock_refresh_token.for_user.return_value = mock_token - - mock_profile_serializer.return_value.data = {'email': 'testuser@example.com'} - - payload = { - 'login': 'testuser@example.com', - 'password': 'TestPass123!' - } - - response = api_client.post('/api/login/', payload, format='json') - - assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] - if response.status_code == status.HTTP_200_OK: - mock_authenticate.assert_called_once() - - @patch('users.views.auth_views.authenticate') - def test_login_invalid_credentials(self, mock_authenticate, api_client): - """Test login with invalid credentials""" - mock_authenticate.return_value = None - - payload = { - 'login': 'testuser@example.com', - 'password': 'WrongPassword' - } - response = api_client.post('/api/login/', payload, format='json') - - assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND] - - -class TestMeView: - @patch('users.views.auth_views.UserProfileSerializer') - def test_me_authenticated(self, mock_profile_serializer, api_client, mock_user): - """Test /me endpoint when authenticated""" - api_client.force_authenticate(user=mock_user) - - mock_profile_serializer.return_value.data = { - 'email': 'testuser@example.com', - 'role': 'user' - } - response = api_client.get('/api/me/') - - assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] - - def test_me_unauthenticated(self, api_client): - """Test /me endpoint without authentication""" - response = api_client.get('/api/me/') - assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND] - - -class TestLogoutView: - def test_logout_success(self, api_client, mock_user): - """Test successful logout""" - api_client.force_authenticate(user=mock_user) - response = api_client.post('/api/logout/') - - assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] - - def test_logout_unauthenticated(self, api_client): - """Test logout without authentication""" - response = api_client.post('/api/logout/') - assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] - - -class TestUserListView: - @patch('users.models.CustomUser') - @patch('users.views.auth_views.UserProfileSerializer') - def test_list_users_superadmin(self, mock_profile_serializer, mock_custom_user, api_client, mock_superadmin): - """Test superadmin can list all users""" - api_client.force_authenticate(user=mock_superadmin) - - mock_custom_user.objects.all.return_value = [] - mock_profile_serializer.return_value.data = [] - - response = api_client.get('/api/users/') - - # Test permission check works, endpoint may not exist - assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] - - def test_list_users_admin_forbidden(self, api_client, mock_admin): - """Test admin cannot list users""" - mock_admin.role = 'admin' - api_client.force_authenticate(user=mock_admin) - response = api_client.get('/api/users/') - - # Should get 403 or 404 (if endpoint doesn't exist) - assert response.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND] - - def test_list_users_unauthenticated(self, api_client): - """Test unauthenticated user cannot list users""" - response = api_client.get('/api/users/') - assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND] - -@pytest.mark.django_db -class TestUserUpdateView: - @patch('users.models.CustomUser') - @patch('users.views.auth_views.UserWithDomainsSerializer') - def test_update_user_basic_fields(self, mock_serializer, mock_custom_user, api_client, mock_superadmin, mock_admin): - """Test updating user basic fields""" - api_client.force_authenticate(user=mock_superadmin) - mock_admin.role = 'admin' - - mock_custom_user.objects.get.return_value = mock_admin - mock_custom_user.objects.prefetch_related.return_value.get.return_value = mock_admin - mock_serializer.return_value.data = {'first_name': 'Updated'} - - payload = { - 'first_name': 'Updated', - 'last_name': 'Name' - } - - response = api_client.patch(f'/api/users/{mock_admin.id}/', payload, format='json') - - assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] - - def test_admin_can_update_user_but_not_role(self, api_client, mock_admin): - """Admin can access the endpoint but role change is ignored""" - target_user = MagicMock(spec=CustomUser) - target_user.id = 99 - target_user.role = 'user' - - with patch('users.models.CustomUser.objects.get') as mock_get: - mock_get.return_value = target_user - api_client.force_authenticate(user=mock_admin) - - payload = { - 'first_name': 'AdminChange', - 'role': 'superadmin' - } - response = api_client.patch(f'/api/users/{target_user.id}/', payload) - - assert response.status_code == 200 - assert target_user.role == 'user' - - def test_user_can_update_own_profile(self, api_client, mock_user): - """Standard users can edit their own names""" - with patch('users.models.CustomUser.objects.get') as mock_get: - mock_get.return_value = mock_user - api_client.force_authenticate(user=mock_user) - - payload = {'first_name': 'NewName'} - response = api_client.patch(f'/api/users/{mock_user.id}/', payload) - - assert response.status_code == 200 - assert mock_user.first_name == 'NewName' - - @patch('users.models.CustomUser.objects.prefetch_related') - def test_admin_cannot_edit_other_user_basic_info(self, api_client, mock_admin, mock_user): - """Admin tries to change a different user's name (Should fail based on your rules)""" - api_client.force_authenticate(user=mock_admin) - mock_user.id = 5 - - payload = {'first_name': 'AdminChangingThis'} - response = api_client.patch(f'/api/users/5/', payload) - - assert mock_user.first_name != 'AdminChangingThis' - - - def test_update_user_unauthenticated(self, api_client, mock_admin): - """Test unauthenticated user cannot update""" - payload = {'first_name': 'Test'} - response = api_client.patch(f'/api/users/{mock_admin.id}/', payload, format='json') - - assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND] - -@pytest.mark.django_db -class TestChangePasswordView: - - def test_user_cannot_change_others_password(self, api_client, mock_user): - """Test that a regular user gets 403 when targeting another user's ID""" - api_client.force_authenticate(user=mock_user) # ID is 1 - - payload = { - "old_password": "any", - "new_password": "NewPassword123!" - } - response = api_client.post('/api/users/99/change-password/', payload) - - assert response.status_code == status.HTTP_403_FORBIDDEN - - @patch('users.models.CustomUser.objects.get') - def test_superadmin_can_change_others_password(self, mock_get, api_client, mock_superadmin): - """Test that Superadmin can bypass ownership and old_password checks""" - api_client.force_authenticate(user=mock_superadmin) - - target_user = MagicMock(spec=CustomUser) - target_user.id = 50 - mock_get.return_value = target_user - - payload = { - "new_password": "SuperSetPassword123!" - } - response = api_client.post('/api/users/50/change-password/', payload) - - assert response.status_code == status.HTTP_200_OK - target_user.set_password.assert_called_with("SuperSetPassword123!") - - @patch('users.models.CustomUser.objects.get') - def test_owner_can_change_own_password(self, mock_get, api_client, mock_user): - """Test that owner can change password if old password is correct""" - api_client.force_authenticate(user=mock_user) - mock_user.id = 1 - mock_get.return_value = mock_user - mock_user.check_password.return_value = True - - payload = { - "old_password": "OldPassword123!", - "new_password": "NewPassword123!" - } - response = api_client.post('/api/users/1/change-password/', payload) - - assert response.status_code == status.HTTP_200_OK - mock_user.set_password.assert_called_with("NewPassword123!") \ No newline at end of file +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/test/frontend/user_registration.spec.ts b/test/frontend/user_registration.spec.ts index e07b1eb7..50e90b87 100644 --- a/test/frontend/user_registration.spec.ts +++ b/test/frontend/user_registration.spec.ts @@ -23,13 +23,102 @@ test('User can register', async ({ page }) => { contentType: 'application/json', body: JSON.stringify({ token: 'fake-jwt-token', - user: { id: 1, username: 'test123', email: 'test@gmail.com' }, + user: { id: 1, username: 'test123', email: 'test@gmail.com', role: 'user', first_name: '', last_name: '' }, + }), + }); + }); + + await page.route('http://localhost:3000/api/me/', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + user: { id: 1, username: 'test123', email: 'test@gmail.com', role: 'user', first_name: '', last_name: '' }, + }), + }); + }); + + await page.route('http://localhost:3000/api/domain/', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + domain_ID: '1', + domain_name: 'Test Domain', + description: 'Test domain description', + creators: [], + }, + ]), + }); + }); + + await page.route('http://localhost:3000/api/metrics/categories/', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + Categories: ['Quality', 'Popularity'], + }), + }); + }); + + await page.route('http://localhost:3000/api/users/?role=admin,superadmin', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + }); + + await page.route('http://localhost:3000/api/library_metric_values/ahp/1/', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + global_ranking: { + LibraryA: 0.82, + LibraryB: 0.64, + }, + category_details: { + Quality: { + LibraryA: 0.9, + LibraryB: 0.7, + }, + Popularity: { + LibraryA: 0.74, + LibraryB: 0.58, + }, + }, + }), + }); + }); + + await page.route('http://localhost:3000/api/domain/1/category-weights/', async route => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + Quality: 0.5, + Popularity: 0.5, + }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + message: 'Weights updated successfully', }), }); }); + await page.goto("http://localhost:3000/login"); await page.getByText('Sign up').click(); - + await page.getByRole('textbox', { name: 'Email' }).click(); await page.getByRole('textbox', { name: 'Email' }).fill('test@gmail.com'); await page.getByRole('textbox', { name: 'Email' }).press('Tab'); @@ -39,12 +128,11 @@ test('User can register', async ({ page }) => { await page.getByRole('textbox', { name: 'Password' }).fill('test123'); await page.getByRole('button', { name: 'Sign up' }).click(); - await page.getByRole('textbox', { name: 'Username or Email' }).click(); await page.getByRole('textbox', { name: 'Username or Email' }).fill('test@gmail.com'); await page.getByRole('textbox', { name: 'Username or Email' }).press('Tab'); await page.getByRole('textbox', { name: 'Password' }).fill('test123'); await page.getByRole('button', { name: 'Sign in' }).click(); - await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'GRAPH VIEW' })).toBeVisible(); }); \ No newline at end of file From aeb62683dbb33cf5cc198374c84b7040fe2c7c60 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sun, 15 Mar 2026 13:10:36 -0400 Subject: [PATCH 28/31] remove signup --- test/frontend/user_registration.spec.ts | 45 ++++--------------------- 1 file changed, 6 insertions(+), 39 deletions(-) diff --git a/test/frontend/user_registration.spec.ts b/test/frontend/user_registration.spec.ts index 50e90b87..29c34f10 100644 --- a/test/frontend/user_registration.spec.ts +++ b/test/frontend/user_registration.spec.ts @@ -1,39 +1,18 @@ import { test, expect } from '@playwright/test'; -test('User can register', async ({ page }) => { - // See all the requested URLs for mocking backend +test('User can log in', async ({ page }) => { + // See all the requested URLs for mocking backend // page.on('request', request => { // console.log('Request URL:', request.url()); // }); - await page.route('http://localhost:3000/api/signup/', async route => { - await route.fulfill({ - status: 201, - contentType: 'application/json', - body: JSON.stringify({ - user: { id: 1, username: 'test123', email: 'test@gmail.com' }, - message: 'User registered successfully', - }), - }); - }); - await page.route('http://localhost:3000/api/login/', async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ token: 'fake-jwt-token', - user: { id: 1, username: 'test123', email: 'test@gmail.com', role: 'user', first_name: '', last_name: '' }, - }), - }); - }); - - await page.route('http://localhost:3000/api/me/', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - user: { id: 1, username: 'test123', email: 'test@gmail.com', role: 'user', first_name: '', last_name: '' }, + user: { id: 1, username: 'test123', email: 'test@gmail.com' }, }), }); }); @@ -116,22 +95,10 @@ test('User can register', async ({ page }) => { }); }); - await page.goto("http://localhost:3000/login"); - await page.getByText('Sign up').click(); - - await page.getByRole('textbox', { name: 'Email' }).click(); - await page.getByRole('textbox', { name: 'Email' }).fill('test@gmail.com'); - await page.getByRole('textbox', { name: 'Email' }).press('Tab'); - - await page.getByRole('textbox', { name: 'Username' }).fill('test123'); - await page.getByRole('textbox', { name: 'Username' }).press('Tab'); - await page.getByRole('textbox', { name: 'Password' }).fill('test123'); - await page.getByRole('button', { name: 'Sign up' }).click(); + await page.goto('http://localhost:3000/login'); - await page.getByRole('textbox', { name: 'Username or Email' }).click(); - await page.getByRole('textbox', { name: 'Username or Email' }).fill('test@gmail.com'); - await page.getByRole('textbox', { name: 'Username or Email' }).press('Tab'); - await page.getByRole('textbox', { name: 'Password' }).fill('test123'); + await page.getByLabel('Username or Email').fill('test@gmail.com'); + await page.getByLabel('Password').fill('test123'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByRole('button', { name: 'GRAPH VIEW' })).toBeVisible(); From f2be22551459b85a356acb41e5f5069f2dd2edb8 Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sun, 15 Mar 2026 16:01:05 -0400 Subject: [PATCH 29/31] resend invite --- src/backend/users/urls.py | 4 +- src/backend/users/views/auth_views.py | 116 +++--- test/backend/users/views/test_auth_views.py | 377 +++++++++++++++++++- 3 files changed, 439 insertions(+), 58 deletions(-) diff --git a/src/backend/users/urls.py b/src/backend/users/urls.py index 466b460e..824230e8 100644 --- a/src/backend/users/urls.py +++ b/src/backend/users/urls.py @@ -14,7 +14,8 @@ DeactivateUserView, ForgotPasswordView, ResetPasswordView, - ValidateResetPasswordView + ValidateResetPasswordView, + ResendInviteView ) urlpatterns = [ @@ -22,6 +23,7 @@ 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"), diff --git a/src/backend/users/views/auth_views.py b/src/backend/users/views/auth_views.py index 2fc2ed93..c2b4f127 100644 --- a/src/backend/users/views/auth_views.py +++ b/src/backend/users/views/auth_views.py @@ -23,6 +23,30 @@ ) +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, + ) + + 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) + + class LoginView(APIView): permission_classes = [AllowAny] @@ -34,7 +58,6 @@ def post(self, request): if not user: return Response({"error": "Invalid credentials"}, status=status.HTTP_401_UNAUTHORIZED) - refresh = RefreshToken.for_user(user) response = Response({"user": UserProfileSerializer(user).data}, status=status.HTTP_200_OK) @@ -121,27 +144,7 @@ def post(self, request): else: user.created_domains.clear() - invite, raw_token = UserInvite.create_for_user( - user=user, - invited_by=request.user, - hours_valid=24 * 7, - ) - - 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) + send_invitation_for_user(user=user, invited_by=request.user) return Response( {"message": "Invitation sent successfully."}, @@ -149,6 +152,29 @@ def post(self, request): ) +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] @@ -172,10 +198,10 @@ def post(self, request): 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."}, + {"error": "This invite link has expired. Please contact your administrator to request a new invitation."}, status=status.HTTP_400_BAD_REQUEST ) @@ -192,7 +218,6 @@ def post(self, request): invite.used_at = timezone.now() invite.save(update_fields=["used_at"]) - return Response({"message": "Account activated successfully."}, status=status.HTTP_200_OK) @@ -200,23 +225,23 @@ class UserListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): - 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 ) - role = request.query_params.get('role', None) - include_domains = request.query_params.get('include_domains', 'false').lower() == 'true' + 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) @@ -227,8 +252,8 @@ class UserUpdateView(APIView): def patch(self, request, user_id): 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" if not is_self and not (is_admin or is_super): return Response({"error": "Forbidden"}, status=403) @@ -239,23 +264,23 @@ def patch(self, request, user_id): return Response({"error": "User not found."}, status=404) 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) if is_super: - role = request.data.get('role') - if role in ['admin', 'superadmin']: + role = request.data.get("role") + if role in ["admin", "superadmin"]: user_to_update.role = role - 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: - 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 @@ -267,11 +292,13 @@ def patch(self, request, user_id): user_to_update.created_domains.add(domain) 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) @@ -281,7 +308,7 @@ class ChangePasswordView(APIView): def post(self, request, user_id): 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( @@ -320,14 +347,15 @@ class UserDomainListView(APIView): def get(self, request, user_id): try: user = CustomUser.objects.get(id=user_id) - 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] @@ -352,11 +380,11 @@ def get(self, request): 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."}, @@ -392,6 +420,7 @@ def patch(self, request, user_id): return Response({"message": "User deactivated successfully."}, status=200) + class ForgotPasswordView(APIView): permission_classes = [AllowAny] @@ -466,6 +495,7 @@ def post(self, request): return Response({"message": "Password reset successfully."}, status=status.HTTP_200_OK) + class ValidateResetPasswordView(APIView): permission_classes = [AllowAny] @@ -485,4 +515,4 @@ def get(self, request): if reset.is_used() or reset.is_expired(): return Response({"valid": False}, status=200) - return Response({"valid": True}, status=200) + return Response({"valid": True}, status=200) \ No newline at end of file diff --git a/test/backend/users/views/test_auth_views.py b/test/backend/users/views/test_auth_views.py index b20ec135..7fa07025 100644 --- a/test/backend/users/views/test_auth_views.py +++ b/test/backend/users/views/test_auth_views.py @@ -1,14 +1,363 @@ -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 +import pytest +from unittest.mock import MagicMock, patch +from rest_framework import status +from rest_framework.test import APIClient +from users.models import CustomUser +from api.database.domain.models import Domain + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def mock_user(): + user = MagicMock(spec=CustomUser) + user.id = 1 + user.email = "testuser@example.com" + user.username = "testuser" + user.role = "user" + user.first_name = "Test" + user.last_name = "User" + user.is_authenticated = True + user.is_active = True + user.is_deleted = False + return user + + +@pytest.fixture +def mock_superadmin(): + user = MagicMock(spec=CustomUser) + user.id = 10 + user.email = "superadmin@example.com" + user.username = "superadmin" + user.role = "superadmin" + user.is_authenticated = True + user.is_active = True + user.is_deleted = False + user.created_domains = MagicMock() + user.created_domains.values_list.return_value = [] + return user + + +@pytest.fixture +def mock_admin(): + user = MagicMock(spec=CustomUser) + user.id = 2 + user.email = "admin@example.com" + user.username = "admin" + user.role = "admin" + user.first_name = "Admin" + user.last_name = "User" + user.is_authenticated = True + user.is_active = True + user.is_deleted = False + user.created_domains = MagicMock() + user.created_domains.all.return_value = [] + user.created_domains.count.return_value = 0 + user.created_domains.add = MagicMock() + user.created_domains.clear = MagicMock() + user.created_domains.values_list.return_value = [] + return user + + +@pytest.fixture +def mock_domain1(): + domain = MagicMock(spec=Domain) + domain.domain_ID = "domain-1" + domain.domain_name = "Domain 1" + domain.description = "First Domain" + return domain + + +@pytest.fixture +def mock_domain2(): + domain = MagicMock(spec=Domain) + domain.domain_ID = "domain-2" + domain.domain_name = "Domain 2" + domain.description = "Second Domain" + return domain + + +class TestLoginView: + @patch("users.views.auth_views.RefreshToken") + @patch("users.views.auth_views.authenticate") + @patch("users.views.auth_views.UserProfileSerializer") + def test_login_success(self, mock_profile_serializer, mock_authenticate, mock_refresh_token, api_client): + mock_user = MagicMock(spec=CustomUser) + mock_user.email = "testuser@example.com" + mock_user.is_active = True + mock_authenticate.return_value = mock_user + + mock_token = MagicMock() + mock_token.access_token = "access_token_value" + mock_refresh_token.for_user.return_value = mock_token + + mock_profile_serializer.return_value.data = {"email": "testuser@example.com"} + + payload = { + "login": "testuser@example.com", + "password": "TestPass123!", + } + + response = api_client.post("/api/login/", payload, format="json") + + assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] + if response.status_code == status.HTTP_200_OK: + mock_authenticate.assert_called_once() + + @patch("users.views.auth_views.authenticate") + def test_login_invalid_credentials(self, mock_authenticate, api_client): + mock_authenticate.return_value = None + + payload = { + "login": "testuser@example.com", + "password": "WrongPassword", + } + response = api_client.post("/api/login/", payload, format="json") + + assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND] + + +class TestMeView: + @patch("users.views.auth_views.UserProfileSerializer") + def test_me_authenticated(self, mock_profile_serializer, api_client, mock_user): + api_client.force_authenticate(user=mock_user) + + mock_profile_serializer.return_value.data = { + "email": "testuser@example.com", + "role": "user", + "is_active": True, + } + response = api_client.get("/api/me/") + + assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] + + def test_me_unauthenticated(self, api_client): + response = api_client.get("/api/me/") + assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND] + + +class TestLogoutView: + def test_logout_success(self, api_client, mock_user): + api_client.force_authenticate(user=mock_user) + response = api_client.post("/api/logout/") + + assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] + + def test_logout_unauthenticated(self, api_client): + response = api_client.post("/api/logout/") + assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND] + + +class TestUserListView: + @patch("users.views.auth_views.UserProfileSerializer") + @patch("users.views.auth_views.CustomUser") + def test_list_users_superadmin(self, mock_custom_user, mock_profile_serializer, api_client, mock_superadmin): + api_client.force_authenticate(user=mock_superadmin) + + mock_qs = MagicMock() + mock_qs.prefetch_related.return_value = mock_qs + mock_custom_user.objects.filter.return_value = mock_qs + mock_profile_serializer.return_value.data = [] + + response = api_client.get("/api/users/") + + assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] + + def test_list_users_admin_forbidden(self, api_client, mock_admin): + api_client.force_authenticate(user=mock_admin) + response = api_client.get("/api/users/") + + assert response.status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND] + + def test_list_users_unauthenticated(self, api_client): + response = api_client.get("/api/users/") + assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND] + + +@pytest.mark.django_db +class TestUserUpdateView: + @patch("users.views.auth_views.CustomUser.objects.get") + def test_update_user_basic_fields(self, mock_get, api_client, mock_superadmin, mock_admin): + api_client.force_authenticate(user=mock_superadmin) + mock_get.return_value = mock_admin + + payload = { + "first_name": "Updated", + "last_name": "Name", + } + + response = api_client.patch(f"/api/users/{mock_admin.id}/", payload, format="json") + + assert response.status_code in [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND] + + @patch("users.views.auth_views.CustomUser.objects.get") + def test_admin_can_update_user_but_not_role(self, mock_get, api_client, mock_admin): + target_user = MagicMock(spec=CustomUser) + target_user.id = 99 + target_user.role = "user" + target_user.email = "target@example.com" + target_user.username = "targetuser" + mock_get.return_value = target_user + + api_client.force_authenticate(user=mock_admin) + + payload = { + "first_name": "AdminChange", + "role": "superadmin", + } + response = api_client.patch(f"/api/users/{target_user.id}/", payload, format="json") + + assert response.status_code == 200 + assert target_user.role == "user" + + @patch("users.views.auth_views.CustomUser.objects.get") + def test_user_can_update_own_profile(self, mock_get, api_client, mock_user): + mock_get.return_value = mock_user + api_client.force_authenticate(user=mock_user) + + payload = {"first_name": "NewName"} + response = api_client.patch(f"/api/users/{mock_user.id}/", payload, format="json") + + assert response.status_code == 200 + assert mock_user.first_name == "NewName" + + @patch("users.views.auth_views.CustomUser.objects.get") + def test_admin_cannot_edit_other_user_basic_info(self, mock_get, api_client, mock_admin): + target_user = MagicMock(spec=CustomUser) + target_user.id = 5 + target_user.first_name = "Original" + target_user.email = "user5@example.com" + target_user.username = "user5" + target_user.role = "user" + mock_get.return_value = target_user + + api_client.force_authenticate(user=mock_admin) + + payload = {"first_name": "AdminChangingThis"} + response = api_client.patch("/api/users/5/", payload, format="json") + + assert response.status_code == 200 + assert target_user.first_name == "Original" + + def test_update_user_unauthenticated(self, api_client, mock_admin): + payload = {"first_name": "Test"} + response = api_client.patch(f"/api/users/{mock_admin.id}/", payload, format="json") + + assert response.status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_404_NOT_FOUND] + + +@pytest.mark.django_db +class TestChangePasswordView: + def test_user_cannot_change_others_password(self, api_client, mock_user): + api_client.force_authenticate(user=mock_user) + + payload = { + "old_password": "any", + "new_password": "NewPassword123!", + } + response = api_client.post("/api/users/99/change-password/", payload, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("users.views.auth_views.validate_password") + @patch("users.views.auth_views.CustomUser.objects.get") + def test_superadmin_can_change_others_password(self, mock_get, mock_validate_password, api_client, mock_superadmin): + api_client.force_authenticate(user=mock_superadmin) + + target_user = MagicMock(spec=CustomUser) + target_user.id = 50 + mock_get.return_value = target_user + + payload = { + "new_password": "SuperSetPassword123!", + } + response = api_client.post("/api/users/50/change-password/", payload, format="json") + + assert response.status_code == status.HTTP_200_OK + target_user.set_password.assert_called_with("SuperSetPassword123!") + mock_validate_password.assert_called_once() + + @patch("users.views.auth_views.validate_password") + @patch("users.views.auth_views.CustomUser.objects.get") + def test_owner_can_change_own_password(self, mock_get, mock_validate_password, api_client, mock_user): + api_client.force_authenticate(user=mock_user) + mock_user.id = 1 + mock_get.return_value = mock_user + mock_user.check_password.return_value = True + + payload = { + "old_password": "OldPassword123!", + "new_password": "NewPassword123!", + } + response = api_client.post("/api/users/1/change-password/", payload, format="json") + + assert response.status_code == status.HTTP_200_OK + mock_user.set_password.assert_called_with("NewPassword123!") + mock_validate_password.assert_called_once() + + +@pytest.mark.django_db +class TestResendInviteView: + @patch("users.views.auth_views.send_invitation_for_user") + @patch("users.views.auth_views.CustomUser.objects.get") + def test_superadmin_can_resend_invite( + self, mock_get, mock_send_invitation, api_client, mock_superadmin + ): + pending_user = MagicMock(spec=CustomUser) + pending_user.id = 25 + pending_user.email = "pending@example.com" + pending_user.is_active = False + pending_user.is_deleted = False + + mock_get.return_value = pending_user + api_client.force_authenticate(user=mock_superadmin) + + response = api_client.post("/api/users/25/resend-invite/") + + assert response.status_code == status.HTTP_200_OK + mock_send_invitation.assert_called_once_with( + user=pending_user, + invited_by=mock_superadmin, + ) + + @patch("users.views.auth_views.CustomUser.objects.get") + def test_resend_invite_forbidden_for_non_superadmin( + self, mock_get, api_client, mock_admin + ): + api_client.force_authenticate(user=mock_admin) + + response = api_client.post("/api/users/25/resend-invite/") + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_get.assert_not_called() + + @patch("users.views.auth_views.CustomUser.objects.get") + def test_resend_invite_fails_for_active_user( + self, mock_get, api_client, mock_superadmin + ): + active_user = MagicMock(spec=CustomUser) + active_user.id = 30 + active_user.is_active = True + active_user.is_deleted = False + + mock_get.return_value = active_user + api_client.force_authenticate(user=mock_superadmin) + + response = api_client.post("/api/users/30/resend-invite/") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["error"] == "User is already active." + + @patch("users.views.auth_views.CustomUser.objects.get") + def test_resend_invite_user_not_found( + self, mock_get, api_client, mock_superadmin + ): + mock_get.side_effect = CustomUser.DoesNotExist + api_client.force_authenticate(user=mock_superadmin) + + response = api_client.post("/api/users/999/resend-invite/") + + assert response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file From d90fb2be63d9168b7a4ea2e208348f504616bb5e Mon Sep 17 00:00:00 2001 From: hamidizf Date: Sun, 15 Mar 2026 16:08:59 -0400 Subject: [PATCH 30/31] UI --- src/frontend/src/pages/Admin.tsx | 55 +++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/pages/Admin.tsx b/src/frontend/src/pages/Admin.tsx index c335b7b0..fdf75047 100644 --- a/src/frontend/src/pages/Admin.tsx +++ b/src/frontend/src/pages/Admin.tsx @@ -64,6 +64,8 @@ const AdminPage: React.FC = () => { const [inviteError, setInviteError] = useState(null); const [showInviteSuccess, setShowInviteSuccess] = useState(false); + const [resendLoadingUserId, setResendLoadingUserId] = useState(null); + useEffect(() => { if (showUpdateSuccess) { const timer = setTimeout(() => setShowUpdateSuccess(false), 3000); @@ -253,6 +255,31 @@ const AdminPage: React.FC = () => { } }; + const handleResendInvite = async (u: User) => { + try { + setResendLoadingUserId(u.id); + setError(null); + + const response = await fetch(apiUrl(`/users/${u.id}/resend-invite/`), { + method: "POST", + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to resend invite"); + } + + setShowInviteSuccess(true); + await fetchUsers(); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setResendLoadingUserId(null); + } + }; + const handleDeactivateUser = async () => { if (!userToDeactivate) return; @@ -424,17 +451,15 @@ const AdminPage: React.FC = () => { ) : ( users.map((u) => { - const canDeactivate = - !!user && - u.is_active && - u.id !== user.id; + const canDeactivate = !!user && u.is_active && u.id !== user.id; + const canResendInvite = !u.is_active; return ( - +
{ Edit + {canResendInvite && ( + + )} + {canDeactivate && ( - {!user && ( - - )} + )} - {!sidebarOpen && !user && ( - - )} +
); };