Skip to content

Commit 61c92bc

Browse files
redirect org users to org dashboard (#2482)
* redirect user to org dashboard on login based on the first org they are determined to be a part of, if they are a part of one * fix test * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * only redirect to org dashboard on first login, and always skip onboarding for org users * redirect using APP_BASE_URL * remove org keycloak scope from default env and add a note about enabling it * fix tests, use settings fixture in authentication views tests --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 14dd9c2 commit 61c92bc

File tree

5 files changed

+111
-12
lines changed

5 files changed

+111
-12
lines changed

authentication/views.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
"""Authentication views"""
22

33
import logging
4+
from urllib.parse import urljoin
45

56
from django.contrib.auth import logout
67
from django.shortcuts import redirect
78
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
9+
from django.utils.text import slugify
810
from django.views import View
911

1012
from main import settings
11-
from main.middleware.apisix_user import ApisixUserMiddleware
13+
from main.middleware.apisix_user import ApisixUserMiddleware, decode_apisix_headers
1214

1315
log = logging.getLogger(__name__)
1416

@@ -64,6 +66,8 @@ class CustomLoginView(View):
6466
Redirect the user to the appropriate url after login
6567
"""
6668

69+
header = "HTTP_X_USERINFO"
70+
6771
def get(
6872
self,
6973
request,
@@ -76,15 +80,42 @@ def get(
7680
redirect_url = get_redirect_url(request)
7781
if not request.user.is_anonymous:
7882
profile = request.user.profile
79-
if not profile.has_logged_in:
80-
profile.has_logged_in = True
81-
profile.save()
82-
if (
83+
84+
apisix_header = decode_apisix_headers(request, self.header)
85+
86+
# Check if user belongs to any organizations
87+
user_organizations = (
88+
apisix_header.get("organizations", {}) if apisix_header else {}
89+
)
90+
91+
if user_organizations:
92+
# First-time login for org user: redirect to org dashboard
93+
if not profile.has_logged_in:
94+
first_org_name = next(iter(user_organizations.keys()))
95+
org_slug = slugify(first_org_name)
96+
97+
log.info(
98+
"User %s belongs to organization: %s (slug: %s)",
99+
request.user.email,
100+
first_org_name,
101+
org_slug,
102+
)
103+
104+
redirect_url = urljoin(
105+
settings.APP_BASE_URL, f"/dashboard/organization/{org_slug}"
106+
)
107+
# Non-organization users: apply onboarding logic
108+
elif (
83109
not profile.completed_onboarding
84110
and request.GET.get("skip_onboarding", "0") == "0"
85111
):
86112
params = urlencode({"next": redirect_url})
87113
redirect_url = f"{settings.MITOL_NEW_USER_LOGIN_URL}?{params}"
88114
profile.completed_onboarding = True
89115
profile.save()
116+
117+
if not profile.has_logged_in:
118+
profile.has_logged_in = True
119+
profile.save()
120+
90121
return redirect(redirect_url)

authentication/views_test.py

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
import json
44
from base64 import b64encode
55
from unittest.mock import MagicMock
6+
from urllib.parse import urljoin
67

78
import pytest
8-
from django.conf import settings
99
from django.test import RequestFactory
1010
from django.urls import reverse
1111

@@ -28,10 +28,18 @@ def test_custom_login(mocker, next_url, allowed):
2828
assert get_redirect_url(mock_request) == (next_url if allowed else "/app")
2929

3030

31-
@pytest.mark.parametrize("has_apisix_header", [True, False])
32-
@pytest.mark.parametrize("next_url", ["/search", None])
33-
def test_logout(mocker, next_url, client, user, has_apisix_header):
31+
@pytest.mark.parametrize(
32+
"test_params",
33+
[
34+
(True, "/search"),
35+
(True, None),
36+
(False, "/search"),
37+
(False, None),
38+
],
39+
)
40+
def test_logout(mocker, client, user, test_params, settings):
3441
"""User should be properly redirected and logged out"""
42+
has_apisix_header, next_url = test_params
3543
header_str = b64encode(
3644
json.dumps(
3745
{
@@ -55,10 +63,10 @@ def test_logout(mocker, next_url, client, user, has_apisix_header):
5563
mock_logout.assert_called_once()
5664

5765

58-
@pytest.mark.parametrize("is_authenticated", [True])
59-
@pytest.mark.parametrize("has_next", [False])
60-
def test_next_logout(mocker, client, user, is_authenticated, has_next):
66+
@pytest.mark.parametrize("test_params", [(True, False)])
67+
def test_next_logout(mocker, client, user, test_params, settings):
6168
"""Test logout redirect cache assignment"""
69+
is_authenticated, has_next = test_params
6270
next_url = "https://ocw.mit.edu"
6371
mock_request = mocker.MagicMock(
6472
GET={"next": next_url if has_next else None},
@@ -211,3 +219,60 @@ def test_custom_login_view_first_time_login_sets_has_logged_in(mocker):
211219

212220
# Verify redirect was called with the correct URL
213221
mock_redirect.assert_called_once_with("/dashboard")
222+
223+
224+
@pytest.mark.parametrize(
225+
"test_case",
226+
[
227+
(
228+
(False, False),
229+
"/dashboard/organization/test-organization",
230+
), # First-time login → org dashboard
231+
(
232+
(False, True),
233+
"/dashboard/organization/test-organization",
234+
), # First-time login → org dashboard
235+
((True, False), "/app"), # Subsequent login → normal app (not onboarding!)
236+
((True, True), "/app"), # Subsequent login → normal app
237+
],
238+
)
239+
def test_login_org_user_redirect(mocker, client, user, test_case, settings):
240+
"""Test organization user redirect behavior - org users skip onboarding regardless of onboarding status"""
241+
# Unpack test case
242+
profile_state, expected_url = test_case
243+
has_logged_in, completed_onboarding = profile_state
244+
245+
# Set up user profile based on test scenario
246+
user.profile.has_logged_in = has_logged_in
247+
user.profile.completed_onboarding = completed_onboarding
248+
user.profile.save()
249+
250+
header_str = b64encode(
251+
json.dumps(
252+
{
253+
"preferred_username": user.username,
254+
"email": user.email,
255+
"sub": user.global_id,
256+
"organization": {
257+
"Test Organization": {
258+
"role": "member",
259+
"id": "org-123",
260+
}
261+
},
262+
}
263+
).encode()
264+
)
265+
client.force_login(user)
266+
response = client.get(
267+
"/login/",
268+
follow=False,
269+
HTTP_X_USERINFO=header_str,
270+
)
271+
assert response.status_code == 302
272+
# Handle environment differences - in some envs it returns full URL, in others just path
273+
expected_full_url = urljoin(settings.APP_BASE_URL, expected_url)
274+
assert response.url in [expected_url, expected_full_url]
275+
276+
# Verify that org users are never sent to onboarding
277+
# (onboarding URL would contain settings.MITOL_NEW_USER_LOGIN_URL)
278+
assert settings.MITOL_NEW_USER_LOGIN_URL not in response.url

env/backend.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ KEYCLOAK_CLIENT_ID=apisix
4848
KEYCLOAK_CLIENT_SECRET=HckCZXToXfaetbBx0Fo3xbjnC468oMi4 # pragma: allowlist-secret
4949
KEYCLOAK_DISCOVERY_URL=http://kc.ol.local:8066/realms/ol-local/.well-known/openid-configuration
5050
KEYCLOAK_REALM_NAME=ol-local
51+
# For some organization related functionality, add organization:* if you have orgs enabled
5152
KEYCLOAK_SCOPES="openid profile ol-profile"
5253
KEYCLOAK_SVC_KEYSTORE_PASSWORD=supertopsecret1234
5354
KEYCLOAK_SVC_HOSTNAME=kc.ol.local

env/backend.local.example.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ KEYCLOAK_CLIENT_ID=apisix
3131
KEYCLOAK_CLIENT_SECRET=HckCZXToXfaetbBx0Fo3xbjnC468oMi4 # pragma: allowlist-secret
3232
KEYCLOAK_DISCOVERY_URL=http://kc.ol.local:8066/realms/ol-local/.well-known/openid-configuration
3333
KEYCLOAK_REALM_NAME=ol-local
34+
# For some organization related functionality, add organization:* if you have orgs enabled
3435
KEYCLOAK_SCOPES="openid profile ol-profile"
3536
KEYCLOAK_SVC_KEYSTORE_PASSWORD=supertopsecret1234
3637
KEYCLOAK_SVC_HOSTNAME=kc.ol.local

main/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@
356356
"first_name": "given_name",
357357
"last_name": "family_name",
358358
"name": "name",
359+
"organizations": "organization",
359360
},
360361
"profiles.Profile": {
361362
"name": "name",

0 commit comments

Comments
 (0)