Skip to content

Commit 2d8a2bf

Browse files
Redirect to dashboard home after login, usually (#2600)
* redirect to dashboard on most logins * encode pathname, too; remove default signup url * update customloginview * make tests less fragile to env settings * fix ts error * rename loginNext to next * check user auth before enrolling
1 parent a25669a commit 2d8a2bf

File tree

14 files changed

+176
-219
lines changed

14 files changed

+176
-219
lines changed

authentication/views.py

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
"""Authentication views"""
22

33
import logging
4-
from urllib.parse import urljoin
54

5+
from django.conf import settings
66
from django.contrib.auth import logout
77
from django.shortcuts import redirect
88
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
9-
from django.utils.text import slugify
109
from django.views import View
1110

12-
from main import settings
1311
from main.middleware.apisix_user import ApisixUserMiddleware, decode_apisix_headers
1412

1513
log = logging.getLogger(__name__)
@@ -80,6 +78,7 @@ def get(
8078
"""
8179
redirect_url = get_redirect_url(request, ["next"])
8280
signup_redirect_url = get_redirect_url(request, ["signup_next", "next"])
81+
should_skip_onboarding = request.GET.get("skip_onboarding", "0") != "0"
8382
if not request.user.is_anonymous:
8483
profile = request.user.profile
8584

@@ -91,31 +90,16 @@ def get(
9190
)
9291

9392
if user_organizations:
94-
# First-time login for org user: redirect to org dashboard
95-
if not profile.has_logged_in:
96-
first_org_name = next(iter(user_organizations.keys()))
97-
org_slug = slugify(first_org_name)
98-
99-
log.info(
100-
"User %s belongs to organization: %s (slug: %s)",
101-
request.user.email,
102-
first_org_name,
103-
org_slug,
104-
)
105-
106-
redirect_url = urljoin(
107-
settings.APP_BASE_URL, f"/dashboard/organization/{org_slug}"
108-
)
109-
# first-time non-org users
110-
elif not profile.has_logged_in:
111-
if request.GET.get("skip_onboarding", "0") == "0":
93+
should_skip_onboarding = True
94+
95+
if not profile.has_logged_in:
96+
if should_skip_onboarding:
97+
redirect_url = signup_redirect_url
98+
else:
11299
params = urlencode({"next": signup_redirect_url})
113100
redirect_url = f"{settings.MITOL_NEW_USER_LOGIN_URL}?{params}"
114101
profile.save()
115-
else:
116-
redirect_url = signup_redirect_url
117102

118-
if not profile.has_logged_in:
119103
profile.has_logged_in = True
120104
profile.save()
121105

authentication/views_test.py

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
from base64 import b64encode
5+
from typing import NamedTuple
56
from unittest.mock import MagicMock
67
from urllib.parse import urljoin
78

@@ -29,7 +30,7 @@
2930
(["allowed-2"], "https://good.com/url-2"),
3031
],
3132
)
32-
def test_get_redirect_url(mocker, param_names, expected_redirect):
33+
def test_get_redirect_url(mocker, param_names, expected_redirect, settings):
3334
"""Next url should be respected if host is allowed"""
3435
GET = {
3536
"exists-a": "/url-a",
@@ -38,10 +39,7 @@ def test_get_redirect_url(mocker, param_names, expected_redirect):
3839
"disallowed-a": "https://malicious.com/url-1",
3940
"allowed-2": "https://good.com/url-2",
4041
}
41-
mocker.patch(
42-
"authentication.views.settings.ALLOWED_REDIRECT_HOSTS",
43-
["good.com"],
44-
)
42+
settings.ALLOWED_REDIRECT_HOSTS = ["good.com"]
4543

4644
mock_request = mocker.MagicMock(GET=GET)
4745
assert get_redirect_url(mock_request, param_names) == expected_redirect
@@ -50,6 +48,7 @@ def test_get_redirect_url(mocker, param_names, expected_redirect):
5048
@pytest.mark.parametrize(
5149
"test_params",
5250
[
51+
# has_apisix_header, next_url
5352
(True, "/search"),
5453
(True, None),
5554
(False, "/search"),
@@ -129,8 +128,9 @@ def test_next_logout(mocker, client, user, test_params, settings):
129128

130129
@pytest.mark.parametrize("is_authenticated", [True, False])
131130
@pytest.mark.parametrize("has_next", [True, False])
132-
def test_custom_logout_view(mocker, client, user, is_authenticated, has_next):
131+
def test_custom_logout_view(mocker, client, user, is_authenticated, has_next, settings): # noqa: PLR0913
133132
"""Test logout redirect"""
133+
settings.ALLOWED_REDIRECT_HOSTS = ["ocw.mit.edu"]
134134
next_url = "https://ocw.mit.edu" if has_next else ""
135135
mock_request = mocker.MagicMock(user=user, META={})
136136
if is_authenticated:
@@ -245,23 +245,43 @@ def test_custom_login_view_first_time_login_sets_has_logged_in(mocker):
245245
mock_profile.save.assert_called_once()
246246

247247

248+
class LoginOrgUserRedirectParams(NamedTuple):
249+
"""Parameters for testing org user login redirect behavior"""
250+
251+
has_logged_in: bool
252+
login_url: str
253+
expected_redirect: str
254+
255+
248256
@pytest.mark.parametrize(
249-
"test_case",
257+
"params",
250258
[
251-
(
252-
False,
253-
"/dashboard/organization/test-organization",
254-
), # First-time login → org dashboard
255-
(
256-
True,
257-
"/app",
258-
), # Subsequent login → normal app
259+
LoginOrgUserRedirectParams(
260+
has_logged_in=False,
261+
login_url="/login/?next=/dashboard",
262+
expected_redirect="/dashboard",
263+
),
264+
LoginOrgUserRedirectParams(
265+
has_logged_in=False,
266+
login_url="/login/?next=/dashboard&signup_next=/somewhere-else",
267+
expected_redirect="/somewhere-else",
268+
),
269+
LoginOrgUserRedirectParams(
270+
has_logged_in=True,
271+
login_url="/login/?next=/dashboard&signup_next=/somewhere-else",
272+
expected_redirect="/dashboard",
273+
),
259274
],
260275
)
261-
def test_login_org_user_redirect(mocker, client, user, test_case, settings):
276+
def test_login_org_user_redirect(
277+
mocker,
278+
client,
279+
user,
280+
params,
281+
settings,
282+
):
262283
"""Test organization user redirect behavior - org users skip onboarding regardless of login history"""
263-
# Unpack test case
264-
has_logged_in, expected_url = test_case
284+
has_logged_in, login_url, expected_redirect = params
265285

266286
# Set up user profile based on test scenario
267287
user.profile.has_logged_in = has_logged_in
@@ -284,15 +304,18 @@ def test_login_org_user_redirect(mocker, client, user, test_case, settings):
284304
)
285305
client.force_login(user)
286306
response = client.get(
287-
"/login/",
307+
login_url,
288308
follow=False,
289309
HTTP_X_USERINFO=header_str,
290310
)
291311
assert response.status_code == 302
292312
# Handle environment differences - in some envs it returns full URL, in others just path
293-
expected_full_url = urljoin(settings.APP_BASE_URL, expected_url)
294-
assert response.url in [expected_url, expected_full_url]
313+
expected_full_redirect = urljoin(settings.APP_BASE_URL, expected_redirect)
314+
assert response.url in [expected_redirect, expected_full_redirect]
295315

296316
# Verify that org users are never sent to onboarding
297317
# (onboarding URL would contain settings.MITOL_NEW_USER_LOGIN_URL)
298318
assert settings.MITOL_NEW_USER_LOGIN_URL not in response.url
319+
320+
user.profile.refresh_from_db()
321+
assert user.profile.has_logged_in is True
Lines changed: 19 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import React from "react"
22
import { renderWithProviders, setMockResponse, waitFor } from "@/test-utils"
3-
import { urls } from "api/test-utils"
4-
import {
5-
urls as b2bUrls,
6-
factories as mitxOnlineFactories,
7-
urls as mitxOnlineUrls,
8-
} from "api/mitxonline-test-utils"
3+
import { makeRequest, urls } from "api/test-utils"
4+
import { urls as b2bUrls } from "api/mitxonline-test-utils"
95
import * as commonUrls from "@/common/urls"
106
import { Permission } from "api/hooks/user"
117
import EnrollmentCodePage from "./EnrollmentCodePage"
12-
import invariant from "tiny-invariant"
138

149
// Mock next-nprogress-bar for App Router
1510
const mockPush = jest.fn<void, [string]>()
@@ -30,8 +25,9 @@ describe("EnrollmentCodePage", () => {
3025
[Permission.Authenticated]: false,
3126
})
3227

33-
setMockResponse.get(mitxOnlineUrls.userMe.get(), null)
34-
setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [])
28+
setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [], {
29+
code: 403,
30+
})
3531

3632
renderWithProviders(<EnrollmentCodePage code="test-code" />, {
3733
url: commonUrls.B2B_ATTACH_VIEW,
@@ -42,26 +38,22 @@ describe("EnrollmentCodePage", () => {
4238
})
4339

4440
const url = new URL(mockPush.mock.calls[0][0])
45-
expect(url.searchParams.get("skip_onboarding")).toBe("1")
46-
const nextUrl = url.searchParams.get("next")
47-
const signupNextUrl = url.searchParams.get("signup_next")
48-
invariant(nextUrl)
49-
invariant(signupNextUrl)
50-
const attachView = commonUrls.b2bAttachView("test-code")
51-
expect(new URL(nextUrl).pathname).toBe(attachView)
52-
expect(new URL(signupNextUrl).pathname).toBe(attachView)
41+
url.searchParams.sort()
42+
const expectedParams = new URLSearchParams({
43+
skip_onboarding: "1",
44+
next: `http://test.learn.odl.local:8062${commonUrls.b2bAttachView("test-code")}`,
45+
})
46+
expectedParams.sort()
47+
expect([...url.searchParams.entries()]).toEqual([
48+
...expectedParams.entries(),
49+
])
5350
})
5451

5552
test("Renders when logged in", async () => {
5653
setMockResponse.get(urls.userMe.get(), {
5754
[Permission.Authenticated]: true,
5855
})
5956

60-
setMockResponse.get(
61-
mitxOnlineUrls.userMe.get(),
62-
mitxOnlineFactories.user.user(),
63-
)
64-
6557
setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [])
6658

6759
renderWithProviders(<EnrollmentCodePage code="test-code" />, {
@@ -70,36 +62,21 @@ describe("EnrollmentCodePage", () => {
7062
})
7163

7264
test("Redirects to dashboard on successful attachment", async () => {
73-
const orgSlug = "test-org"
74-
const mitxOnlineUser = mitxOnlineFactories.user.user({
75-
b2b_organizations: [
76-
{
77-
id: 1,
78-
name: "Test Organization",
79-
description: "A test organization",
80-
logo: "https://example.com/logo.png",
81-
slug: `org-${orgSlug}`,
82-
contracts: [],
83-
},
84-
],
85-
})
86-
8765
setMockResponse.get(urls.userMe.get(), {
8866
[Permission.Authenticated]: true,
8967
})
9068

91-
setMockResponse.get(mitxOnlineUrls.userMe.get(), mitxOnlineUser)
92-
93-
setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [])
69+
const attachUrl = b2bUrls.b2bAttach.b2bAttachView("test-code")
70+
setMockResponse.post(attachUrl, [])
9471

9572
renderWithProviders(<EnrollmentCodePage code="test-code" />, {
9673
url: commonUrls.B2B_ATTACH_VIEW,
9774
})
9875

9976
await waitFor(() => {
100-
expect(mockPush).toHaveBeenCalledWith(
101-
commonUrls.organizationView(orgSlug),
102-
)
77+
expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined)
10378
})
79+
80+
expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME)
10481
})
10582
})

frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import React from "react"
33
import { styled, Breadcrumbs, Container, Typography } from "ol-components"
44
import * as urls from "@/common/urls"
55
import { useB2BAttachMutation } from "api/mitxonline-hooks/organizations"
6-
import { useMitxOnlineUserMe } from "api/mitxonline-hooks/user"
76
import { userQueries } from "api/hooks/user"
87
import { useQuery } from "@tanstack/react-query"
98
import { useRouter } from "next-nprogress-bar"
@@ -18,11 +17,7 @@ const InterstitialMessage = styled(Typography)(({ theme }) => ({
1817
}))
1918

2019
const EnrollmentCodePage: React.FC<EnrollmentCodePage> = ({ code }) => {
21-
const {
22-
mutate: attach,
23-
isSuccess,
24-
isPending,
25-
} = useB2BAttachMutation({
20+
const enrollment = useB2BAttachMutation({
2621
enrollment_code: code,
2722
})
2823
const router = useRouter()
@@ -31,24 +26,21 @@ const EnrollmentCodePage: React.FC<EnrollmentCodePage> = ({ code }) => {
3126
...userQueries.me(),
3227
staleTime: 0,
3328
})
34-
const { data: mitxOnlineUser } = useMitxOnlineUserMe()
3529

30+
const enrollAsync = enrollment.mutateAsync
3631
React.useEffect(() => {
37-
attach?.()
38-
}, [attach])
32+
if (user?.is_authenticated) {
33+
enrollAsync().then(() => router.push(urls.DASHBOARD_HOME))
34+
}
35+
}, [user?.is_authenticated, enrollAsync, router])
3936

4037
React.useEffect(() => {
4138
if (userLoading) {
4239
return
4340
}
4441
if (!user?.is_authenticated) {
4542
const loginUrlString = urls.auth({
46-
loginNext: {
47-
pathname: urls.b2bAttachView(code),
48-
searchParams: null,
49-
},
50-
// On signup, redirect to the attach page so attachment can occur.
51-
signupNext: {
43+
next: {
5244
pathname: urls.b2bAttachView(code),
5345
searchParams: null,
5446
},
@@ -57,13 +49,7 @@ const EnrollmentCodePage: React.FC<EnrollmentCodePage> = ({ code }) => {
5749
loginUrl.searchParams.set("skip_onboarding", "1")
5850
router.push(loginUrl.toString())
5951
}
60-
if (isSuccess) {
61-
const org = mitxOnlineUser?.b2b_organizations?.[0]
62-
if (org) {
63-
router.push(urls.organizationView(org.slug.replace("org-", "")))
64-
}
65-
}
66-
}, [isSuccess, userLoading, user, mitxOnlineUser, code, router])
52+
}, [userLoading, user, code, router])
6753

6854
return (
6955
<Container>
@@ -72,7 +58,7 @@ const EnrollmentCodePage: React.FC<EnrollmentCodePage> = ({ code }) => {
7258
ancestors={[{ href: urls.HOME, label: "Home" }]}
7359
current="Use Enrollment Code"
7460
/>
75-
{isPending && (
61+
{enrollment.isPending && (
7662
<InterstitialMessage>Validating code "{code}"...</InterstitialMessage>
7763
)}
7864
</Container>

frontends/main/src/app-pages/ErrorPage/ForbiddenPage.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ test("Fetches auth data afresh and redirects unauthenticated users to auth", asy
5454
await waitFor(() => {
5555
expect(mockedRedirect).toHaveBeenCalledWith(
5656
routes.auth({
57-
loginNext: {
57+
next: {
5858
pathname: "/foo",
5959
searchParams: new URLSearchParams({ cat: "meow" }),
6060
},

frontends/main/src/app-pages/HomePage/HomePage.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ describe("Home Page personalize section", () => {
296296
expect(link).toHaveAttribute(
297297
"href",
298298
routes.auth({
299-
loginNext: {
299+
next: {
300300
pathname: routes.DASHBOARD_HOME,
301301
searchParams: null,
302302
},

frontends/main/src/app-pages/HomePage/PersonalizeSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const AUTH_TEXT_DATA = {
7777
linkProps: {
7878
children: "Sign Up for Free",
7979
href: urls.auth({
80-
loginNext: { pathname: urls.DASHBOARD_HOME, searchParams: null },
80+
next: { pathname: urls.DASHBOARD_HOME, searchParams: null },
8181
}),
8282
},
8383
},

0 commit comments

Comments
 (0)