Repository: zulip/zulip

Issue:
Skip login page for organizations with web-public streams
For organizations with web-public streams, we can minimize friction for visitors by taking the user directly to the organization, bypassing the `/login` page regardless of whether the user is logged in. Users who wish to log in can use one of the login buttons in the web-public view.

Details:
- It should work this way for links to a message/topic/stream that is web-public, as well as links to the organization homepage.
- Ideally, links to a stream/topic/message that is not web-public would still take the user to the login page.
- This should only apply to organizations that have web-public streams enabled and actually have at least one web-public stream.

[CZO thread](https://chat.zulip.org/#narrow/stream/2-general/topic/web-public.20.2F.20login.20sequencing)


Discussion:

Resolving patch:
diff --git a/zerver/views/development/dev_login.py b/zerver/views/development/dev_login.py
--- a/zerver/views/development/dev_login.py
+++ b/zerver/views/development/dev_login.py
@@ -79,7 +79,6 @@ def dev_direct_login(
     realm = get_realm(subdomain)
 
     if request.POST.get("prefers_web_public_view") == "Anonymous login":
-        request.session["prefers_web_public_view"] = True
         redirect_to = get_safe_redirect_to(next, realm.uri)
         return HttpResponseRedirect(redirect_to)
 
diff --git a/zerver/views/home.py b/zerver/views/home.py
--- a/zerver/views/home.py
+++ b/zerver/views/home.py
@@ -8,7 +8,7 @@
 from django.utils.cache import patch_cache_control
 
 from zerver.actions.user_settings import do_change_tos_version
-from zerver.context_processors import get_valid_realm_from_request
+from zerver.context_processors import get_realm_from_request, get_valid_realm_from_request
 from zerver.decorator import web_public_view, zulip_login_required
 from zerver.forms import ToSForm
 from zerver.lib.compatibility import is_outdated_desktop_app, is_unsupported_browser
@@ -19,7 +19,6 @@
 from zerver.lib.user_counts import realm_user_count
 from zerver.lib.utils import statsd
 from zerver.models import PreregistrationUser, Realm, Stream, UserProfile
-from zerver.views.auth import get_safe_redirect_to
 from zerver.views.portico import hello_view
 
 
@@ -117,14 +116,10 @@ def home(request: HttpRequest) -> HttpResponse:
     if settings.ROOT_DOMAIN_LANDING_PAGE and subdomain == Realm.SUBDOMAIN_FOR_ROOT_DOMAIN:
         return hello_view(request)
 
-    # TODO: The following logic is a bit hard to read. We save a
-    # database query in the common case by avoiding the call to
-    # `get_valid_realm_from_request` if user hasn't requested
-    # web-public access.
-    if (
-        request.POST.get("prefers_web_public_view") == "true"
-        or request.session.get("prefers_web_public_view")
-    ) and get_valid_realm_from_request(request).allow_web_public_streams_access():
+    realm = get_realm_from_request(request)
+    if realm is None:
+        return render(request, "zerver/invalid_realm.html", status=404)
+    if realm.allow_web_public_streams_access():
         return web_public_view(home_real)(request)
     return zulip_login_required(home_real)(request)
 
@@ -161,32 +156,9 @@ def home_real(request: HttpRequest) -> HttpResponse:
     if request.user.is_authenticated:
         user_profile = request.user
         realm = user_profile.realm
-
-        # User is logged in and hence no longer `prefers_web_public_view`.
-        if "prefers_web_public_view" in request.session.keys():
-            del request.session["prefers_web_public_view"]
     else:
         realm = get_valid_realm_from_request(request)
-
-        # TODO: Ideally, we'd open Zulip directly as a spectator if
-        # the URL had clicked a link to content on a web-public
-        # stream.  We could maybe do this by parsing `next`, but it's
-        # not super convenient with Zulip's hash-based URL scheme.
-
-        # The "Access without an account" button on the login page
-        # submits a POST to this page with this hidden field set.
-        if request.POST.get("prefers_web_public_view") == "true":
-            request.session["prefers_web_public_view"] = True
-            # We serve a redirect here, rather than serving a page, to
-            # avoid browser "Confirm form resubmission" prompts on reload.
-            redirect_to = get_safe_redirect_to(request.POST.get("next"), realm.uri)
-            return redirect(redirect_to)
-
-        # See the assert in `home` above for why this must be true.
-        assert request.session.get("prefers_web_public_view")
-
-        # For users who have selected public access, we load the
-        # spectator experience.  We fall through to the shared code
+        # We load the spectator experience.  We fall through to the shared code
         # for loading the application, with user_profile=None encoding
         # that we're a spectator, not a logged-in user.
         user_profile = None


Test patch:
diff --git a/zerver/tests/test_decorators.py b/zerver/tests/test_decorators.py
--- a/zerver/tests/test_decorators.py
+++ b/zerver/tests/test_decorators.py
@@ -2170,7 +2170,7 @@ def inner(request: HttpRequest) -> HttpResponse:
         # no realm can be set on the request notes.
         with mock.patch("zerver.views.home.zulip_login_required", lambda f: mock_home(None)):
             result = self.client_get("/", subdomain="")
-            self.assertEqual(result.status_code, 200)
+            self.assertEqual(result.status_code, 404)
 
         root_subdomain_realm = do_create_realm("", "Root Domain")
         # Now test that that realm does get set, if it exists, for requests
diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py
--- a/zerver/tests/test_home.py
+++ b/zerver/tests/test_home.py
@@ -14,7 +14,7 @@
 
 from corporate.models import Customer, CustomerPlan
 from zerver.actions.create_user import do_create_user
-from zerver.actions.realm_settings import do_change_realm_plan_type
+from zerver.actions.realm_settings import do_change_realm_plan_type, do_set_realm_property
 from zerver.actions.users import change_user_is_active
 from zerver.lib.compatibility import LAST_SERVER_UPGRADE_TIME, is_outdated_server
 from zerver.lib.home import (
@@ -248,7 +248,7 @@ def test_home(self) -> None:
             set(result["Cache-Control"].split(", ")), {"must-revalidate", "no-store", "no-cache"}
         )
 
-        self.assert_length(queries, 45)
+        self.assert_length(queries, 46)
         self.assert_length(cache_mock.call_args_list, 5)
 
         html = result.content.decode()
@@ -311,46 +311,22 @@ def test_home_demo_organization(self) -> None:
         self.assertEqual(set(actual_keys), set(expected_keys))
 
     def test_logged_out_home(self) -> None:
-        # Redirect to login on first request.
-        result = self.client_get("/")
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(result.url, "/login/")
-
-        # Tell server that user wants to log in anonymously
-        # Redirects to load webapp.
         realm = get_realm("zulip")
-        result = self.client_post("/", {"prefers_web_public_view": "true"})
-        self.assertEqual(self.client.session.get("prefers_web_public_view"), True)
-        self.assertEqual(realm.enable_spectator_access, True)
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(result.url, "http://zulip.testserver")
-
-        # Disable spectator login. Since Realm.enable_spectator_access
-        # is False, the login should fail.
-        realm.enable_spectator_access = False
-        realm.save()
+        do_set_realm_property(realm, "enable_spectator_access", False, acting_user=None)
 
-        result = self.client_post("/", {"prefers_web_public_view": "true"})
-        self.assertEqual(self.client.session.get("prefers_web_public_view"), True)
-        self.assertEqual(realm.enable_spectator_access, False)
+        # Redirect to login if spectator access is disabled.
+        result = self.client_get("/")
         self.assertEqual(result.status_code, 302)
         self.assertEqual(result.url, "/login/")
 
-        # Enable spectator login.
-        realm.enable_spectator_access = True
-        realm.save()
-
-        result = self.client_post("/", {"prefers_web_public_view": "true"})
-        self.assertEqual(self.client.session.get("prefers_web_public_view"), True)
-        self.assertEqual(realm.enable_spectator_access, True)
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(result.url, "http://zulip.testserver")
-
-        # Always load the web app from then on directly
+        # Load webapp directly if spectator access is enabled.
+        do_set_realm_property(realm, "enable_spectator_access", True, acting_user=None)
         result = self.client_get("/")
         self.assertEqual(result.status_code, 200)
 
+        # Check no unnecessary params are passed to spectators.
         page_params = self._get_page_params(result)
+        self.assertEqual(page_params["is_spectator"], True)
         actual_keys = sorted(str(k) for k in page_params.keys())
         removed_keys = [
             "custom_profile_field_types",
@@ -361,7 +337,6 @@ def test_logged_out_home(self) -> None:
         ]
         expected_keys = [i for i in self.expected_page_params_keys if i not in removed_keys]
         self.assertEqual(actual_keys, expected_keys)
-        self.assertEqual(self.client.session.get("prefers_web_public_view"), True)
 
         # Test information passed to client about users.
         page_params = self._get_page_params(result)
@@ -384,11 +359,6 @@ def test_logged_out_home(self) -> None:
         date_length = len("YYYY-MM-DD")
         self.assert_length(page_params["realm_users"][0]["date_joined"], date_length)
 
-        # Web-public session key should clear once user is logged in
-        self.login("hamlet")
-        self.client_get("/")
-        self.assertEqual(self.client.session.get("prefers_web_public_view"), None)
-
     def test_home_under_2fa_without_otp_device(self) -> None:
         with self.settings(TWO_FACTOR_AUTHENTICATION_ENABLED=True):
             self.login("iago")
@@ -421,7 +391,7 @@ def test_num_queries_for_realm_admin(self) -> None:
                 result = self._get_home_page()
                 self.check_rendered_logged_in_app(result)
                 self.assert_length(cache_mock.call_args_list, 6)
-            self.assert_length(queries, 42)
+            self.assert_length(queries, 43)
 
     def test_num_queries_with_streams(self) -> None:
         main_user = self.example_user("hamlet")
@@ -452,7 +422,7 @@ def test_num_queries_with_streams(self) -> None:
         with queries_captured() as queries2:
             result = self._get_home_page()
 
-        self.assert_length(queries2, 40)
+        self.assert_length(queries2, 41)
 
         # Do a sanity check that our new streams were in the payload.
         html = result.content.decode()
diff --git a/zerver/tests/test_sessions.py b/zerver/tests/test_sessions.py
--- a/zerver/tests/test_sessions.py
+++ b/zerver/tests/test_sessions.py
@@ -4,6 +4,7 @@
 
 from django.utils.timezone import now as timezone_now
 
+from zerver.actions.realm_settings import do_set_realm_property
 from zerver.actions.users import change_user_is_active
 from zerver.lib.sessions import (
     delete_all_deactivated_user_sessions,
@@ -28,8 +29,8 @@ def do_test_session(
         action()
         if expected_result:
             result = self.client_get("/", subdomain=realm.subdomain)
-            self.assertEqual(302, result.status_code)
-            self.assertEqual("/login/", result.url)
+            self.assertEqual(200, result.status_code)
+            self.assertTrue('is_spectator":true' in str(result.content))
         else:
             self.assertIn("_auth_user_id", self.client.session)
 
@@ -40,8 +41,8 @@ def test_delete_session(self) -> None:
         for session in user_sessions(user_profile):
             delete_session(session)
         result = self.client_get("/")
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(result.url, "/login/")
+        self.assertEqual(result.status_code, 200)
+        self.assertTrue('is_spectator":true' in str(result.content))
 
     def test_delete_user_sessions(self) -> None:
         user_profile = self.example_user("hamlet")
@@ -77,8 +78,19 @@ def test_delete_all_user_sessions(self) -> None:
             get_realm("zulip"),
             True,
         )
+
+        lear_realm = get_realm("lear")
+        do_set_realm_property(lear_realm, "enable_spectator_access", True, acting_user=None)
+        self.make_stream(
+            "web_public_stream",
+            realm=lear_realm,
+            is_web_public=True,
+        )
         self.do_test_session(
-            self.mit_user("sipbtest"), lambda: delete_all_user_sessions(), get_realm("zephyr"), True
+            self.lear_user("cordelia"),
+            lambda: delete_all_user_sessions(),
+            lear_realm,
+            True,
         )
 
     def test_delete_all_deactivated_user_sessions(self) -> None:
@@ -89,8 +101,8 @@ def test_delete_all_deactivated_user_sessions(self) -> None:
         self.client_post("/accounts/logout/")
         delete_all_deactivated_user_sessions()
         result = self.client_get("/")
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(result.url, "/login/")
+        self.assertEqual(result.status_code, 200)
+        self.assertTrue('is_spectator":true' in str(result.content))
 
         # Test nothing happens to an active user's session
         self.login("othello")
@@ -110,8 +122,8 @@ def test_delete_all_deactivated_user_sessions(self) -> None:
             [f"INFO:root:Deactivating session for deactivated user {user_profile_3.id}"],
         )
         result = self.client_get("/")
-        self.assertEqual(result.status_code, 302)
-        self.assertEqual(result.url, "/login/")
+        self.assertEqual(result.status_code, 200)
+        self.assertTrue('is_spectator":true' in str(result.content))
 
 
 class TestExpirableSessionVars(ZulipTestCase):
diff --git a/zerver/tests/test_urls.py b/zerver/tests/test_urls.py
--- a/zerver/tests/test_urls.py
+++ b/zerver/tests/test_urls.py
@@ -43,9 +43,8 @@ def test_public_urls(self) -> None:
                 "/en/accounts/login/",
                 "/ru/accounts/login/",
                 "/help/",
-            ],
-            302: [
-                # These 302 because they redirect to the spectator experience.
+                # Since web-public streams are enabled in this `zulip`
+                # instance, the public access experience is loaded directly.
                 "/",
                 "/en/",
                 "/ru/",