Gate self-serve registration behind ENABLE_ACCOUNT_REGISTRATION (#25)
Unit Tests / test (push) Successful in 9s

## Summary
- Add `ENABLE_ACCOUNT_REGISTRATION` (default `false`) for self-serve sign-up, set via env / control-node secret (`chat_backend_<env>.env`)
- Harden `POST /api/user/create/` to create company + user, return JWTs, and reject when the flag is off
- Expose `GET /api/public/settings/` with `enable_account_registration` for the SPA

Supports frontend: [chat_web_app#31](ai_ml_operations/chat_web_app#31)

## Test plan
- [ ] `cd llm_be && uv run python manage.py test chat_backend.tests.test_views_users`
- [ ] Confirm `POST /api/user/create/` returns 403 when flag unset/false
- [ ] With `ENABLE_ACCOUNT_REGISTRATION=true`, register returns access/refresh + creates company
- [ ] `GET /api/public/settings/` returns the flag
- [ ] Add `ENABLE_ACCOUNT_REGISTRATION=false` to control-node `chat_backend_prod.env` / `chat_backend_beta.env` before deploy (see `.env.prod.example`)Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
2026-07-27 04:19:59 -07:00
parent ad44359804
commit 30ce3d048d
8 changed files with 160 additions and 15 deletions
+49 -7
View File
@@ -54,13 +54,55 @@ class CustomUserSerializer(serializers.ModelSerializer):
fields = "__all__"
extra_kwargs = {"password": {"write_only": True}}
# def create(self, validated_data):
# password = validated_data.pop('password',None)
# instance = self.Meta.model(**validated_data)
# if password is not None:
# instance.set_password(password)
# instance.save()
# return instance
class SelfServeRegistrationSerializer(serializers.Serializer):
"""Minimal payload for public self-serve sign-up (gated by settings)."""
email = serializers.EmailField(required=True)
password = serializers.CharField(min_length=8, write_only=True)
first_name = serializers.CharField(
required=False, allow_blank=True, max_length=150, default=""
)
last_name = serializers.CharField(
required=False, allow_blank=True, max_length=150, default=""
)
company_name = serializers.CharField(
required=False, allow_blank=True, max_length=256, default=""
)
def validate_email(self, value: str) -> str:
email = value.strip().lower()
if CustomUser.objects.filter(email__iexact=email).exists():
raise serializers.ValidationError("A user with this email already exists.")
if CustomUser.objects.filter(username__iexact=email).exists():
raise serializers.ValidationError("A user with this email already exists.")
return email
def create(self, validated_data):
email = validated_data["email"]
password = validated_data["password"]
first_name = (validated_data.get("first_name") or "").strip()
last_name = (validated_data.get("last_name") or "").strip()
company_name = (validated_data.get("company_name") or "").strip()
if not company_name:
company_name = f"{email}'s workspace"
company = Company.objects.create(
name=company_name,
state="NA",
zipcode="00000",
address="N/A",
)
user = CustomUser.objects.create_user(
username=email,
email=email,
password=password,
first_name=first_name,
last_name=last_name,
company=company,
is_company_manager=True,
)
return user
class ConversationSerializer(serializers.ModelSerializer):
+58 -1
View File
@@ -95,14 +95,71 @@ class TokenTestCase(APITestCase):
self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK)
class PublicSettingsTestCase(APITestCase):
def test_returns_registration_flag(self):
with self.settings(ENABLE_ACCOUNT_REGISTRATION=False):
response = self.client.get(reverse("public_settings"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["enable_account_registration"], False)
class UserCreateTestCase(APITestCase):
def test_registration_disabled_by_default(self):
response = self.client.post(
reverse("create_user"),
{"email": "new@example.com", "password": "securepass1"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(CustomUser.objects.count(), 0)
def test_invalid_payload_returns_errors(self):
response = self.client.post(reverse("create_user"), {}, format="json")
with self.settings(ENABLE_ACCOUNT_REGISTRATION=True):
response = self.client.post(reverse("create_user"), {}, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("email", response.data)
self.assertEqual(CustomUser.objects.count(), 0)
def test_creates_user_company_and_returns_tokens(self):
with self.settings(ENABLE_ACCOUNT_REGISTRATION=True):
response = self.client.post(
reverse("create_user"),
{
"email": "New.User@Example.com",
"password": "securepass1",
"first_name": "New",
"last_name": "User",
"company_name": "New Co",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["email"], "new.user@example.com")
self.assertIn("access", response.data)
self.assertIn("refresh", response.data)
user = CustomUser.objects.get(email="new.user@example.com")
self.assertTrue(user.check_password("securepass1"))
self.assertTrue(user.is_company_manager)
self.assertEqual(user.company.name, "New Co")
self.assertEqual(user.username, "new.user@example.com")
def test_duplicate_email_rejected(self):
make_user(email="taken@example.com")
with self.settings(ENABLE_ACCOUNT_REGISTRATION=True):
response = self.client.post(
reverse("create_user"),
{"email": "taken@example.com", "password": "securepass1"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("email", response.data)
class SetPasswordTestCase(APITestCase):
def setUp(self):
+2
View File
@@ -7,6 +7,7 @@ from .views import (
CustomUserInvite,
LogoutAndBlacklistRefreshTokenForUserView,
CustomUserGet,
PublicSettingsView,
is_authenticated,
AnnouncmentView,
FeedbackView,
@@ -32,6 +33,7 @@ urlpatterns = [
path("token/obtain/", CustomObtainTokenView.as_view(), name="token_create"),
path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"),
path("user/create/", CustomUserCreate.as_view(), name="create_user"),
path("public/settings/", PublicSettingsView.as_view(), name="public_settings"),
path("user/invite/", CustomUserInvite.as_view(), name="invite_user"),
path("user/reset_password/", reset_password, name="reset_password"),
path(
+39 -7
View File
@@ -6,6 +6,7 @@ from rest_framework import permissions, status
from .serializers import (
MyTokenObtainPairSerializer,
CustomUserSerializer,
SelfServeRegistrationSerializer,
BasicUserSerializer,
AnnouncmentSerializer,
CompanySerializer,
@@ -92,18 +93,49 @@ class CustomObtainTokenView(TokenObtainPairView):
serializer_class = MyTokenObtainPairSerializer
class PublicSettingsView(APIView):
"""Non-secret feature flags / public config for the SPA."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def get(self, request):
return Response(
{
"enable_account_registration": settings.ENABLE_ACCOUNT_REGISTRATION,
}
)
class CustomUserCreate(APIView):
"""Self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION (default off)."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def post(self, request, format="json"):
serializer = CustomUserSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
if user:
json = serializer.data
return Response(json, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
if not settings.ENABLE_ACCOUNT_REGISTRATION:
return Response(
{"detail": "Account registration is disabled."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = SelfServeRegistrationSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
user = serializer.save()
refresh = RefreshToken.for_user(user)
return Response(
{
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"access": str(refresh.access_token),
"refresh": str(refresh),
},
status=status.HTTP_201_CREATED,
)
def send_invite_email(slug, email_to_invite):