Skip to content

Commit 208c9bb

Browse files
separate login and signup redirects
1 parent 3d3e75c commit 208c9bb

File tree

15 files changed

+193
-110
lines changed

15 files changed

+193
-110
lines changed

authentication/views.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
log = logging.getLogger(__name__)
1414

1515

16-
def get_redirect_url(request):
16+
def get_redirect_url(request, param_name):
1717
"""
1818
Get the redirect URL from the request.
1919
@@ -23,7 +23,7 @@ def get_redirect_url(request):
2323
Returns:
2424
str: Redirect URL
2525
"""
26-
next_url = request.GET.get("next") or request.COOKIES.get("next")
26+
next_url = request.GET.get(param_name) or request.COOKIES.get(param_name)
2727
return (
2828
next_url
2929
if next_url
@@ -49,7 +49,7 @@ def get(
4949
GET endpoint reached after logging a user out from Keycloak
5050
"""
5151
user = getattr(request, "user", None)
52-
user_redirect_url = get_redirect_url(request)
52+
user_redirect_url = get_redirect_url(request, "next")
5353
if user and user.is_authenticated:
5454
logout(request)
5555
if request.META.get(ApisixUserMiddleware.header):
@@ -73,15 +73,18 @@ def get(
7373
"""
7474
GET endpoint for logging a user in.
7575
"""
76-
redirect_url = get_redirect_url(request)
77-
if not request.user.is_anonymous:
76+
login_redirect_url = get_redirect_url(request, "next")
77+
signup_redirect_url = get_redirect_url(request, "signup_next")
78+
if (
79+
not request.user.is_anonymous
80+
and not request.user.profile.completed_onboarding
81+
):
7882
profile = request.user.profile
79-
if (
80-
not profile.completed_onboarding
81-
and request.GET.get("skip_onboarding", "0") == "0"
82-
):
83-
params = urlencode({"next": redirect_url})
84-
redirect_url = f"{settings.MITOL_NEW_USER_LOGIN_URL}?{params}"
83+
if request.GET.get("skip_onboarding", "0") == "0":
84+
params = urlencode({"next": signup_redirect_url})
85+
login_redirect_url = f"{settings.MITOL_NEW_USER_LOGIN_URL}?{params}"
8586
profile.completed_onboarding = True
8687
profile.save()
87-
return redirect(redirect_url)
88+
else:
89+
return redirect(signup_redirect_url)
90+
return redirect(login_redirect_url)

authentication/views_test.py

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,32 @@
88
from django.conf import settings
99
from django.test import RequestFactory
1010
from django.urls import reverse
11+
from django.utils.http import urlencode
1112

1213
from authentication.views import CustomLoginView, get_redirect_url
1314

1415

1516
@pytest.mark.parametrize(
16-
("next_url", "allowed"),
17+
("next_url", "allowed", "param_name"),
1718
[
18-
("/app", True),
19-
("http://open.odl.local:8062/search", True),
20-
("http://open.odl.local:8069/search", False),
21-
("https://ocw.mit.edu", True),
22-
("https://fake.fake.edu", False),
19+
("/app", True, "next"),
20+
("http://open.odl.local:8062/search", True, "next"),
21+
("http://open.odl.local:8069/search", False, "next"),
22+
("https://ocw.mit.edu", True, "next"),
23+
("https://fake.fake.edu", False, "next"),
24+
("/app", True, "signup_next"),
25+
("http://open.odl.local:8062/search", True, "signup_next"),
26+
("http://open.odl.local:8069/search", False, "signup_next"),
27+
("https://ocw.mit.edu", True, "signup_next"),
28+
("https://fake.fake.edu", False, "signup_next"),
2329
],
2430
)
25-
def test_custom_login(mocker, next_url, allowed):
31+
def test_get_redirect_url(mocker, next_url, allowed, param_name):
2632
"""Next url should be respected if host is allowed"""
27-
mock_request = mocker.MagicMock(GET={"next": next_url})
28-
assert get_redirect_url(mock_request) == (next_url if allowed else "/app")
33+
mock_request = mocker.MagicMock(GET={param_name: next_url})
34+
assert get_redirect_url(mock_request, param_name=param_name) == (
35+
next_url if allowed else "/app"
36+
)
2937

3038

3139
@pytest.mark.parametrize("has_apisix_header", [True, False])
@@ -116,49 +124,53 @@ def test_custom_logout_view(mocker, client, user, is_authenticated, has_next):
116124
def test_custom_login_view_authenticated_user_with_onboarding(mocker):
117125
"""Test CustomLoginView for an authenticated user with incomplete onboarding"""
118126
factory = RequestFactory()
119-
request = factory.get(reverse("login"), {"next": "/dashboard"})
127+
request = factory.get(
128+
reverse("login"),
129+
{"next": "/irrelevant", "signup_next": "/search?resource=184"},
130+
)
120131
request.user = MagicMock(is_anonymous=False)
121132
request.user.profile = MagicMock(completed_onboarding=False)
122-
mocker.patch("authentication.views.get_redirect_url", return_value="/dashboard")
123-
mocker.patch(
124-
"authentication.views.urlencode", return_value="next=/search?resource=184"
125-
)
126133
mocker.patch(
127134
"authentication.views.settings.MITOL_NEW_USER_LOGIN_URL", "/onboarding"
128135
)
129136

130137
response = CustomLoginView().get(request)
131138

132139
assert response.status_code == 302
133-
assert response.url == "/onboarding?next=/search?resource=184"
140+
assert response.url == f"/onboarding?{urlencode({'next': '/search?resource=184'})}"
134141

135142

136143
def test_custom_login_view_authenticated_user_skip_onboarding(mocker):
137144
"""Test skip_onboarding flag skips redirect to onboarding and sets completed_onboarding"""
138145
factory = RequestFactory()
139146
request = factory.get(
140-
reverse("login"), {"next": "/dashboard", "skip_onboarding": "1"}
147+
reverse("login"),
148+
{
149+
"next": "/irrelevant",
150+
"signup_next": "/search?resource=184",
151+
"skip_onboarding": "1",
152+
},
141153
)
142154
request.user = MagicMock(is_anonymous=False)
143155
request.user.profile = MagicMock(completed_onboarding=False)
144-
mocker.patch("authentication.views.get_redirect_url", return_value="/dashboard")
145156

146157
response = CustomLoginView().get(request)
147158
request.user.profile.refresh_from_db()
148159
# user should not be marked as completed onboarding
149160
assert request.user.profile.completed_onboarding is False
150161

151162
assert response.status_code == 302
152-
assert response.url == "/dashboard"
163+
assert response.url == "/search?resource=184"
153164

154165

155166
def test_custom_login_view_authenticated_user_with_completed_onboarding(mocker):
156167
"""Test test that user who has completed onboarding is redirected to next url"""
157168
factory = RequestFactory()
158-
request = factory.get(reverse("login"), {"next": "/dashboard"})
169+
request = factory.get(
170+
reverse("login"), {"next": "/dashboard", "signup_next": "/irrelevant"}
171+
)
159172
request.user = MagicMock(is_anonymous=False)
160173
request.user.profile = MagicMock(completed_onboarding=True)
161-
mocker.patch("authentication.views.get_redirect_url", return_value="/dashboard")
162174

163175
response = CustomLoginView().get(request)
164176

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from "react"
22
import { renderWithProviders, screen, waitFor } from "../../test-utils"
3-
import { HOME, login } from "@/common/urls"
3+
import * as routes from "@/common/urls"
44
import ForbiddenPage from "./ForbiddenPage"
55
import { setMockResponse, urls, factories } from "api/test-utils"
66
import { useUserMe } from "api/hooks/user"
@@ -22,7 +22,7 @@ test("The ForbiddenPage loads with a link that directs to HomePage", async () =>
2222
setMockResponse.get(urls.userMe.get(), makeUser({ is_authenticated: true }))
2323
renderWithProviders(<ForbiddenPage />)
2424
const homeLink = await screen.findByRole("link", { name: "Home" })
25-
expect(homeLink).toHaveAttribute("href", HOME)
25+
expect(homeLink).toHaveAttribute("href", routes.HOME)
2626
})
2727

2828
test("Fetches auth data afresh and redirects unauthenticated users to auth", async () => {
@@ -53,9 +53,11 @@ test("Fetches auth data afresh and redirects unauthenticated users to auth", asy
5353

5454
await waitFor(() => {
5555
expect(mockedRedirect).toHaveBeenCalledWith(
56-
login({
57-
pathname: "/foo",
58-
searchParams: new URLSearchParams({ cat: "meow" }),
56+
routes.auth({
57+
loginNext: {
58+
pathname: "/foo",
59+
searchParams: new URLSearchParams({ cat: "meow" }),
60+
},
5961
}),
6062
)
6163
})

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useEffect } from "react"
22
import ErrorPageTemplate from "./ErrorPageTemplate"
33
import { userQueries } from "api/hooks/user"
44
import { useQuery } from "@tanstack/react-query"
5-
import { redirectLoginToCurrent } from "@/common/utils"
5+
import { redirectAuthToCurrent } from "@/common/utils"
66

77
const ForbiddenPage: React.FC = () => {
88
const user = useQuery({
@@ -15,7 +15,7 @@ const ForbiddenPage: React.FC = () => {
1515

1616
useEffect(() => {
1717
if (shouldRedirect) {
18-
redirectLoginToCurrent()
18+
redirectAuthToCurrent()
1919
}
2020
}, [shouldRedirect])
2121

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,11 @@ describe("Home Page personalize section", () => {
283283
const link = within(personalize).getByRole("link")
284284
expect(link).toHaveAttribute(
285285
"href",
286-
routes.login({
287-
pathname: routes.DASHBOARD_HOME,
288-
searchParams: null,
286+
routes.auth({
287+
loginNext: {
288+
pathname: routes.DASHBOARD_HOME,
289+
searchParams: null,
290+
},
289291
}),
290292
)
291293
})

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ const AUTH_TEXT_DATA = {
7676
text: "As a member, get personalized recommendations, curate learning lists, and follow your areas of interest.",
7777
linkProps: {
7878
children: "Sign Up for Free",
79-
href: urls.login({ pathname: urls.DASHBOARD_HOME, searchParams: null }),
79+
href: urls.auth({
80+
loginNext: { pathname: urls.DASHBOARD_HOME, searchParams: null },
81+
}),
8082
},
8183
},
8284
}
Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,35 @@
1-
import { login } from "./urls"
1+
import { auth } from "./urls"
22

33
const MITOL_API_BASE_URL = process.env.NEXT_PUBLIC_MITOL_API_BASE_URL
44

55
test("login encodes the next parameter appropriately", () => {
6-
expect(login({ pathname: null, searchParams: null })).toBe(
7-
`${MITOL_API_BASE_URL}/login?next=http://test.learn.odl.local:8062/`,
6+
expect(
7+
auth({
8+
loginNext: { pathname: null, searchParams: null },
9+
}),
10+
).toBe(
11+
`${MITOL_API_BASE_URL}/login?next=http://test.learn.odl.local:8062/&signup_next=http://test.learn.odl.local:8062/dashboard`,
812
)
913

1014
expect(
11-
login({
12-
pathname: "/foo/bar",
13-
searchParams: null,
15+
auth({
16+
loginNext: {
17+
pathname: "/foo/bar",
18+
searchParams: null,
19+
},
1420
}),
1521
).toBe(
16-
`${MITOL_API_BASE_URL}/login?next=http://test.learn.odl.local:8062/foo/bar`,
22+
`${MITOL_API_BASE_URL}/login?next=http://test.learn.odl.local:8062/foo/bar&signup_next=http://test.learn.odl.local:8062/dashboard`,
1723
)
1824

1925
expect(
20-
login({
21-
pathname: "/foo/bar",
22-
searchParams: new URLSearchParams("?cat=meow"),
26+
auth({
27+
loginNext: {
28+
pathname: "/foo/bar",
29+
searchParams: new URLSearchParams("?cat=meow"),
30+
},
2331
}),
2432
).toBe(
25-
`${MITOL_API_BASE_URL}/login?next=http://test.learn.odl.local:8062/foo/bar%3Fcat%3Dmeow`,
33+
`${MITOL_API_BASE_URL}/login?next=http://test.learn.odl.local:8062/foo/bar%3Fcat%3Dmeow&signup_next=http://test.learn.odl.local:8062/dashboard`,
2634
)
2735
})

frontends/main/src/common/urls.ts

Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -55,40 +55,6 @@ if (process.env.NODE_ENV !== "production") {
5555

5656
const MITOL_API_BASE_URL = process.env.NEXT_PUBLIC_MITOL_API_BASE_URL
5757

58-
export const LOGIN = `${MITOL_API_BASE_URL}/login`
59-
export const LOGOUT = `${MITOL_API_BASE_URL}/logout/`
60-
61-
/**
62-
* Returns the URL to the login page, with a `next` parameter to redirect back
63-
* to the given pathname + search parameters.
64-
*
65-
* NOTES:
66-
* 1. useLoginToCurrent() is a convenience function that uses the current
67-
* pathname and search parameters to generate the next URL.
68-
* 2. `next` is required to encourage its use. You can explicitly pass `null`
69-
* for values to skip them if desired.
70-
*/
71-
export const login = (next: {
72-
pathname: string | null
73-
searchParams: URLSearchParams | null
74-
hash?: string | null
75-
}) => {
76-
const pathname = next.pathname ?? "/"
77-
const searchParams = next.searchParams ?? new URLSearchParams()
78-
const hash = next.hash ?? ""
79-
/**
80-
* To include search parameters in the next URL, we need to encode them.
81-
* If we pass `?next=/foo/bar?cat=meow` directly, Django receives two separate
82-
* parameters: `next` and `cat`.
83-
*
84-
* There's no need to encode the path parameter (it might contain slashes,
85-
* but those are allowed in search parameters) so let's keep it readable.
86-
*/
87-
const search = searchParams?.toString() ? `?${searchParams.toString()}` : ""
88-
const nextHref = `${ORIGIN}${pathname}${encodeURIComponent(search)}${encodeURIComponent(hash as string)}`
89-
return `${LOGIN}?next=${nextHref}`
90-
}
91-
9258
export const DASHBOARD_VIEW = "/dashboard/[tab]"
9359
const dashboardView = (tab: string) => generatePath(DASHBOARD_VIEW, { tab })
9460

@@ -166,4 +132,59 @@ export const SEARCH_LEARNING_MATERIAL = querifiedSearchUrl({
166132
resource_category: "learning_material",
167133
})
168134

135+
export const LOGIN = `${MITOL_API_BASE_URL}/login`
136+
export const LOGOUT = `${MITOL_API_BASE_URL}/logout/`
137+
138+
type UrlDescriptor = {
139+
pathname: string | null
140+
searchParams: URLSearchParams | null
141+
hash?: string | null
142+
}
143+
export type LoginUrlOpts = {
144+
/**
145+
* URL to redirect to after login.
146+
*/
147+
loginNext: UrlDescriptor
148+
/**
149+
* URL to redirect to after signup.
150+
*/
151+
signupNext?: UrlDescriptor
152+
}
153+
154+
const DEFAULT_SIGNUP_NEXT: UrlDescriptor = {
155+
pathname: DASHBOARD_HOME,
156+
searchParams: null,
157+
}
158+
159+
/**
160+
* Returns the URL to the authentication page (login and signup).
161+
*
162+
* NOTES:
163+
* 1. useAuthToCurrent() is a convenience function that uses the current
164+
* pathname and search parameters to generate the next URL.
165+
* 2. `next` is required to encourage its use. You can explicitly pass `null`
166+
* for values to skip them if desired.
167+
*/
168+
export const auth = (opts: LoginUrlOpts) => {
169+
const { loginNext, signupNext = DEFAULT_SIGNUP_NEXT } = opts
170+
const encode = (value: UrlDescriptor) => {
171+
const pathname = value.pathname ?? "/"
172+
const searchParams = value.searchParams ?? new URLSearchParams()
173+
const hash = value.hash ?? ""
174+
/**
175+
* To include search parameters in the next URL, we need to encode them.
176+
* If we pass `?next=/foo/bar?cat=meow` directly, Django receives two separate
177+
* parameters: `next` and `cat`.
178+
*
179+
* There's no need to encode the path parameter (it might contain slashes,
180+
* but those are allowed in search parameters) so let's keep it readable.
181+
*/
182+
const search = searchParams?.toString() ? `?${searchParams.toString()}` : ""
183+
return `${ORIGIN}${pathname}${encodeURIComponent(search)}${encodeURIComponent(hash)}`
184+
}
185+
const loginNextHref = encode(loginNext)
186+
const signupNextHref = encode(signupNext)
187+
return `${LOGIN}?next=${loginNextHref}&signup_next=${signupNextHref}`
188+
}
189+
169190
export const ECOMMERCE_CART = "/cart/" as const

0 commit comments

Comments
 (0)