Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion api/custom_auth/permissions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from django.conf import settings
from django.views import View
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request

from organisations.invites.models import Invite, InviteLink


class CurrentUser(IsAuthenticated):
"""
Expand All @@ -17,8 +20,27 @@ def has_object_permission(self, request, view, obj): # type: ignore[no-untyped-


class IsSignupAllowed(AllowAny):
message = "Signing up without an invitation is disabled. Please contact your administrator."

def has_permission(self, request: Request, view: View) -> bool:
return not settings.PREVENT_SIGNUP
if not settings.PREVENT_SIGNUP:
return True

email = request.data.get("email")
if email and Invite.objects.filter(email__iexact=email).exists():
Comment thread
Zaimwa9 marked this conversation as resolved.
return True

invite_hash = request.data.get("invite_hash")
if invite_hash:
try:
invite_link = InviteLink.objects.get(hash=invite_hash)
except InviteLink.DoesNotExist:
pass

if not invite_link.is_expired:
return True

raise PermissionDenied(self.message)
Copy link
Copy Markdown
Contributor

@emyller emyller May 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following returning False above, DRF should already raise using the message attribute from the class on its own. 😉



class IsPasswordLoginAllowed(AllowAny):
Expand Down
121 changes: 121 additions & 0 deletions api/tests/unit/custom_auth/test_unit_custom_auth_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from datetime import timedelta
from typing import Any, Callable

import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import ( # type: ignore[attr-defined]
APIClient,
override_settings,
)

from organisations.invites.models import Invite, InviteLink
from organisations.models import Organisation


@pytest.fixture
def signup_data() -> dict[str, Any]:
return {
"email": "test@example.com",
"password": "testpass123",
"first_name": "Test",
"last_name": "User",
}


def test_signup_allowed_when_prevent_signup_disabled(
api_client: APIClient, signup_data: dict[str, Any], db: None
) -> None:
# Given
url = reverse("api-v1:custom_auth:ffadminuser-list")

# When
response = api_client.post(url, data=signup_data)

# Then
assert response.status_code == status.HTTP_201_CREATED


@override_settings(PREVENT_SIGNUP=True) # type: ignore[misc]
def test_signup_blocked_when_prevent_signup_enabled_and_no_invitation(
api_client: APIClient, signup_data: dict[str, Any], db: None
) -> None:
# Given
url = reverse("api-v1:custom_auth:ffadminuser-list")

# When
response = api_client.post(url, data=signup_data)

# Then
assert response.status_code == status.HTTP_403_FORBIDDEN
assert (
str(response.data["detail"])
== "Signing up without an invitation is disabled. Please contact your administrator."
)


@override_settings(PREVENT_SIGNUP=True) # type: ignore[misc]
def test_signup_allowed_with_email_invite(
api_client: APIClient, signup_data: dict[str, Any], db: None
) -> None:
# Given
organisation = Organisation.objects.create(name="Test Org")
Invite.objects.create(email=signup_data["email"], organisation=organisation)
url = reverse("api-v1:custom_auth:ffadminuser-list")

# When
response = api_client.post(url, data=signup_data)

# Then
assert response.status_code == status.HTTP_201_CREATED


@pytest.mark.parametrize(
"get_invite_hash, expected_status, expected_detail",
[
(
lambda organisation: InviteLink.objects.create(
organisation=organisation
).hash,
status.HTTP_201_CREATED,
None,
),
(
lambda _: "invalid-hash",
status.HTTP_403_FORBIDDEN,
"Signing up without an invitation is disabled. Please contact your administrator.",
),
(
lambda organisation: InviteLink.objects.create(
organisation=organisation,
expires_at=timezone.now() - timedelta(days=1),
),
status.HTTP_403_FORBIDDEN,
"Signing up without an invitation is disabled. Please contact your administrator.",
),
],
)
@override_settings(PREVENT_SIGNUP=True) # type: ignore[misc]
def test_signup_with_invite_hash_behavior(
api_client: APIClient,
signup_data: dict[str, Any],
db: None,
get_invite_hash: Callable[[Organisation], str],
expected_status: int,
expected_detail: str,
organisation: Organisation,
) -> None:
# Given
invite_hash = get_invite_hash(organisation)

signup_data_with_hash = {**signup_data, "invite_hash": invite_hash}
url = reverse("api-v1:custom_auth:ffadminuser-list")

# When
response = api_client.post(url, data=signup_data_with_hash)

# Then
assert response.status_code == expected_status
if expected_detail:
assert str(response.data["detail"]) == expected_detail
6 changes: 3 additions & 3 deletions frontend/web/components/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,6 @@ const HomePage: React.FC<RouteComponentProps> = ({ history, location }) => {
)
}
}

return (
<AccountProvider>
{(
Expand Down Expand Up @@ -522,8 +521,9 @@ const HomePage: React.FC<RouteComponentProps> = ({ history, location }) => {
>
<ErrorMessage
error={
typeof AccountStore.error === 'string'
? AccountStore.error
typeof (AccountStore.error as any)
?.detail === 'string'
? (AccountStore.error as any).detail
: 'Please check your details and try again'
}
/>
Expand Down
Loading