diff --git a/.vscode/settings.json b/.vscode/settings.json
index 06d6178f35b640c810350cb1f0d275b2b416e854..839b1bed5e071e933505494720e1529387d1c395 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -5,6 +5,9 @@
     "coreutils",
     "modelcluster",
     "pagedown",
+    "pytestmark",
+    "ratelimit",
+    "SIGNUP",
     "WAGTAILADMIN",
     "wagtailcore"
   ]
diff --git a/docs/setup/administrators/configuration.md b/docs/setup/administrators/configuration.md
index 93f93d8aac705dd80cf46af895d0ea407b9910de..0e13424a6c33f47047f47074e8f80d1cd3095ea8 100644
--- a/docs/setup/administrators/configuration.md
+++ b/docs/setup/administrators/configuration.md
@@ -77,11 +77,11 @@ This determines the length of time for which the user will remain logged in. The
 
 ### If users should be able to register accounts without first creating applications
 
-    ENABLE_REGISTRATION_WITHOUT_APPLICATION = env.bool('ENABLE_REGISTRATION_WITHOUT_APPLICATION', False)
+    ENABLE_PUBLIC_SIGNUP = env.bool('ENABLE_PUBLIC_SIGNUP', True)
 
 ### If users are forced to log in before creating applications
 
-    FORCE_LOGIN_FOR_APPLICATION = env.bool('FORCE_LOGIN_FOR_APPLICATION', False)
+    FORCE_LOGIN_FOR_APPLICATION = env.bool('FORCE_LOGIN_FOR_APPLICATION', True)
 
 ### Set the allowed file extension for all uploads fields.
 
diff --git a/hypha/apply/dashboard/views.py b/hypha/apply/dashboard/views.py
index 6e7f771a2e16a14a64b4db6b8351f7b73feb1c5c..41823412a5cee38bef482c76c4e89995c5626ffd 100644
--- a/hypha/apply/dashboard/views.py
+++ b/hypha/apply/dashboard/views.py
@@ -1,6 +1,6 @@
 from django.conf import settings
 from django.db.models import Count
-from django.http import HttpResponseRedirect
+from django.http import HttpResponseForbidden, HttpResponseRedirect
 from django.shortcuts import render
 from django.urls import reverse, reverse_lazy
 from django.views.generic import TemplateView
@@ -843,3 +843,14 @@ class DashboardView(ViewDispatcher):
     applicant_view = ApplicantDashboardView
     finance_view = FinanceDashboardView
     contracting_view = ContractingDashboardView
+
+    def dispatch(self, request, *args, **kwargs):
+        response = super().dispatch(request, *args, **kwargs)
+
+        # Handle the case when there is no dashboard for the user
+        # and redirect them to the home page of apply site.
+        # Suggestion: create a dedicated dashboard for user without any role.
+        if isinstance(response, HttpResponseForbidden):
+            return HttpResponseRedirect("/")
+
+        return response
diff --git a/hypha/apply/funds/models/submissions.py b/hypha/apply/funds/models/submissions.py
index ae0a1f8a573db084fe74783e7cd0c9b3d447713c..5af8a58e67b19b5b74dca2c099c33d52bea3c35f 100644
--- a/hypha/apply/funds/models/submissions.py
+++ b/hypha/apply/funds/models/submissions.py
@@ -547,11 +547,11 @@ class ApplicationSubmission(
     def ensure_user_has_account(self):
         if self.user and self.user.is_authenticated:
             self.form_data["email"] = self.user.email
-            self.form_data["full_name"] = self.user.get_full_name()
-            # Ensure applying user should have applicant role
-            if not self.user.is_applicant:
-                applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME)
-                self.user.groups.add(applicant_group)
+            if name := self.user.get_full_name():
+                self.form_data["full_name"] = name
+            else:
+                # user doesn't have name set, so use the one from the form
+                self.user.full_name = self.form_data["full_name"]
                 self.user.save()
         else:
             # Rely on the form having the following must include fields (see blocks.py)
@@ -564,11 +564,6 @@ class ApplicationSubmission(
                 self.user, _ = User.objects.get_or_create(
                     email=email, defaults={"full_name": full_name}
                 )
-                # Ensure applying user should have applicant role
-                if not self.user.is_applicant:
-                    applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME)
-                    self.user.groups.add(applicant_group)
-                    self.user.save()
             else:
                 self.user, _ = User.objects.get_or_create_and_notify(
                     email=email,
@@ -576,6 +571,12 @@ class ApplicationSubmission(
                     defaults={"full_name": full_name},
                 )
 
+        # Make sure the user is in the applicant group
+        if not self.user.is_applicant:
+            applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME)
+            self.user.groups.add(applicant_group)
+            self.user.save()
+
     def get_from_parent(self, attribute):
         try:
             return getattr(self.round.specific, attribute)
diff --git a/hypha/apply/funds/models/utils.py b/hypha/apply/funds/models/utils.py
index f7267b5c24131f4bd02b27baeeb421c6314f8bb6..dd52fbe334097e54ea86cd1a07eedee8f6693994 100644
--- a/hypha/apply/funds/models/utils.py
+++ b/hypha/apply/funds/models/utils.py
@@ -1,4 +1,5 @@
 from django.db import models
+from django.shortcuts import redirect
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from wagtail.admin.panels import (
@@ -130,7 +131,7 @@ class WorkflowStreamForm(WorkflowHelpers, AbstractStreamForm):  # type: ignore
                 source=form_submission,
             )
 
-        return super().render_landing_page(request, form_submission, *args, **kwargs)
+        return redirect("apply:submissions:success", pk=form_submission.id)
 
     content_panels = AbstractStreamForm.content_panels + [
         FieldPanel("workflow_name"),
diff --git a/hypha/apply/funds/templates/funds/lab_type_landing.html b/hypha/apply/funds/templates/funds/lab_type_landing.html
deleted file mode 100644
index b13d2c423b3ce21c2514adc6f1d628cfadf9eec0..0000000000000000000000000000000000000000
--- a/hypha/apply/funds/templates/funds/lab_type_landing.html
+++ /dev/null
@@ -1,4 +0,0 @@
-{% extends "funds/application_base_landing.html" %}
-{% load wagtailcore_tags wagtailsettings_tags %}
-
-{% block extra_text %}{{ settings.funds.ApplicationSettings.extra_text_lab|richtext }}{% endblock %}
diff --git a/hypha/apply/funds/templates/funds/round_landing.html b/hypha/apply/funds/templates/funds/round_landing.html
deleted file mode 100644
index 0ed5e7f0e678b2c322fe7499bc599a42c822b686..0000000000000000000000000000000000000000
--- a/hypha/apply/funds/templates/funds/round_landing.html
+++ /dev/null
@@ -1,3 +0,0 @@
-{% extends "funds/application_base_landing.html" %}
-
-{% block page_title %}{{ page.get_parent.title }}{% endblock %}
diff --git a/hypha/apply/funds/templates/funds/application_base_landing.html b/hypha/apply/funds/templates/funds/submission-success.html
similarity index 59%
rename from hypha/apply/funds/templates/funds/application_base_landing.html
rename to hypha/apply/funds/templates/funds/submission-success.html
index 1097899405aa599618c02bce4d53bf90da8b76f0..b032625997f310680e55861d2b1220e6c1afde70 100644
--- a/hypha/apply/funds/templates/funds/application_base_landing.html
+++ b/hypha/apply/funds/templates/funds/submission-success.html
@@ -29,25 +29,46 @@
             </p>
 
             {% with email_context=page.specific %}
-                <p>{{ email_context.confirmation_text_extra|urlize }}</p>
+                {% if email_context.confirmation_text_extra %}
+                    <p data-testid="db-confirmation-text-extra">{{ email_context.confirmation_text_extra|urlize }}</p>
+                {% endif %}
             {% endwith %}
 
-            {% block extra_text %}
-                <div class="prose">
+            {% if form_submission.round and settings.funds.ApplicationSettings.extra_text_round %}
+                <div class="prose" data-testid="db-extra-text">
                     {{ settings.funds.ApplicationSettings.extra_text_round|richtext }}
                 </div>
-            {% endblock %}
+            {% elif settings.funds.ApplicationSettings.extra_text_lab %}
+                <div class="prose" data-testid="db-extra-text">
+                    {{ settings.funds.ApplicationSettings.extra_text_lab|richtext }}
+                </div>
+            {% endif %}
 
         {% endif %}
 
-        <div class="mt-4">
-            {% if request.user.is_authenticated %}
+        <div class="mt-4 space-x-2">
+            {% if request.user.is_authenticated and request.user.can_access_dashboard%}
                 <a
                     class="button button--primary"
                     href="{% url 'dashboard:dashboard' %}"
                 >
                     {% trans "Go to your dashboard" %}
                 </a>
+                {% if form_submission.status == 'draft' %}
+                    <a
+                        class="button button--secondary"
+                        href="{% url 'apply:submissions:edit' form_submission.id %}"
+                    >
+                        {% trans "Continue editing" %}
+                    </a>
+                {% else %}
+                    <a
+                        class="button button--secondary"
+                        href="{% url 'apply:submissions:detail' form_submission.id %}"
+                    >
+                        {% trans "View your submission" %}
+                    </a>
+                {% endif %}
             {% else %}
                 <a
                     class="button button--primary"
diff --git a/hypha/apply/funds/tests/test_models.py b/hypha/apply/funds/tests/test_models.py
index 6259dc7afbb7e39cbcd9947a89fac3db90b11c39..530217bc472acf4eba123601e44356af0bbc69ea 100644
--- a/hypha/apply/funds/tests/test_models.py
+++ b/hypha/apply/funds/tests/test_models.py
@@ -243,8 +243,10 @@ class TestFormSubmission(TestCase):
             response = page.serve(request)
 
         if not ignore_errors:
-            # Check the data we submit is correct
-            self.assertNotContains(response, "errors")
+            # check it is redirected
+            self.assertEqual(response.status_code, 302)
+            # check "success" is present in the redirect url location
+            self.assertIn("success", response.url)
         return response
 
     def test_workflow_and_draft(self):
@@ -345,7 +347,7 @@ class TestFormSubmission(TestCase):
         self.assertEqual(self.User.objects.count(), 2)
 
         response = self.submit_form(email="", name="", user=user, ignore_errors=True)
-        self.assertEqual(response.status_code, 200)
+        assert response.status_code == 302 and "success" in response.url
 
         # Lead + applicant
         self.assertEqual(self.User.objects.count(), 2)
diff --git a/hypha/apply/funds/tests/test_views.py b/hypha/apply/funds/tests/test_views.py
index 054c9df1121a24f2843f868a1c07ce05a5d9c4fb..4cc8d634572ac71df2b13ea64ec1db8024d5164d 100644
--- a/hypha/apply/funds/tests/test_views.py
+++ b/hypha/apply/funds/tests/test_views.py
@@ -2,6 +2,7 @@ import re
 from datetime import timedelta
 
 from bs4 import BeautifulSoup
+from django.conf import settings
 from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import PermissionDenied
 from django.http import Http404
@@ -1644,7 +1645,7 @@ class TestAnonSubmissionFileView(BaseSubmissionFileViewTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(len(response.redirect_chain), 2)
         for path, _ in response.redirect_chain:
-            self.assertIn(reverse("users_public:login"), path)
+            self.assertIn(reverse(settings.LOGIN_URL), path)
 
 
 class BaseProjectDeleteTestCase(BaseViewTestCase):
diff --git a/hypha/apply/funds/urls.py b/hypha/apply/funds/urls.py
index 87809e3ec21201d688c934a7e032e91c34b2ac87..6b11739e0634c7961615f30e161f836ea0efc769 100644
--- a/hypha/apply/funds/urls.py
+++ b/hypha/apply/funds/urls.py
@@ -27,6 +27,7 @@ from .views import (
     SubmissionSealedView,
     SubmissionStaffFlaggedView,
     SubmissionUserFlaggedView,
+    submission_success,
 )
 from .views_beta import (
     bulk_archive_submissions,
@@ -74,6 +75,7 @@ app_name = "funds"
 submission_urls = (
     [
         path("", SubmissionOverviewView.as_view(), name="overview"),
+        path("success/<int:pk>/", submission_success, name="success"),
         path("all/", SubmissionListView.as_view(), name="list"),
         path("all-beta/", submission_all_beta, name="list-beta"),
         path("all-beta/bulk_archive/", bulk_archive_submissions, name="bulk-archive"),
diff --git a/hypha/apply/funds/views.py b/hypha/apply/funds/views.py
index b942613db05e05c7e5718ac7cc3b37ac67ecaf61..5725d425c20bab81b7b8345577734272ac3d9d5a 100644
--- a/hypha/apply/funds/views.py
+++ b/hypha/apply/funds/views.py
@@ -13,7 +13,7 @@ from django.contrib.humanize.templatetags.humanize import intcomma
 from django.core.exceptions import PermissionDenied
 from django.db.models import Count, F, Q
 from django.http import FileResponse, Http404, HttpResponse, HttpResponseRedirect
-from django.shortcuts import get_object_or_404
+from django.shortcuts import get_object_or_404, render
 from django.urls import reverse_lazy
 from django.utils import timezone
 from django.utils.decorators import method_decorator
@@ -123,6 +123,17 @@ from .workflow import (
 User = get_user_model()
 
 
+def submission_success(request, pk):
+    submission = get_object_or_404(ApplicationSubmission, pk=pk)
+    return render(
+        request,
+        "funds/submission-success.html",
+        {
+            "form_submission": submission,
+        },
+    )
+
+
 class SubmissionStatsMixin:
     def get_context_data(self, **kwargs):
         submissions = ApplicationSubmission.objects.exclude_draft()
diff --git a/hypha/apply/projects/tests/test_settings.py b/hypha/apply/projects/tests/test_settings.py
index b006647fa687c296692cf97ab098e946ee3a7efe..58b7b1175d946370f4bf953ae986863bee0b4036 100644
--- a/hypha/apply/projects/tests/test_settings.py
+++ b/hypha/apply/projects/tests/test_settings.py
@@ -1,15 +1,18 @@
-from django.test import TestCase, override_settings
+# Fix me, for details on why this is commented out, see
+# https://github.com/HyphaApp/hypha/issues/3606
 
-from hypha.apply.users.tests.factories import StaffFactory
+# from django.test import TestCase, override_settings
 
+# from hypha.apply.users.tests.factories import StaffFactory
 
-class TestProjectFeatureFlag(TestCase):
-    @override_settings(PROJECTS_ENABLED=False)
-    def test_urls_404_when_turned_off(self):
-        self.client.force_login(StaffFactory())
 
-        response = self.client.get("/apply/projects/", follow=True)
-        self.assertEqual(response.status_code, 404)
+# class TestProjectFeatureFlag(TestCase):
+#     @override_settings(PROJECTS_ENABLED=False)
+#     def test_urls_404_when_turned_off(self):
+#         self.client.force_login(StaffFactory())
 
-        response = self.client.get("/apply/projects/1/", follow=True)
-        self.assertEqual(response.status_code, 404)
+#         response = self.client.get("/apply/projects/", follow=True)
+#         self.assertEqual(response.status_code, 404)
+
+#         response = self.client.get("/apply/projects/1/", follow=True)
+#         self.assertEqual(response.status_code, 404)
diff --git a/hypha/apply/projects/tests/test_views.py b/hypha/apply/projects/tests/test_views.py
index 688f9d2119feefaac2ef88bafd5f54c3dd2c57b7..480e767079a55fb4d219abf13f265a25a7240b99 100644
--- a/hypha/apply/projects/tests/test_views.py
+++ b/hypha/apply/projects/tests/test_views.py
@@ -2,6 +2,7 @@ import json
 from io import BytesIO
 
 from dateutil.relativedelta import relativedelta
+from django.conf import settings
 from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import PermissionDenied
 from django.test import RequestFactory, TestCase, override_settings
@@ -771,7 +772,7 @@ class TestAnonPacketView(BasePacketFileViewTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(len(response.redirect_chain), 2)
         for path, _ in response.redirect_chain:
-            self.assertIn(reverse("users_public:login"), path)
+            self.assertIn(reverse(settings.LOGIN_URL), path)
 
 
 class TestProjectDetailApprovalView(TestCase):
diff --git a/hypha/apply/stream_forms/models.py b/hypha/apply/stream_forms/models.py
index b20e5875dde3936c8a47b6bb32647a6b6998888f..27c0f0cf4a4676a81b5fe71acc0eb4affd60a039 100644
--- a/hypha/apply/stream_forms/models.py
+++ b/hypha/apply/stream_forms/models.py
@@ -74,9 +74,15 @@ class BaseStreamForm:
                     "You are logged in so this information is fetched from your user account."
                 )
                 if isinstance(block, FullNameBlock) and user and user.is_authenticated:
-                    field_from_block.disabled = True
-                    field_from_block.initial = user.full_name
-                    field_from_block.help_text = disabled_help_text
+                    if user.full_name:
+                        field_from_block.disabled = True
+                        field_from_block.initial = user.full_name
+                        field_from_block.help_text = disabled_help_text
+                    else:
+                        field_from_block.help_text = _(
+                            "You are logged in but your user account does not have a "
+                            "full name. We'll update your user account with the name you provide here."
+                        )
                 if isinstance(block, EmailBlock) and user and user.is_authenticated:
                     field_from_block.disabled = True
                     field_from_block.initial = user.email
diff --git a/hypha/apply/urls.py b/hypha/apply/urls.py
index 82ec8597497719a5014a39ce792f2b2209980271..35fe9a8e322e04c604b4630836672462989827e8 100644
--- a/hypha/apply/urls.py
+++ b/hypha/apply/urls.py
@@ -21,9 +21,7 @@ urlpatterns = [
     # page and advances user to download backup code page.
     path(
         "account/two_factor/setup/complete/",
-        RedirectView.as_view(
-            url=reverse_lazy("users:backup_tokens_password"), permanent=False
-        ),
+        RedirectView.as_view(url=reverse_lazy("users:backup_tokens"), permanent=False),
         name="two_factor:setup_complete",
     ),
     path("", include(tf_urls, "two_factor")),
diff --git a/hypha/apply/users/forms.py b/hypha/apply/users/forms.py
index 13dcd722e7c1e7baffd5d66aa7058f41de46314f..4808e19f7333a0559b93b4d4128389cf09b816ec 100644
--- a/hypha/apply/users/forms.py
+++ b/hypha/apply/users/forms.py
@@ -23,6 +23,33 @@ class CustomAuthenticationForm(AuthenticationForm):
             )
 
 
+class PasswordlessAuthForm(forms.Form):
+    """Form to collect the email for passwordless login or signup (if enabled)
+
+    Adds login extra text and user content to the form, if configured in the
+    wagtail auth settings.
+    """
+
+    email = forms.EmailField(
+        label=_("Email Address"),
+        required=True,
+        max_length=254,
+        widget=forms.EmailInput(attrs={"autofocus": True, "autocomplete": "email"}),
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.request = kwargs.pop("request", None)
+        self.user_settings = AuthSettings.load(request_or_site=self.request)
+        self.extra_text = self.user_settings.extra_text
+        if self.user_settings.consent_show:
+            self.fields["consent"] = forms.BooleanField(
+                label=self.user_settings.consent_text,
+                help_text=self.user_settings.consent_help,
+                required=True,
+            )
+
+
 class CustomUserAdminFormBase:
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -65,18 +92,21 @@ class ProfileForm(forms.ModelForm):
         fields = ["full_name", "email", "slack"]
 
     def __init__(self, *args, **kwargs):
+        self.request = kwargs.pop("request", None)
         super().__init__(*args, **kwargs)
         if not self.instance.is_apply_staff_or_finance:
             del self.fields["slack"]
 
-        if not self.instance.has_usable_password():
-            # User is registered with oauth - no password change allowed
-            email_field = self.fields["email"]
-            email_field.disabled = True
-            email_field.required = False
-            email_field.help_text = _(
-                "You are registered using OAuth, please contact an admin if you need to change your email address."
-            )
+        if self.request is not None:
+            backend = self.request.session["_auth_user_backend"]
+            if "social_core.backends" in backend:
+                # User is registered with oauth - no password change allowed
+                email_field = self.fields["email"]
+                email_field.disabled = True
+                email_field.required = False
+                email_field.help_text = _(
+                    "You are registered using OAuth, please contact an admin if you need to change your email address."
+                )
 
     def clean_slack(self):
         slack = self.cleaned_data["slack"]
@@ -140,21 +170,22 @@ class EmailChangePasswordForm(forms.Form):
 
 
 class TWOFAPasswordForm(forms.Form):
-    password = forms.CharField(
-        label=_("Please type your password to confirm"),
-        strip=False,
-        widget=forms.PasswordInput(attrs={"autofocus": True}),
+    confirmation_text = forms.CharField(
+        label=_('To proceed, type "disable" below and then click on "confirm":'),
+        strip=True,
+        # add widget with autofocus to avoid password autofill
+        widget=forms.TextInput(attrs={"autofocus": True, "autocomplete": "off"}),
     )
 
     def __init__(self, user, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.user = user
 
-    def clean_password(self):
-        password = self.cleaned_data["password"]
-        if not self.user.check_password(password):
+    def clean_confirmation_text(self):
+        text = self.cleaned_data["confirmation_text"]
+        if text != "disable":
             raise forms.ValidationError(
-                _("Incorrect password. Please try again."),
-                code="password_incorrect",
+                _("Incorrect input."),
+                code="confirmation_text_incorrect",
             )
-        return password
+        return text
diff --git a/hypha/apply/users/middleware.py b/hypha/apply/users/middleware.py
index fd11afec3749570a842a75c533280b4cd9b72beb..d38207b9d744153a72a9c82ab8da536353c53ab3 100644
--- a/hypha/apply/users/middleware.py
+++ b/hypha/apply/users/middleware.py
@@ -1,17 +1,33 @@
+import logging
+
 from django.conf import settings
-from django.shortcuts import redirect
+from django.core.exceptions import MiddlewareNotUsed
+from django.urls import set_urlconf
+from django.utils.log import log_response
+from django.utils.translation import gettext_lazy as _
 from social_core.exceptions import AuthForbidden
 from social_django.middleware import (
     SocialAuthExceptionMiddleware as _SocialAuthExceptionMiddleware,
 )
 
-ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS = [
-    "login/",
-    "logout/",
-    "account/",
+from hypha.apply.users.views import mfa_failure_view
+
+logger = logging.getLogger("django.security.two_factor")
+
+TWO_FACTOR_EXEMPTED_PATH_PREFIXES = [
+    "/auth/",
+    "/login/",
+    "/logout/",
+    "/account/",
+    "/apply/submissions/success/",
 ]
 
 
+def get_page_path(wagtail_page):
+    _, _, page_path = wagtail_page.get_url_parts()
+    return page_path
+
+
 class SocialAuthExceptionMiddleware(_SocialAuthExceptionMiddleware):
     """
     Wrapper around SocialAuthExceptionMiddleware to customise messages
@@ -31,31 +47,74 @@ class TwoFactorAuthenticationMiddleware:
     To activate this middleware set env variable ENFORCE_TWO_FACTOR as True.
 
     This will redirect all request from unverified users to enable 2FA first.
-    Except the request made on the url paths listed in ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS.
+    Except the request made on the url paths listed in TWO_FACTOR_EXEMPTED_PATH_PREFIXES.
     """
 
+    reason = _("Two factor authentication required")
+
     def __init__(self, get_response):
+        if not settings.ENFORCE_TWO_FACTOR:
+            raise MiddlewareNotUsed()
+
         self.get_response = get_response
 
-    def is_path_allowed(self, path):
-        for sub_path in ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS:
-            if sub_path in path:
+    def _accept(self, request):
+        return self.get_response(request)
+
+    def _reject(self, request, reason):
+        set_urlconf("hypha.apply.urls")
+        response = mfa_failure_view(request, reason=reason)
+        log_response(
+            "Forbidden (%s): %s",
+            reason,
+            request.path,
+            response=response,
+            request=request,
+            logger=logger,
+        )
+        return response
+
+    def whitelisted_paths(self, path):
+        if path == "/":
+            return True
+
+        for sub_path in TWO_FACTOR_EXEMPTED_PATH_PREFIXES:
+            if path.startswith(sub_path):
                 return True
         return False
 
+    def get_urls_open_rounds(self):
+        from hypha.apply.funds.models import ApplicationBase
+
+        return map(
+            get_page_path, ApplicationBase.objects.order_by_end_date().specific()
+        )
+
+    def get_urls_open_labs(self):
+        from hypha.apply.funds.models import LabBase
+
+        return map(
+            get_page_path,
+            LabBase.objects.public().live().specific(),
+        )
+
     def __call__(self, request):
+        if self.whitelisted_paths(request.path):
+            return self._accept(request)
+
         # code to execute before the view
         user = request.user
-        if settings.ENFORCE_TWO_FACTOR:
-            if (
-                user.is_authenticated
-                and not user.is_verified()
-                and not user.social_auth.exists()
-            ):
-                if not self.is_path_allowed(request.path):
-                    return redirect("/account/two_factor/required/")
-
-        response = self.get_response(request)
-
-        # code to execute after view
-        return response
+        if user.is_authenticated:
+            if user.social_auth.exists() or user.is_verified():
+                return self._accept(request)
+
+            # Allow rounds and lab detail pages
+            if request.path in self.get_urls_open_rounds():
+                return self._accept(request)
+
+            if request.path in self.get_urls_open_labs():
+                return self._accept(request)
+
+            return self._reject(request, self.reason)
+
+        return self._accept(request)
diff --git a/hypha/apply/users/migrations/0021_pendingsignup.py b/hypha/apply/users/migrations/0021_pendingsignup.py
new file mode 100644
index 0000000000000000000000000000000000000000..a40e95239c435f609af7dce4433f1f0f4bb9ce6d
--- /dev/null
+++ b/hypha/apply/users/migrations/0021_pendingsignup.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.2.21 on 2023-09-12 08:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("users", "0020_auto_20230625_1825"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="PendingSignup",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("email", models.EmailField(max_length=254, unique=True)),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("modified", models.DateTimeField(auto_now=True)),
+                ("token", models.CharField(max_length=255, unique=True)),
+            ],
+            options={
+                "verbose_name_plural": "Pending signups",
+                "ordering": ("created",),
+            },
+        ),
+    ]
diff --git a/hypha/apply/users/migrations/0022_confirmaccesstoken.py b/hypha/apply/users/migrations/0022_confirmaccesstoken.py
new file mode 100644
index 0000000000000000000000000000000000000000..20b34b7c3e6efb5dc744c87201b79c686f65cc74
--- /dev/null
+++ b/hypha/apply/users/migrations/0022_confirmaccesstoken.py
@@ -0,0 +1,42 @@
+# Generated by Django 3.2.22 on 2023-10-31 06:59
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("users", "0021_pendingsignup"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="ConfirmAccessToken",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("token", models.CharField(max_length=6)),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("modified", models.DateTimeField(auto_now=True)),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name_plural": "Confirm Access Tokens",
+                "ordering": ("modified",),
+            },
+        ),
+    ]
diff --git a/hypha/apply/users/models.py b/hypha/apply/users/models.py
index 44ee07b515f51bc6ec66a59e1c23e2fedb6eba8e..fbaab70c3351111063b05a927c63465b16d7df9e 100644
--- a/hypha/apply/users/models.py
+++ b/hypha/apply/users/models.py
@@ -1,6 +1,6 @@
 from django.conf import settings
 from django.contrib.auth.hashers import make_password
-from django.contrib.auth.models import AbstractUser, BaseUserManager, Group
+from django.contrib.auth.models import AbstractUser, BaseUserManager
 from django.core import exceptions
 from django.db import IntegrityError, models
 from django.db.models.constants import LOOKUP_SEP
@@ -23,7 +23,11 @@ from .groups import (
     STAFF_GROUP_NAME,
     TEAMADMIN_GROUP_NAME,
 )
-from .utils import get_user_by_email, is_user_already_registered, send_activation_email
+from .utils import (
+    get_user_by_email,
+    is_user_already_registered,
+    send_activation_email,
+)
 
 
 class UserQuerySet(models.QuerySet):
@@ -185,10 +189,6 @@ class UserManager(BaseUserManager.from_queryset(UserQuerySet)):
             send_activation_email(user, site, redirect_url=redirect_url)
             _created = True
 
-        applicant_group = Group.objects.get(name=APPLICANT_GROUP_NAME)
-        if applicant_group not in user.groups.all():
-            user.groups.add(applicant_group)
-            user.save()
         return user, _created
 
 
@@ -285,6 +285,18 @@ class User(AbstractUser):
             and not self.groups.filter(name=APPROVER_GROUP_NAME).exists()
         )
 
+    @cached_property
+    def can_access_dashboard(self):
+        return (
+            self.is_apply_staff
+            or self.is_reviewer
+            or self.is_partner
+            or self.is_community_reviewer
+            or self.is_finance
+            or self.is_contracting
+            or self.is_applicant
+        )
+
     @cached_property
     def is_finance_level_2(self):
         # disable finance2 user if invoice flow in not extended
@@ -362,3 +374,44 @@ class AuthSettings(BaseGenericSetting):
             _("Register form customizations"),
         ),
     ]
+
+
+class PendingSignup(models.Model):
+    """This model tracks pending passwordless self-signups, and is used to
+    generate a  one-time use URLfor each signup.
+
+    The URL is sent to the user via email, and when they click on it, they are
+    redirected to the registration page, where a new is created.
+
+    Once the user is created, the PendingSignup instance is deleted.
+    """
+
+    email = models.EmailField(unique=True)
+    created = models.DateTimeField(auto_now_add=True)
+    modified = models.DateTimeField(auto_now=True)
+    token = models.CharField(max_length=255, unique=True)
+
+    def __str__(self):
+        return f"{self.email} ({self.created})"
+
+    class Meta:
+        ordering = ("created",)
+        verbose_name_plural = "Pending signups"
+
+
+class ConfirmAccessToken(models.Model):
+    """
+    Once the user is created, the PendingSignup instance is deleted.
+    """
+
+    token = models.CharField(max_length=6)
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+    created = models.DateTimeField(auto_now_add=True)
+    modified = models.DateTimeField(auto_now=True)
+
+    def __str__(self):
+        return f"ConfirmAccessToken: {self.user.email} ({self.created})"
+
+    class Meta:
+        ordering = ("modified",)
+        verbose_name_plural = "Confirm Access Tokens"
diff --git a/hypha/apply/users/services.py b/hypha/apply/users/services.py
new file mode 100644
index 0000000000000000000000000000000000000000..8762aa00c2be7f7f98cdb9d85da5258de084d8c4
--- /dev/null
+++ b/hypha/apply/users/services.py
@@ -0,0 +1,162 @@
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.http import HttpRequest
+from django.urls import reverse
+from django.utils.crypto import get_random_string
+from django.utils.encoding import force_bytes
+from django.utils.http import urlsafe_base64_encode
+from wagtail.models import Site
+
+from hypha.core.mail import MarkdownMail
+
+from .models import PendingSignup
+from .tokens import PasswordlessLoginTokenGenerator, PasswordlessSignupTokenGenerator
+from .utils import get_redirect_url, get_user_by_email
+
+User = get_user_model()
+
+
+class PasswordlessAuthService:
+    login_token_generator_class = PasswordlessLoginTokenGenerator
+    signup_token_generator_class = PasswordlessSignupTokenGenerator
+
+    next_url = None
+
+    def __init__(self, request: HttpRequest, redirect_field_name: str = "next") -> None:
+        self.redirect_field_name = redirect_field_name
+        self.next_url = get_redirect_url(request, self.redirect_field_name)
+        self.request = request
+        self.site = Site.find_for_request(request)
+
+    def _get_login_path(self, user):
+        token = self.login_token_generator_class().make_token(user)
+        uid = urlsafe_base64_encode(force_bytes(user.pk))
+        login_path = reverse(
+            "users:do_passwordless_login", kwargs={"uidb64": uid, "token": token}
+        )
+
+        if self.next_url:
+            login_path = f"{login_path}?next={self.next_url}"
+
+        return login_path
+
+    def _get_signup_path(self, signup_obj):
+        token = self.signup_token_generator_class().make_token(user=signup_obj)
+        uid = urlsafe_base64_encode(force_bytes(signup_obj.pk))
+
+        signup_path = reverse(
+            "users:do_passwordless_signup", kwargs={"uidb64": uid, "token": token}
+        )
+
+        if self.next_url:
+            signup_path = f"{signup_path}?next={self.next_url}"
+
+        return signup_path
+
+    def get_email_context(self) -> dict:
+        return {
+            "org_long_name": settings.ORG_LONG_NAME,
+            "org_email": settings.ORG_EMAIL,
+            "org_short_name": settings.ORG_SHORT_NAME,
+            "site": self.site,
+        }
+
+    def send_email_no_account_found(self, to):
+        context = self.get_email_context()
+        subject = "Login attempt at {org_long_name}".format(**context)
+        # Force subject to a single line to avoid header-injection issues.
+        subject = "".join(subject.splitlines())
+
+        email = MarkdownMail("users/emails/passwordless_login_no_account_found.md")
+        email.send(
+            to=to,
+            subject=subject,
+            from_email=settings.DEFAULT_FROM_EMAIL,
+            context=context,
+        )
+
+    def send_login_email(self, user):
+        login_path = self._get_login_path(user)
+        timeout_minutes = self.login_token_generator_class().TIMEOUT // 60
+
+        context = self.get_email_context()
+        context.update(
+            {
+                "user": user,
+                "is_active": user.is_active,
+                "name": user.get_full_name(),
+                "username": user.get_username(),
+                "login_path": login_path,
+                "timeout_minutes": timeout_minutes,
+            }
+        )
+
+        subject = "Login to {username} at {org_long_name}".format(**context)
+        # Force subject to a single line to avoid header-injection issues.
+        subject = "".join(subject.splitlines())
+
+        email = MarkdownMail("users/emails/passwordless_login_email.md")
+        email.send(
+            to=user.email,
+            subject=subject,
+            from_email=settings.DEFAULT_FROM_EMAIL,
+            context=context,
+        )
+
+    def send_new_account_login_email(self, signup_obj):
+        signup_path = self._get_signup_path(signup_obj)
+        timeout_minutes = self.login_token_generator_class().TIMEOUT // 60
+
+        context = self.get_email_context()
+        context.update(
+            {
+                "signup_path": signup_path,
+                "timeout_minutes": timeout_minutes,
+            }
+        )
+
+        subject = "Welcome to {org_long_name}".format(**context)
+        # Force subject to a single line to avoid header-injection issues.
+        subject = "".join(subject.splitlines())
+
+        email = MarkdownMail("users/emails/passwordless_new_account_login.md")
+        email.send(
+            to=signup_obj.email,
+            subject=subject,
+            from_email=settings.DEFAULT_FROM_EMAIL,
+            context=context,
+        )
+
+    def initiate_login_signup(self, email: str) -> None:
+        """Send a passwordless login/signup email.
+
+        If the user exists, send a login email. If the user does not exist, send a
+        signup invite email.
+
+        Args:
+            email: Email address to send the email to.
+            request: HttpRequest object.
+            next_url: URL to redirect to after login/signup. Defaults to None.
+
+        Returns:
+            None
+        """
+        if user := get_user_by_email(email):
+            self.send_login_email(user)
+            return
+
+        # No account found
+        if not settings.ENABLE_PUBLIC_SIGNUP:
+            self.send_email_no_account_found(email)
+            return
+
+        # Self registration is enabled
+        signup_obj, _ = PendingSignup.objects.update_or_create(
+            email=email,
+            defaults={
+                "token": get_random_string(32, "abcdefghijklmnopqrstuvwxyz0123456789")
+            },
+        )
+        self.send_new_account_login_email(signup_obj)
+
+        return True
diff --git a/hypha/apply/users/templates/elevate/elevate.html b/hypha/apply/users/templates/elevate/elevate.html
index 9a55046959590ced3e9d0930aa3768f83282b2f9..1a5dbd29c5358dbad817da7c1bb589127abccff0 100644
--- a/hypha/apply/users/templates/elevate/elevate.html
+++ b/hypha/apply/users/templates/elevate/elevate.html
@@ -1,34 +1,73 @@
 {% extends "base-apply.html" %}
-{% load i18n wagtailcore_tags %}
+{% load i18n wagtailcore_tags heroicons %}
 
 {% block title %}{% trans "Confirm access" %}{% endblock %}
 {% block body_class %}bg-white{% endblock %}
 
 {% block content %}
-    <div class="max-w-lg px-4 pt-4 mx-auto md:mt-5 md:py-4">
+    <div class="max-w-md px-4 pt-4 mx-auto md:mt-5 md:py-4">
 
-        <form class="form" method="post" action="./" class="px-4 pt-4">
-            {% csrf_token %}
-            <h2 class="text-2xl">{% trans "Confirm access" %}</h2>
+        <h2 class="text-2xl text-center">{% trans "Confirm access" %}</h2>
 
-            <p class="px-3 py-2 bg-orange-100 rounded mb-4">
-                Signed in as <strong>{{ request.user }} ({{ request.user.email }})</strong>
-            </p>
+        <p class="text-center mb-4">
+            Signed in as <strong>{% if request.user.full_name %} {{ request.user.full_name }} ({{ request.user.email }}) {% else %}{{ request.user.email }} {% endif %}</strong>
+        </p>
+
+        <section id="section-form">
+
+            {% if request.user.has_usable_password %}
+                <form
+                    class="form form--error-inline mb-4 px-4 pt-4 border rounded-sm bg-gray-50"
+                    method="post"
+                    action="./"
+                    data-test-id="section-password-input"
+                    id="form-password-input"
+                >
+                    {% for field in form %}
+                        {% include "forms/includes/field.html" %}
+                    {% endfor %}
+
+                    <div class="form__group">
+                        <button class="button button--primary" type="submit">{% trans "Confirm" %}</button>
+                    </div>
+                </form>
+            {% else %}
+                <section data-test-id="section-confirm" id="confirm-code-input" class="mb-4 px-4 pt-4 text-center">
+
+                    <button
+                        class="button button--primary"
+                        type="submit"
+                        hx-post="{% url 'users:elevate_send_confirm_access_email' %}{% if request.GET.next %}?next={{request.GET.next}}{% endif %}"
+                        hx-swap="outerHTML"
+                        hx-target="#confirm-code-input"
+                    >
+                        {% trans "Send a confirmation code to your email" %}
+                    </button>
+                </section>
+            {% endif %}
 
-            {% if form.non_field_errors %}
-                <div class="wrapper wrapper--error">{{ form.non_field_errors.as_text }}</div>
+            {% if request.user.has_usable_password %}
+                <section data-test-id="section-send-email" class="px-4 border pt-2 pb-4">
+                    <p>{% trans "Having problems?" %}</p>
+                    <ul class="list-disc ml-4">
+                        <li>
+                            <a
+                                class="m-0"
+                                type="submit"
+                                hx-post="{% url 'users:elevate_send_confirm_access_email' %}{% if request.GET.next %}?next={{request.GET.next}}{% endif %}"
+                                hx-target="#section-form"
+                            >
+                                {% trans "Send a confirmation code to your email" %}
+                            </a>
+                        </li>
+                    </ul>
+                </section>
             {% endif %}
 
-            {% for field in form %}
-                {% include "forms/includes/field.html" %}
-            {% endfor %}
+        </section>
 
-            <div class="form__group">
-                <button class="button button--primary" type="submit">{% trans "Confirm" %}</button>
-            </div>
-        </form>
 
-        <p class="text-xs text-center max-w-sm mt-8 text-gray-500 mx-auto">
+        <p class="text-xs text-center max-w-xs mt-8 text-gray-500 mx-auto leading-relaxed">
             {% blocktrans %}
                 <strong>Tip:</strong> You are entering sudo mode. After you've performed a sudo-protected
                 action, you'll only be asked to re-authenticate again after a few hours of inactivity.
diff --git a/hypha/apply/users/templates/two_factor/_base_focus.html b/hypha/apply/users/templates/two_factor/_base_focus.html
index 2a63a6db9b5c4f98a178ce0c12b6010155e33bf6..d53a7af9f9951f7d36cb0786b2ffd52c55e75a83 100644
--- a/hypha/apply/users/templates/two_factor/_base_focus.html
+++ b/hypha/apply/users/templates/two_factor/_base_focus.html
@@ -13,10 +13,12 @@
         {% endslot %}
         {% comment %} {% slot sub_heading %}{% trans "All submissions ready for discussion." %}{% endslot %} {% endcomment %}
 
-        <a href="{% url 'dashboard:dashboard' %}" class="button button--primary button--arrow-pixels-white" hx-boost='true'>
-            {% trans "Go to my dashboard" %}
-            <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg>
-        </a>
+        {% if user.can_access_dashboard %}
+            <a href="{% url 'dashboard:dashboard' %}" class="button button--primary button--arrow-pixels-white" hx-boost='true'>
+                {% trans "Go to my dashboard" %}
+                <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg>
+            </a>
+        {% endif %}
     {% endadminbar %}
 
     <div class="wrapper wrapper--inner-space-medium max-w-2xl  two-factor">
diff --git a/hypha/apply/users/templates/two_factor/_wizard_actions.html b/hypha/apply/users/templates/two_factor/_wizard_actions.html
index eaff592606c70899396093c95d943ef8af1c4fd3..4931f35879c1668653f7b17930babe08a6097b24 100644
--- a/hypha/apply/users/templates/two_factor/_wizard_actions.html
+++ b/hypha/apply/users/templates/two_factor/_wizard_actions.html
@@ -1,7 +1,7 @@
 {% load i18n %}
 
 {% if wizard.steps.current == 'token' %}
-    {% trans "Login" as button_text %}
+    {% trans "Submit" as button_text %}
 {% elif wizard.steps.current == 'generator' %}
     {% trans "Next" as button_text %}
 {% elif wizard.steps.current == 'welcome' %}
@@ -10,12 +10,22 @@
     {% trans "Next" as button_text %}
 {% endif %}
 
-<button type="submit" class="button button--primary">{{ button_text }}</button>
+<button
+    type="submit"
+    class="button button--primary mb-4"
+>
+    {{ button_text }}
+</button>
 
 <script>
-    var lbl = document.querySelector("label[for=id_generator-token]");
+    const lbl = document.querySelector("label[for=id_generator-token]");
+    const otpInput = document.querySelector("#id_token-otp_token");
     if (lbl) {
         lbl.textContent = "{% trans "Verification code" %}:";
     }
+    if(otpInput){
+        // set max-width to 6 characters
+        otpInput.style.maxWidth = "10ch";
+    }
 
 </script>
diff --git a/hypha/apply/users/templates/two_factor/core/backup_tokens.html b/hypha/apply/users/templates/two_factor/core/backup_tokens.html
index 297cd8503d7b596883c0986bce7de540be3f0348..ab0108d20862eca1f12acfa0039a777dbddbdcaf 100644
--- a/hypha/apply/users/templates/two_factor/core/backup_tokens.html
+++ b/hypha/apply/users/templates/two_factor/core/backup_tokens.html
@@ -14,7 +14,7 @@
                   cols="8"
                   rows="{{ device.token_set.count }}"
                   id="list-backup-tokens"
-                  class="border"
+                  class="font-mono pr-0 font-medium leading-tight bg-orange-100 resize-none"
         >{% for token in device.token_set.all %}{{ token.token }}{% if not forloop.last %}&#013;&#010;{% endif %}{% endfor %}</textarea>
 
         <form method="post" class="actions actions-footer">{% csrf_token %}{{ form }}
@@ -34,11 +34,14 @@
             <p class="hide-print">{% blocktrans %}Once done, acknowledge you have stored the codes securely and then click "Finish".{% endblocktrans %}</p>
             <div class="form">
                 <ul class="errorlist hidden error-action-agree"><li>Please confirm you have stored the codes securely below.</li></ul>
-                <div class="form__group form__group--checkbox">
+                <div class="form__item mb-4">
                     <input type="checkbox" id="action_agree" name="action_agree" value="action_agree">
-                    <label for="action_agree"> I have stored the backup codes securely.</label><br><br>
+                    <label for="action_agree"> I have stored the backup codes securely.</label>
                 </div>
-                <a class="btn btn-link btn-finish" href="{% url 'users:account' %}">{% trans "Finish" %}</a>
+                <div class="form__item">
+                    <a class="btn btn-link btn-finish" href="{% url 'users:account' %}">{% trans "Finish" %}</a>
+                </div>
+
             </div>
         </form>
     {% else %}
@@ -87,18 +90,21 @@
 
     {# Instantiate clipboard by passing a HTML element, uses clipboard.js #}
         var clipboardBtn = document.querySelector('.btn-copy-to-clipboard');
-        var clipboard = new ClipboardJS(clipboardBtn);
-        var tooltip = tippy(clipboardBtn, {
-            trigger: 'manual',
-            animation: 'fade'
-        });
+        if (clipboardBtn) {
+            var clipboard = new ClipboardJS(clipboardBtn);
+            var tooltip = tippy(clipboardBtn, {
+                trigger: 'manual',
+                animation: 'fade'
+            });
+
+            clipboard.on('success', function (e) {
+                tooltip.show();
+            });
+            clipboard.on('error', function (e) {
+                tooltip.setContent("Use ctrl/cmd + C to copy the backup codes.")
+                tooltip.show();
+            });
+        }
 
-        clipboard.on('success', function (e) {
-            tooltip.show();
-        });
-        clipboard.on('error', function (e) {
-            tooltip.setContent("Use ctrl/cmd + C to copy the backup codes.")
-            tooltip.show();
-        });
     </script>
 {% endblock %}
diff --git a/hypha/apply/users/templates/two_factor/core/setup.html b/hypha/apply/users/templates/two_factor/core/setup.html
index 8d9066ab8774f8388d62bba6040cb47dc591c24a..6cadae9cae5e4d72689fdd4a0ff7a1594caa2d7c 100644
--- a/hypha/apply/users/templates/two_factor/core/setup.html
+++ b/hypha/apply/users/templates/two_factor/core/setup.html
@@ -52,7 +52,7 @@
     {% include "two_factor/_wizard_forms.html" %}
 
     {# hidden submit button to enable [enter] key #}
-    <input type="submit" value="" class="d-none" />
+    <input type="submit" value="" class="hidden" />
 
     {% include "two_factor/_wizard_actions.html" %}
   </form>
diff --git a/hypha/apply/users/templates/two_factor/core/setup_complete.html b/hypha/apply/users/templates/two_factor/core/setup_complete.html
index 973817d57fa0db4815959aba1204496546b52515..627f4a458b13ed7ea79ba5bac6af5be15a88cc26 100644
--- a/hypha/apply/users/templates/two_factor/core/setup_complete.html
+++ b/hypha/apply/users/templates/two_factor/core/setup_complete.html
@@ -13,13 +13,13 @@
     To get the backup codes you can continue to Show Codes.{% endblocktrans %}</p>
 
   {% if not phone_methods %}
-    <a href="{% url 'users:backup_tokens_password' %}" class="btn btn-link">{% trans "Show Codes" %}</a>
+    <a href="{% url 'users:backup_tokens' %}" class="btn btn-link">{% trans "Show Codes" %}</a>
   {% else %}
     <p>{% blocktrans trimmed %}However, it might happen that you don't have access to
       your primary token device. To enable account recovery, add a phone
       number.{% endblocktrans %}</p>
 
-    <p><a href="{% url 'users:backup_tokens_password' %}" class="btn btn-block">{% trans "Show Codes" %}</a></p>
+    <p><a href="{% url 'users:backup_tokens' %}" class="btn btn-block">{% trans "Show Codes" %}</a></p>
     <p><a href="{% url 'two_factor:phone_create' %}"
           class="btn btn-success">{% trans "Add Phone Number" %}</a></p>
   {% endif %}
diff --git a/hypha/apply/users/templates/two_factor/core/two_factor_required.html b/hypha/apply/users/templates/two_factor/core/two_factor_required.html
index ad0f7254cfd5dfae947dcfff4154a734a7a09605..d5cf73ae1f854559030c0773abed73333add3460 100644
--- a/hypha/apply/users/templates/two_factor/core/two_factor_required.html
+++ b/hypha/apply/users/templates/two_factor/core/two_factor_required.html
@@ -1,22 +1,38 @@
 <!--Custom template to enforce 2FA and Copied from two_factor/core/otp_required.html-->
-
 {% extends "two_factor/_base_focus.html" %}
 {% load i18n %}
 
 {% block content %}
-  <h1>{% block title %}{% trans "Permission Denied" %}{% endblock %}</h1>
+    <h1>{% block title %}{% trans "Permission Denied" %}: {{ reason }}{% endblock %}</h1>
+
+    <div class="prose mb-4">
+        <p>
+            {% blocktrans trimmed %}
+                The page you are trying to access requires users to verify their
+                identity using two-factor authentication for security reasons.
+            {% endblocktrans %}
+        </p>
+
+        <p>
+            {% blocktrans trimmed %}
+                In order to access this page, you need to set up these security
+                features. Without setting them up, you will only be able to access
+                your account's profile section or log out from the system.
+            {% endblocktrans %}
+        </p>
+
+        <p>
+            {% blocktrans trimmed %}
+                Two-factor authentication has not been set up
+                for your account yet. Please set it up to enhance
+                the security of your account.
+            {% endblocktrans %}
+        </p>
+    </div>
 
-  <p>{% blocktrans trimmed %}The page you requested, enforces users to verify using
-    two-factor authentication for security reasons. You need to set up these
-    security features in order to access this page. Without setting up these security features,
-    You can only access the account(Profile section) or can logout from the system.{% endblocktrans %}</p>
 
-  <p>{% blocktrans trimmed %}Two-factor authentication is not already set up for your
-    account. Set up two-factor authentication for enhanced account
-    security.{% endblocktrans %}</p>
+    <a href="{% url 'two_factor:setup' %}?next={{request.path}}" class="btn btn-primary">
+        {% trans "Set up Two-Factor Authentication (2FA)" %}
+    </a>
 
-  <p>
-    <a href="{% url 'two_factor:setup' %}" class="btn btn-primary">
-      {% trans "Set up Two-Factor Authentication (2FA)" %}</a>
-  </p>
 {% endblock %}
diff --git a/hypha/apply/users/templates/two_factor/profile/disable.html b/hypha/apply/users/templates/two_factor/profile/disable.html
index 683c8c3038ab994b56ae598d088a9e48ca79c191..b4dc0b43c91adc7701d8b77bb0f4f913bad60e6c 100644
--- a/hypha/apply/users/templates/two_factor/profile/disable.html
+++ b/hypha/apply/users/templates/two_factor/profile/disable.html
@@ -1,39 +1,40 @@
+
 {% extends "two_factor/_base_focus.html" %}
 {% load i18n %}
 
 {% block content %}
-    <p><a href="{% url 'users:account'%}"
-          class="btn btn-link">{% trans "Back to account" %}</a></p>
     <h1>{% block title %}{% trans "Disable Two-factor Authentication" %}{% endblock %}</h1>
-    <p>{% blocktrans trimmed %}Disabling Two-factor authentication weakens your account security.
+    <p class="mb-4">{% blocktrans trimmed %}Disabling Two-factor authentication weakens your account security.
         We recommend reenabling it when you can.{% endblocktrans %}</p>
-    <div class="wrapper wrapper--small wrapper--inner-space-medium">
-        <form class="form" action="" method="POST" novalidate>
-            {% if form.non_field_errors %}
-                <ul class="errorlist">
-                    {% for error in form.non_field_errors %}
-                        <li>{{ error }}</li>
-                    {% endfor %}
-                </ul>
-            {% endif %}
-
-            {% if form.errors %}
-                <ul class="errorlist">
-                    {% blocktrans trimmed count counter=form.errors.items|length %}
-                        <li>Please correct the error below.</li>
-                    {% plural %}
-                        <li>Please correct the errors below.</li>
-                    {% endblocktrans %}
-                </ul>
-            {% endif %}
-
-            {% csrf_token %}
-
-            {% for field in form %}
-                {% include "forms/includes/field.html" %}
-            {% endfor %}
-
-            <button class="btn btn-danger" type="submit">{% trans 'Disable Two-factor Authentication' %}</button>
-        </form>
-    </div>
+
+    <form class="form" action="" method="POST" novalidate>
+        {% if form.non_field_errors %}
+            <ul class="errorlist">
+                {% for error in form.non_field_errors %}
+                    <li>{{ error }}</li>
+                {% endfor %}
+            </ul>
+        {% endif %}
+
+        {% if form.errors %}
+            <ul class="errorlist">
+                {% blocktrans trimmed count counter=form.errors.items|length %}
+                    <li>Please correct the error below.</li>
+                {% plural %}
+                    <li>Please correct the errors below.</li>
+                {% endblocktrans %}
+            </ul>
+        {% endif %}
+
+        {% csrf_token %}
+
+        {% for field in form %}
+            {% include "forms/includes/field.html" %}
+        {% endfor %}
+
+        <button class="btn btn-danger" type="submit">
+            {% trans 'Confirm' %}
+        </button>
+    </form>
+
 {% endblock %}
diff --git a/hypha/apply/users/templates/two_factor/profile/profile.html b/hypha/apply/users/templates/two_factor/profile/profile.html
index b5e73b325406dcb7aea85a353a9155bfed54a5a6..21a9b92f27d5312326142c79e3df2981c7399468 100644
--- a/hypha/apply/users/templates/two_factor/profile/profile.html
+++ b/hypha/apply/users/templates/two_factor/profile/profile.html
@@ -48,7 +48,7 @@
         You have {{ counter }} backup tokens remaining.
       {% endblocktrans %}
     </p>
-    <p><a href="{% url 'users:backup_tokens_password' %}"
+    <p><a href="{% url 'users:backup_tokens' %}"
           class="btn btn-info">{% trans "Show Codes" %}</a></p>
 
     <h2>{% trans "Disable Two-Factor Authentication" %}</h2>
diff --git a/hypha/apply/users/templates/users/account.html b/hypha/apply/users/templates/users/account.html
index 12346f36c0d47ba705d27d8c49c788f36bed56c3..1e95d50328ecaa964f55b5e5456a729fe9860aa7 100644
--- a/hypha/apply/users/templates/users/account.html
+++ b/hypha/apply/users/templates/users/account.html
@@ -8,10 +8,12 @@
         {% slot header %}{% trans "Welcome" %} {{ user }}{% endslot %}
         {% slot sub_heading %}{% trans "Manage your account details and security." %}{% endslot %}
 
-        <a href="{% url 'dashboard:dashboard' %}" class="button button--primary button--arrow-pixels-white" hx-boost='true'>
-            {% trans "Go to my dashboard" %}
-            <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg>
-        </a>
+        {% if user.can_access_dashboard %}
+            <a href="{% url 'dashboard:dashboard' %}" class="button button--primary button--arrow-pixels-white" hx-boost='true'>
+                {% trans "Go to my dashboard" %}
+                <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg>
+            </a>
+        {% endif %}
     {% endadminbar %}
 
     <div class="profile">
@@ -28,23 +30,38 @@
             </form>
         </div>
 
-        {% if show_change_password and user.has_usable_password and not backends.associated %}
-            <div class="profile__column">
-                <h2 class="text-2xl">{% trans "Account Security" %}</h2>
-                <h3 class="text-base">{% trans "Password" %}</h3>
-                <p><a class="button button--primary" href="{% url 'users:password_change' %}">{% trans "Update password" %}</a></p>
 
-                <h4 class="text-base mt-8">{% trans "Two-Factor Authentication (2FA)" %}</h4>
-                {% if default_device %}
-                    <div>
-                        <p><a class="button button--primary" href="{% url 'users:backup_tokens_password' %}">{% trans "Backup codes" %}</a></p>
-                        <p><a class="button button--primary button--warning" href="{% url 'two_factor:disable' %}">{% trans "Disable 2FA" %}</a></p>
-                    </div>
-                {% else %}
-                    <p><a class="button button--primary" href="{% url 'two_factor:setup' %}">{% trans "Enable 2FA" %}</a></p>
-                {% endif %}
-            </div>
-        {% endif %}
+        <div class="profile__column">
+            <h2 class="text-2xl">{% trans "Account Security" %}</h2>
+
+            {% if show_change_password %}
+                <div class="block_manage_password mb-8">
+                    <h3 class="text-base mb-0">{% trans "Password" %}</h3>
+                    <p>
+                        {% if user.has_usable_password %}
+                            <a class="button button--primary" href="{% url 'users:password_change' %}">
+                                {% trans "Update password" %}
+                            </a>
+                        {% else %}
+                            <button class="button button--primary"
+                                    hx-post="{% url 'users:set_user_password' %}"
+                                    hx-swap="outerHTML"
+                            >
+                                {% trans "Set Password" %}
+                            </button>
+                        {% endif %}
+                    </p>
+                </div>
+            {% endif %}
+
+            <h3 class="text-base mb-2">{% trans "Two-Factor Authentication (2FA)" %}</h3>
+            {% if default_device %}
+                <a class="button button--primary mb-2" href="{% url 'users:backup_tokens' %}">{% trans "Backup codes" %}</a>
+                <a class="button button--primary button--warning mb-2" href="{% url 'two_factor:disable' %}">{% trans "Disable 2FA" %}</a>
+            {% else %}
+                <a class="button button--primary" href="{% url 'two_factor:setup' %}">{% trans "Enable 2FA" %}</a>
+            {% endif %}
+        </div>
 
 
         {% if swappable_form %}
@@ -63,13 +80,13 @@
                     </form>
                 {% endif %}
 
-        {# Remove the comment block tags below when such need arises. e.g. adding new providers #}
-        {% comment %}
-        {% can_use_oauth as show_oauth_link %}
-        {% if show_oauth_link %}
-            <a href="{% url 'users:oauth' %}">{% trans "Manage OAuth" %}</a>
-        {% endif %}
-        {% endcomment %}
+                {# Remove the comment block tags below when such need arises. e.g. adding new providers #}
+                {% comment %}
+                {% can_use_oauth as show_oauth_link %}
+                {% if show_oauth_link %}
+                    <a href="{% url 'users:oauth' %}">{% trans "Manage OAuth" %}</a>
+                {% endif %}
+                {% endcomment %}
             </div>
         {% endif %}
     </div>
diff --git a/hypha/apply/users/templates/users/activation/email_subject.txt b/hypha/apply/users/templates/users/activation/email_subject.txt
new file mode 100644
index 0000000000000000000000000000000000000000..367b3ea742df0f1ed6cd65b4b11200dd0200c2ef
--- /dev/null
+++ b/hypha/apply/users/templates/users/activation/email_subject.txt
@@ -0,0 +1,3 @@
+{% load i18n %}{% autoescape off %}
+{% blocktranslate %}Account details for {{ username }} at {{ org_long_name }}{% endblocktranslate %}
+{% endautoescape %}
diff --git a/hypha/apply/users/templates/users/activation/invalid.html b/hypha/apply/users/templates/users/activation/invalid.html
index b6dc037fd6cbd9c33a8cdd5522b7cd24020eff66..1457244442cf9a197bd2e2b2d9f7c7543e67506d 100644
--- a/hypha/apply/users/templates/users/activation/invalid.html
+++ b/hypha/apply/users/templates/users/activation/invalid.html
@@ -1,18 +1,25 @@
-{% extends 'base.html' %}
-{% load i18n %}
+{% extends "base-apply.html" %}
+{% load i18n heroicons %}
 
 {% block title %}{% trans "Invalid activation" %}{% endblock %}
-{% block page_title %}{% trans "Invalid activation URL" %}{% endblock %}
+{% block body_class %}bg-white{% endblock %}
 
 {% block content %}
-    {% url 'users:password_reset' as password_reset %}
-    <div class="wrapper wrapper--small wrapper--bottom-space">
-        <p><strong>{% trans "Two possible reasons:" %}</strong></p>
-        <ul>
-            <li>{% trans "The activation link has expired." %}</li>
-            <li>{% trans "The account has already been activated." %}</li>
-        </ul>
+    <div class="w-full bg-white mt-5 md:py-4">
 
-        <p>{% blocktrans %}First try to <a href="{{ password_reset }}">reset your password</a>. If that fails please contact {{ ORG_SHORT_NAME }} at{% endblocktrans %} <a href="mailto:{{ ORG_EMAIL }}">{{ ORG_EMAIL }}</a></p>
-    </div>
+        <section class="max-w-2xl">
+            {% heroicon_outline "exclamation-triangle" aria_hidden="true" size=64 class="stroke-red-600" %}
+
+            <h2 class="text-2xl">{% trans "Invalid activation URL" %}</h2>
+            {% url 'users:password_reset' as password_reset %}
+            <div class="wrapper wrapper--small wrapper--bottom-space">
+                <p><strong>{% trans "Two possible reasons:" %}</strong></p>
+                <ol class="list-decimal pl-6">
+                    <li>{% trans "The activation link has expired." %}</li>
+                    <li>{% trans "The account has already been activated." %}</li>
+                </ol>
+
+                <p>{% blocktrans %}First try to <a href="{{ password_reset }}">reset your password</a>. If that fails please contact {{ ORG_SHORT_NAME }} at{% endblocktrans %} <a href="mailto:{{ ORG_EMAIL }}">{{ ORG_EMAIL }}</a></p>
+            </section>
+        </div>
 {% endblock %}
diff --git a/hypha/apply/users/templates/users/email_change/confirm_password.html b/hypha/apply/users/templates/users/email_change/confirm_password.html
deleted file mode 100644
index 5b5b638acbcacf8ac9383593c66d99c898b7c8a3..0000000000000000000000000000000000000000
--- a/hypha/apply/users/templates/users/email_change/confirm_password.html
+++ /dev/null
@@ -1,40 +0,0 @@
-{% extends 'base.html' %}
-{% load i18n %}
-{% block header_modifier %}header--light-bg{% endblock %}
-{% block page_title %}{% trans "Enter Password" %}{% endblock %}
-{% block title %}{% trans "Enter Password" %}{% endblock %}
-
-
-{% block content %}
-    <div class="wrapper wrapper--small wrapper--inner-space-medium">
-        <form class="form" action="" method="POST" novalidate>
-            {% if form.non_field_errors %}
-                <ul class="errorlist">
-                    {% for error in form.non_field_errors %}
-                        <li>{{ error }}</li>
-                    {% endfor %}
-                </ul>
-            {% endif %}
-
-            {% if form.errors %}
-                <ul class="errorlist">
-                    {% blocktrans trimmed count counter=form.errors.items|length %}
-                        <li>Please correct the error below.</li>
-                    {% plural %}
-                        <li>Please correct the errors below.</li>
-                    {% endblocktrans %}
-                </ul>
-            {% endif %}
-
-            {% csrf_token %}
-
-            {% for field in form %}
-                {% include "forms/includes/field.html" %}
-            {% endfor %}
-
-            <div class="form__group">
-                <button class="button button--primary" type="submit">{% trans 'Submit' %}</button>
-            </div>
-        </form>
-    </div>
-{% endblock %}
diff --git a/hypha/apply/users/templates/users/email_change/done.html b/hypha/apply/users/templates/users/email_change/done.html
index 448999f44acd2263458bddf4bd395cc18d72a808..fcf6194f7d99a48b74807fef036e626f90c186a6 100644
--- a/hypha/apply/users/templates/users/email_change/done.html
+++ b/hypha/apply/users/templates/users/email_change/done.html
@@ -1,11 +1,21 @@
-{% extends "base.html" %}
+{% extends "base-apply.html" %}
 {% load i18n %}
-{% block header_modifier %}header--light-bg{% endblock %}
-{% block page_title %}{% trans "Check your email" %}{% endblock %}
+{% block page_title %}{% trans "Email Change - Verify Email" %}{% endblock %}
 {% block title %}{% trans "Verify Email" %}{% endblock %}
 
 {% block content %}
-    <div class="wrapper wrapper--small wrapper--bottom-space">
-        <p>{% trans "To start using the new email, please click on the confirmation link that has been sent to you on your new email." %}</p>
+
+    {% adminbar %}
+        {% slot header %}{% trans "Email Update" %}{% endslot %}
+    {% endadminbar %}
+
+    <div class="wrapper mt-6 prose">
+        <h2>{% trans "Confirm &amp; verify your new email!" %} </h2>
+        <p>
+            {% trans "We have sent a confirmation link to your new email." %}
+        </p>
+        <p>
+            {% trans "To start using the new email, please click on the confirmation link that has been sent to you on your new email." %}
+        </p>
     </div>
 {% endblock %}
diff --git a/hypha/apply/users/templates/users/emails/confirm_access.md b/hypha/apply/users/templates/users/emails/confirm_access.md
new file mode 100644
index 0000000000000000000000000000000000000000..f9ee524e436afbca82a1d4f7b9f0757c9d0a1d3f
--- /dev/null
+++ b/hypha/apply/users/templates/users/emails/confirm_access.md
@@ -0,0 +1,19 @@
+{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}
+{% blocktrans %}Dear {{ user }},{% endblocktrans %}
+
+{% blocktrans %}To confirm access at {{ org_long_name }} use the code below (valid for {{ timeout_minutes }} minutes):{% endblocktrans %}
+
+{{ token }}
+
+{% blocktrans %}If you did not request this email, please ignore it.{% endblocktrans %}
+
+{% if org_email %}
+{% blocktrans %}If you have any questions, please contact us at {{ org_email }}.{% endblocktrans %}
+{% endif %}
+
+{% blocktrans %}Kind Regards,
+The {{ org_short_name }} Team{% endblocktrans %}
+
+--
+{{ org_long_name }}
+{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}
diff --git a/hypha/apply/users/templates/users/emails/passwordless_login_email.md b/hypha/apply/users/templates/users/emails/passwordless_login_email.md
new file mode 100644
index 0000000000000000000000000000000000000000..9ebb66c1239049c64c8f6775e7b3880b1403c54c
--- /dev/null
+++ b/hypha/apply/users/templates/users/emails/passwordless_login_email.md
@@ -0,0 +1,26 @@
+{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}{% firstof name username as user %}
+{% blocktrans %}Dear {{ user }},{% endblocktrans %}
+
+{% if is_active %}
+{% blocktrans %}Login to your account on the {{ org_long_name }} web site by clicking this link or copying and pasting it to your browser:{% endblocktrans %}
+
+{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}{{ login_path }}
+
+{% blocktrans %}This link will valid for {{ timeout_minutes }} minutes and can be used only once.{% endblocktrans %}
+
+{% else %}
+{% blocktrans %}Your account on the {{ org_long_name }} web site is deactivated. Please contact site administrators.{% endblocktrans %}
+{% endif %}
+
+{% blocktrans %}If you did not request this email, please ignore it.{% endblocktrans %}
+
+{% if org_email %}
+{% blocktrans %}If you have any questions, please contact us at {{ org_email }}.{% endblocktrans %}
+{% endif %}
+
+{% blocktrans %}Kind Regards,
+The {{ org_short_name }} Team{% endblocktrans %}
+
+--
+{{ org_long_name }}
+{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}
diff --git a/hypha/apply/users/templates/users/emails/passwordless_login_no_account_found.md b/hypha/apply/users/templates/users/emails/passwordless_login_no_account_found.md
new file mode 100644
index 0000000000000000000000000000000000000000..9f9cea09efc7e17da8f00933bddf8202b8018f3b
--- /dev/null
+++ b/hypha/apply/users/templates/users/emails/passwordless_login_no_account_found.md
@@ -0,0 +1,16 @@
+{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}
+
+{% blocktrans %}Dear,{% endblocktrans %}
+
+{% blocktrans %}It looks like you are trying to login on {{ org_long_name }} web site, but we could not find any account with the email provided.{% endblocktrans %}
+
+{% if org_email %}
+{% blocktrans %}If you have any questions, please contact us at {{ org_email }}.{% endblocktrans %}
+{% endif %}
+
+{% blocktrans %}Kind Regards,
+The {{ org_short_name }} Team{% endblocktrans %}
+
+--
+{{ org_long_name }}
+{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}
diff --git a/hypha/apply/users/templates/users/emails/passwordless_new_account_login.md b/hypha/apply/users/templates/users/emails/passwordless_new_account_login.md
new file mode 100644
index 0000000000000000000000000000000000000000..ab23da16a81d6029fddbaead4f700e67e02623b7
--- /dev/null
+++ b/hypha/apply/users/templates/users/emails/passwordless_new_account_login.md
@@ -0,0 +1,21 @@
+{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}
+{% blocktrans %}Dear,{% endblocktrans %}
+
+{% blocktrans %}Welcome to {{ org_long_name }} web site. Create your account by clicking this link or copying and pasting it to your browser:{% endblocktrans %}
+
+{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}{{ signup_path }}
+
+{% blocktrans %}This link will valid for {{ timeout_minutes }} minutes and can be used only once.{% endblocktrans %}
+
+{% blocktrans %}If you did not request this email, please ignore it.{% endblocktrans %}
+
+{% if org_email %}
+{% blocktrans %}If you have any questions, please contact us at {{ org_email }}.{% endblocktrans %}
+{% endif %}
+
+{% blocktrans %}Kind Regards,
+The {{ org_short_name }} Team{% endblocktrans %}
+
+--
+{{ org_long_name }}
+{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}
diff --git a/hypha/apply/users/templates/users/emails/set_password.txt b/hypha/apply/users/templates/users/emails/set_password.txt
new file mode 100644
index 0000000000000000000000000000000000000000..a5c66b62cfe42b7d71ff20dfd087bebaf8a360f9
--- /dev/null
+++ b/hypha/apply/users/templates/users/emails/set_password.txt
@@ -0,0 +1,15 @@
+{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}{% firstof name username as user %}
+{% blocktrans %}Dear {{ user }},{% endblocktrans %}
+
+{% blocktrans %}Set your account password on the {{ org_long_name }} web site by clicking this link or copying and pasting it to your browser:{% endblocktrans %}
+
+{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}{{ activation_path }}
+
+{% blocktrans %}This link can be used only once and will lead you to a page where you can set your password. It will remain active for {{ timeout_days }} days, so please set your password as soon as possible.{% endblocktrans %}
+
+{% blocktrans %}Kind Regards,
+The {{ org_short_name }} Team{% endblocktrans %}
+
+--
+{{ org_long_name }}
+{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}
diff --git a/hypha/apply/users/templates/users/emails/set_password_subject.txt b/hypha/apply/users/templates/users/emails/set_password_subject.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ac5b535ead2106a5f0f52654d08c7c664879d9cd
--- /dev/null
+++ b/hypha/apply/users/templates/users/emails/set_password_subject.txt
@@ -0,0 +1,3 @@
+{% load i18n %}{% autoescape off %}
+{% blocktranslate %}Set password for {{ username }} at {{ org_long_name }}{% endblocktranslate %}
+{% endautoescape %}
diff --git a/hypha/apply/users/templates/users/login.html b/hypha/apply/users/templates/users/login.html
index ab5154f588e5752417c054591d395864625eb119..37815bf1c946c5109372dc02775b8a17ca68c6f3 100644
--- a/hypha/apply/users/templates/users/login.html
+++ b/hypha/apply/users/templates/users/login.html
@@ -31,18 +31,36 @@
 
                     {% if wizard.steps.current == 'auth' %}
 
+                        <style>
+                            .id_auth-password {
+                                margin-bottom: 0.25rem;
+                            }
+                        </style>
+
                         <h2 class="text-2xl">Log in to {{ ORG_SHORT_NAME }}</h2>
                         {% for field in form %}
-                            <div class="form__group">
+                            <div class="relative max-w-sm {% if field.auto_id == "id_auth-password" %}mb-4{% endif %}">
                                 {% include "forms/includes/field.html" %}
+                                {% if field.auto_id == "id_auth-password" %}
+                                    <div class="text-right">
+                                        <a class="link text-sm hover:opacity-75" href="{% url 'users:password_reset' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" hx-boost="true">{% trans "Forgot your password?" %}</a>
+                                    </div>
+                                {% endif %}
                             </div>
                         {% endfor %}
+
                         {% if settings.users.AuthSettings.extra_text %}
-                            {{ settings.users.AuthSettings.extra_text|richtext}}
+                            <div class="prose prose-sm mb-6 rounded-sm bg-slate-50 p-4">
+                                {{ settings.users.AuthSettings.extra_text|richtext}}
+                            </div>
                         {% endif %}
 
-                        <div class="form__group">
+                        <div class="form__group max-w-sm flex items-center justify-between gap-4">
                             <button class="link link--button link--button-secondary" type="submit">{% trans "Log in" %}</button>
+
+                            {% if ENABLE_PUBLIC_SIGNUP %}
+                                <a class="hover:opacity-75" href="{% url 'users_public:register' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" hx-boost="true"> {% trans "Create account" %}</a>
+                            {% endif %}
                         </div>
 
                         {% if GOOGLE_OAUTH2 %}
@@ -56,20 +74,13 @@
                                 <a class="link link--button link--button-tertiary" href="{% url "social:begin" "google-oauth2" %}{% if next %}?next={{ next }}{% endif %}">{% blocktrans %}Log in with your {{ ORG_SHORT_NAME }} email{% endblocktrans %}</a>
                             </div>
                         {% endif %}
-
-                        <div class="inline-flex gap-8 w-full mt-4">
-                            {% if ENABLE_REGISTRATION_WITHOUT_APPLICATION %}
-                                <a href="{% url 'users_public:register' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" hx-boost="true"> {% trans "Create account" %}</a>
-                            {% endif %}
-                            <a class="link" href="{% url 'users:password_reset' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" hx-boost="true">{% trans "Forgot your password?" %}</a>
-                        </div>
                     {% else %}
 
                         <div class="form__group">
                             {{ wizard.form }}
                         </div>
 
-                    {# hidden submit button to enable [enter] key #}
+                        {# hidden submit button to enable [enter] key #}
                         <div class="sr-only"><input type="submit" value=""/></div>
 
                         {% if other_devices %}
@@ -90,7 +101,7 @@
                         {% if backup_tokens %}
                             <p>{% trans "As a last resort, you can use a backup codes:" %}
                                 <button name="wizard_goto_step" type="submit" value="backup"
-                                        class="link link--button link--button-tertiary">{% trans "Use Backup Code" %}</button>
+                                        class="button button--transparent">{% trans "Use Backup Code" %}</button>
                             </p>
                         {% endif %}
                     {% endif %}
diff --git a/hypha/apply/users/templates/users/partials/confirmation_code_sent.html b/hypha/apply/users/templates/users/partials/confirmation_code_sent.html
new file mode 100644
index 0000000000000000000000000000000000000000..c3a7f8feddf4826289f8bb3cde1219ba756a9639
--- /dev/null
+++ b/hypha/apply/users/templates/users/partials/confirmation_code_sent.html
@@ -0,0 +1,74 @@
+{% load i18n heroicons %}
+<form
+    class="form form--error-inline px-4 py-4 mb-4 border rounded-sm bg-gray-50 w-full text-center"
+    id="elevate-check-code-form"
+    x-data="{ code: '' }"
+>
+    {% csrf_token %}
+    {% if error %}
+        <p class="mb-4 font-bold text-red-700">{% trans "Invalid code, please try again!" %}</p>
+    {% else %}
+        <p class="mb-4">
+            {% heroicon_mini "check-circle" class="inline align-text-bottom fill-green-700" aria_hidden=true %}
+            <em>{% trans "An email containing a code has been sent. Please check your email for the code." %}</em>
+        </p>
+    {% endif %}
+
+    <div class="mb-4">
+        <label class="font-bold mr-1" for="id_code">{% trans "Enter Code" %}: </label>
+        <input
+            name='code'
+            id="id_code"
+            autofocus
+            required
+            type='text'
+            maxlength='6'
+            class="mb-2 !w-28 placeholder:text-gray-400 text-center tracking-wider"
+            x-model="code"
+            autocomplete="off"
+            placeholder="_ _ _ _ _ _"
+            data-1p-ignore
+        >
+    </div>
+
+    <div>
+        <button
+            class="button button-primary block mb-4"
+            type="submit"
+            hx-post="{% url 'users:elevate_check_code' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}"
+            hx-validate="true"
+            hx-target="#section-form"
+            x-bind:disabled="code ? false : true"
+        >
+            {% trans "Confirm" %}
+        </button>
+    </div>
+    {% if error %}
+        <button
+            class="link hover:underline"
+            hx-post="{% url 'users:elevate_send_confirm_access_email' %}{% if request.GET.next %}?next={{request.GET.next}}{% endif %}"
+            hx-target="#section-form"
+        >
+            {% trans "Re-send code?" %}
+        </button>
+    {% endif %}
+</form>
+
+{% if request.user.has_usable_password %}
+    <section data-test-id="section-send-email" class="px-4 border pt-2 pb-4">
+        <p>{% trans "Having problems?" %}</p>
+        <ul class="list-disc ml-4">
+            <li>
+                <a
+                    class="m-0"
+                    type="submit"
+                    hx-boost="true"
+                    href="{% url 'users:elevate' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}"
+                >
+                    {% trans "Use your password" %}
+                </a>
+            </li>
+        </ul>
+    </section>
+{% endif %}
+
diff --git a/hypha/apply/users/templates/users/partials/passwordless_login_signup_sent.html b/hypha/apply/users/templates/users/partials/passwordless_login_signup_sent.html
new file mode 100644
index 0000000000000000000000000000000000000000..62a07ec4299d848c42b834ee948c056826d4fd8c
--- /dev/null
+++ b/hypha/apply/users/templates/users/partials/passwordless_login_signup_sent.html
@@ -0,0 +1,29 @@
+{% extends base_template %}
+{% load i18n heroicons %}
+
+{% block content %}
+    <section class="prose mt-8">
+        <div>
+            {% heroicon_outline "document-check" aria_hidden="true" size=64 %}
+        </div>
+        <h2 class="mt-4">
+            {% trans "Check your inbox to proceed!" %}
+        </h2>
+
+        <p>
+            {% if ENABLE_PUBLIC_SIGNUP %}
+                {% trans "We have sent you an email containing a link for logging in or signing up. Please check your email and use the link provided to either login or create your account." %}</p>
+            {% else %}
+                {% trans "We've sent you an email with a login link. Kindly check your email and follow the link to access your account." %}</p>
+            {% endif %}
+        </p>
+
+        <p>
+            {% blocktrans %}Check your "Spam" folder, if you don't find the email in your inbox.{% endblocktrans %}
+        </p>
+
+        <p>
+            <a href="{% url 'users_public:passwordless_login_signup' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" class="font-medium hover:underline">Try again</a>
+        </p>
+    </section>
+{% endblock content %}
diff --git a/hypha/apply/users/templates/users/passwordless_login_signup.html b/hypha/apply/users/templates/users/passwordless_login_signup.html
new file mode 100644
index 0000000000000000000000000000000000000000..d837caf90f50a786d2149d69991b4dc771dfa7b0
--- /dev/null
+++ b/hypha/apply/users/templates/users/passwordless_login_signup.html
@@ -0,0 +1,69 @@
+{% extends base_template %}
+{% load i18n wagtailcore_tags heroicons %}
+
+{% block title %}{% trans "Login or Signup" %}{% endblock %}
+
+{% block content %}
+    <div class="max-w-2xl bg-white mt-5 md:py-4">
+
+        <section class="pt-4 px-5">
+            <form class="form form--user-login" method="post" hx-post="./" hx-swap="outerHTML" hx-target="#main">
+                {% csrf_token %}
+
+                {% if redirect_url %}
+                    <input type="hidden" name="next" value="{{ redirect_url }}">
+                {% endif %}
+
+                <h2 class="text-2xl">Login {% if ENABLE_PUBLIC_SIGNUP %}or Signup {% endif %}to {{ ORG_SHORT_NAME }}</h2>
+
+                <div>
+                    {% for hidden in form.hidden_fields %}
+                        {{ hidden }}
+                    {% endfor %}
+                    {% for field in form.visible_fields %}
+                        {% if field.field %}
+                            {% include "forms/includes/field.html" %}
+                        {% else %}
+                            {{ field }}
+                        {% endif %}
+                    {% endfor %}
+                </div>
+
+                {% if settings.users.AuthSettings.extra_text %}
+                    <div class="prose prose-sm mb-6 p-4 rounded-sm bg-slate-50">
+                        {{ settings.users.AuthSettings.extra_text|richtext}}
+                    </div>
+                {% endif %}
+
+                <div class="form__group">
+                    <button class="link link--button link--button-secondary" type="submit">{% trans "Next" %}</button>
+                </div>
+
+                <div class="flex items-center justify-start relative mb-4">
+                    <hr class="inline w-32 h-px my-6 bg-gray-300 border-0">
+                    <span class="px-3 text-gray-400 font-medium">or</span>
+                    <hr class="inline w-32 h-px my-6 bg-gray-300 border-0">
+                </div>
+
+                <section>
+                    {% if GOOGLE_OAUTH2 %}
+                        <a
+                            class="link link--button link--button-tertiary"
+                            href="{% url "social:begin" "google-oauth2" %}{% if next %}?next={{ next }}{% endif %}"
+                        >
+                            {% blocktrans %}Log in with your {{ ORG_SHORT_NAME }} email{% endblocktrans %}
+                        </a>
+                    {% endif %}
+
+                    <a
+                        class="link link--button link--button-tertiary"
+                        href="{% url 'users_public:login' %}{% if next %}?next={{next}}{% endif %}"
+                    >
+                        {% heroicon_mini "key" size=18 class="inline align-text-bottom mr-1" aria_hidden=true %}
+                        {% trans "Login with Password" %}
+                    </a>
+                </section>
+            </form>
+        </section>
+    </div>
+{% endblock %}
diff --git a/hypha/apply/users/tests/test_forms.py b/hypha/apply/users/tests/test_forms.py
index dc7fd7d6f958e6bb141074b3b6c1cc631d4baa9d..1b3c5f35c575fe1bf2128fc3de6e8524682a7fb3 100644
--- a/hypha/apply/users/tests/test_forms.py
+++ b/hypha/apply/users/tests/test_forms.py
@@ -1,5 +1,5 @@
 from django.forms.models import model_to_dict
-from django.test import TestCase
+from django.test import RequestFactory, TestCase
 
 from ..forms import EmailChangePasswordForm, ProfileForm
 from .factories import StaffFactory, UserFactory
@@ -12,9 +12,11 @@ class BaseTestProfileForm(TestCase):
         data.update(**values)
         return data
 
-    def submit_form(self, instance, **extra_data):
+    def submit_form(self, instance, request=None, **extra_data):
         form = ProfileForm(
-            instance=instance, data=self.form_data(instance, **extra_data)
+            instance=instance,
+            data=self.form_data(instance, **extra_data),
+            request=request,
         )
         if form.is_valid():
             form.save()
@@ -28,7 +30,7 @@ class TestProfileForm(BaseTestProfileForm):
 
     def test_email_unique(self):
         other_user = UserFactory()
-        form = self.submit_form(self.user, email=other_user.email)
+        form = self.submit_form(instance=self.user, email=other_user.email)
         # form will update the other user's email with same user email, only non exiting email address can be added
         self.assertTrue(form.is_valid())
         self.user.refresh_from_db()
@@ -36,13 +38,13 @@ class TestProfileForm(BaseTestProfileForm):
 
     def test_can_change_email(self):
         new_email = "me@another.com"
-        self.submit_form(self.user, email=new_email)
+        self.submit_form(instance=self.user, email=new_email)
         self.user.refresh_from_db()
         self.assertEqual(self.user.email, new_email)
 
     def test_cant_set_slack_name(self):
         slack_name = "@foobar"
-        self.submit_form(self.user, slack=slack_name)
+        self.submit_form(instance=self.user, slack=slack_name)
         self.user.refresh_from_db()
         self.assertNotEqual(self.user.slack, slack_name)
 
@@ -51,29 +53,33 @@ class TestStaffProfileForm(BaseTestProfileForm):
     def setUp(self):
         self.staff = StaffFactory()
 
-    def test_cant_change_email(self):
+    def test_cant_change_email_oauth(self):
         new_email = "me@this.com"
-        self.submit_form(self.staff, email=new_email)
+        request = RequestFactory().get("/")
+        request.session = {
+            "_auth_user_backend": "social_core.backends.google.GoogleOAuth2"
+        }
+        self.submit_form(instance=self.staff, request=request, email=new_email)
         self.staff.refresh_from_db()
         self.assertNotEqual(new_email, self.staff.email)
 
     def test_can_set_slack_name(self):
         slack_name = "@foobar"
-        self.submit_form(self.staff, slack=slack_name)
+        self.submit_form(instance=self.staff, slack=slack_name)
 
         self.staff.refresh_from_db()
         self.assertEqual(self.staff.slack, slack_name)
 
     def test_can_set_slack_name_with_trailing_space(self):
         slack_name = "@foobar"
-        self.submit_form(self.staff, slack=slack_name)
+        self.submit_form(instance=self.staff, slack=slack_name)
 
         self.staff.refresh_from_db()
         self.assertEqual(self.staff.slack, slack_name)
 
     def test_cant_set_slack_name_with_space(self):
         slack_name = "@ foobar"
-        form = self.submit_form(self.staff, slack=slack_name)
+        form = self.submit_form(instance=self.staff, slack=slack_name)
         self.assertFalse(form.is_valid())
 
         self.staff.refresh_from_db()
@@ -81,14 +87,14 @@ class TestStaffProfileForm(BaseTestProfileForm):
 
     def test_auto_prepend_at(self):
         slack_name = "foobar"
-        self.submit_form(self.staff, slack=slack_name)
+        self.submit_form(instance=self.staff, slack=slack_name)
 
         self.staff.refresh_from_db()
         self.assertEqual(self.staff.slack, "@" + slack_name)
 
     def test_can_clear_slack_name(self):
         slack_name = ""
-        self.submit_form(self.staff, slack=slack_name)
+        self.submit_form(instance=self.staff, slack=slack_name)
 
         self.staff.refresh_from_db()
         self.assertEqual(self.staff.slack, slack_name)
diff --git a/hypha/apply/users/tests/test_middleware.py b/hypha/apply/users/tests/test_middleware.py
index a5de674bb4cd1480baf8ee7e182822f6517bd96b..d446661b641bcb19c3930bb2c366cf15f3f39bf8 100644
--- a/hypha/apply/users/tests/test_middleware.py
+++ b/hypha/apply/users/tests/test_middleware.py
@@ -4,7 +4,7 @@ from django.urls import reverse
 
 from hypha.apply.users.tests.factories import UserFactory
 
-from ..middleware import ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS
+from ..middleware import TWO_FACTOR_EXEMPTED_PATH_PREFIXES
 
 
 @override_settings(ROOT_URLCONF="hypha.apply.urls", ENFORCE_TWO_FACTOR=True)
@@ -17,14 +17,10 @@ class TestTwoFactorAuthenticationMiddleware(TestCase):
         self.client.force_login(user)
 
         response = self.client.get(settings.LOGIN_REDIRECT_URL, follow=True)
-        self.assertRedirects(
-            response, reverse("users:two_factor_required"), status_code=301
-        )
+        assert "Permission Denied" in response.content.decode("utf-8")
 
         response = self.client.get(reverse("funds:submissions:list"), follow=True)
-        self.assertRedirects(
-            response, reverse("users:two_factor_required"), status_code=301
-        )
+        assert "Permission Denied" in response.content.decode("utf-8")
 
     def test_verified_user_redirect(self):
         user = UserFactory()
@@ -40,6 +36,6 @@ class TestTwoFactorAuthenticationMiddleware(TestCase):
         user = UserFactory()
         self.client.force_login(user)
 
-        for path in ALLOWED_SUBPATH_FOR_UNVERIFIED_USERS:
+        for path in TWO_FACTOR_EXEMPTED_PATH_PREFIXES:
             response = self.client.get(path, follow=True)
             self.assertEqual(response.status_code, 200)
diff --git a/hypha/apply/users/tests/test_oauth_access.py b/hypha/apply/users/tests/test_oauth_access.py
index 124d211b32e441247ce38595e8ba23c463cf2f34..8c7a9244de1b6b9a744647eea0c3bfdc83e8fcb5 100644
--- a/hypha/apply/users/tests/test_oauth_access.py
+++ b/hypha/apply/users/tests/test_oauth_access.py
@@ -22,7 +22,7 @@ class TestOAuthAccess(TestCase):
         response = self.client.get(oauth_page, follow=True)
         self.assertRedirects(
             response,
-            reverse("users_public:login") + "?next=" + reverse("users:oauth"),
+            reverse(settings.LOGIN_URL) + "?next=" + reverse("users:oauth"),
             status_code=301,
             target_status_code=200,
         )
diff --git a/hypha/apply/users/tests/test_registration.py b/hypha/apply/users/tests/test_registration.py
index ea0019c6b6967b4c223064cae1d58d55ca3b2a47..6f654e96d310bb52f5bfb6da6aa4f90184df38bc 100644
--- a/hypha/apply/users/tests/test_registration.py
+++ b/hypha/apply/users/tests/test_registration.py
@@ -1,3 +1,4 @@
+from django.conf import settings
 from django.core import mail
 from django.test import TestCase, override_settings
 from django.urls import reverse
@@ -8,17 +9,17 @@ from hypha.apply.utils.testing import make_request
 
 @override_settings(ROOT_URLCONF="hypha.apply.urls")
 class TestRegistration(TestCase):
-    @override_settings(ENABLE_REGISTRATION_WITHOUT_APPLICATION=False)
+    @override_settings(ENABLE_PUBLIC_SIGNUP=False)
     def test_registration_enabled_has_no_link(self):
         response = self.client.get("/", follow=True)
         self.assertNotContains(response, reverse("users_public:register"))
 
-    @override_settings(ENABLE_REGISTRATION_WITHOUT_APPLICATION=True)
+    @override_settings(ENABLE_PUBLIC_SIGNUP=True)
     def test_registration_enabled_has_link(self):
         response = self.client.get("/", follow=True)
         self.assertContains(response, reverse("users_public:register"))
 
-    @override_settings(ENABLE_REGISTRATION_WITHOUT_APPLICATION=True)
+    @override_settings(ENABLE_PUBLIC_SIGNUP=True)
     def test_registration(self):
         response = self.client.post(
             reverse("users_public:register"),
@@ -35,7 +36,7 @@ class TestRegistration(TestCase):
         assert response.status_code == 302
         assert reverse("users_public:register-success") in response.url
 
-    @override_settings(ENABLE_REGISTRATION_WITHOUT_APPLICATION=True)
+    @override_settings(ENABLE_PUBLIC_SIGNUP=True)
     def test_duplicate_registration_fails(self):
         response = self.client.post(
             reverse("users_public:register"),
@@ -61,13 +62,11 @@ class TestRegistration(TestCase):
         assert len(mail.outbox) == 0
         self.assertContains(response, "A user with that email already exists")
 
-    @override_settings(
-        FORCE_LOGIN_FOR_APPLICATION=True, ENABLE_REGISTRATION_WITHOUT_APPLICATION=False
-    )
+    @override_settings(FORCE_LOGIN_FOR_APPLICATION=True, ENABLE_PUBLIC_SIGNUP=False)
     def test_force_login(self):
         fund = FundTypeFactory()
         response = fund.serve(
             make_request(None, {}, method="get", site=fund.get_site())
         )
         assert response.status_code == 302
-        assert response.url == reverse("users_public:login") + "?next=/"
+        assert response.url == reverse(settings.LOGIN_URL) + "?next=/"
diff --git a/hypha/apply/users/tests/test_tokens.py b/hypha/apply/users/tests/test_tokens.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a406828590491c7e4dcb918da80bd492ed38c72
--- /dev/null
+++ b/hypha/apply/users/tests/test_tokens.py
@@ -0,0 +1,63 @@
+import pytest
+from ddf import G
+
+from hypha.apply.users.models import PendingSignup
+from hypha.apply.users.tests.factories import UserFactory
+
+from ..tokens import PasswordlessLoginTokenGenerator, PasswordlessSignupTokenGenerator
+
+# mark all test to use database
+pytestmark = pytest.mark.django_db
+
+
+def test_passwordless_login_token(time_machine, settings):
+    """
+    Test to check that the tokens are generated correctly and that they are valid
+    for the correct amount of time.
+    """
+    settings.PASSWORDLESS_LOGIN_TIMEOUT = 60
+
+    time_machine.move_to("2021-01-01 00:00:00", tick=False)
+    # Create a token generator
+    token_generator = PasswordlessLoginTokenGenerator()
+    # Create a user
+    user = UserFactory()
+    # Create a token
+    token = token_generator.make_token(user)
+
+    # Check that the token is valid
+    assert token_generator.check_token(user, token)
+
+    # negative check
+    assert token_generator.check_token(user, "invalid-token") is False
+
+    # timeout check
+    time_machine.shift(delta=62)
+    assert token_generator.check_token(user, token) is False
+
+
+def test_passwordless_signup_token(time_machine, settings):
+    """
+    Test to check that the tokens are generated correctly and that they are valid
+    for the correct amount of time.
+    """
+    settings.PASSWORDLESS_SIGNUP_TIMEOUT = 60
+
+    time_machine.move_to("2021-01-01 00:00:00", tick=False)
+
+    # Create a token generator
+    token_generator = PasswordlessSignupTokenGenerator()
+    # Create a user
+    signup_obj = G(PendingSignup)
+
+    # Create a token
+    token = token_generator.make_token(user=signup_obj)
+    # Check that the token is valid
+    assert token_generator.check_token(user=signup_obj, token=token)
+
+    # negative check
+    assert token_generator.check_token(signup_obj, "invalid-token") is False
+
+    # timeout check
+    time_machine.shift(delta=62)
+    assert token_generator.check_token(signup_obj, token) is False
diff --git a/hypha/apply/users/tests/test_views.py b/hypha/apply/users/tests/test_views.py
index 45351e8e8f5825c461320166fa9590f51a914d2e..0171d310df02ef444aa218cdcbbd7d8c8a04a642 100644
--- a/hypha/apply/users/tests/test_views.py
+++ b/hypha/apply/users/tests/test_views.py
@@ -1,3 +1,4 @@
+from django.conf import settings
 from django.core import mail
 from django.test import TestCase, override_settings
 from django.urls import reverse
@@ -25,7 +26,7 @@ class TestProfileView(BaseTestProfielView):
         # Initial redirect will be via to https through a 301
         self.assertRedirects(
             response,
-            reverse("users_public:login") + "?next=" + self.url,
+            reverse(settings.LOGIN_URL) + "?next=" + self.url,
             status_code=301,
         )
 
diff --git a/hypha/apply/users/tokens.py b/hypha/apply/users/tokens.py
new file mode 100644
index 0000000000000000000000000000000000000000..6c842fdfb122079bcd4e285ba467a795637f46db
--- /dev/null
+++ b/hypha/apply/users/tokens.py
@@ -0,0 +1,81 @@
+from django.conf import settings
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+from django.utils.crypto import constant_time_compare
+from django.utils.http import base36_to_int
+
+
+class PasswordlessLoginTokenGenerator(PasswordResetTokenGenerator):
+    key_salt = None
+    TIMEOUT = None
+
+    def __init__(self) -> None:
+        self.key_salt = (
+            self.key_salt or "hypha.apply.users.tokens.PasswordlessLoginTokenGenerator"
+        )
+        self.TIMEOUT = self.TIMEOUT or settings.PASSWORDLESS_LOGIN_TIMEOUT
+        super().__init__()
+
+    def check_token(self, user, token):
+        """
+        Check that a token is correct for a given user.
+        """
+        if not (user and token):
+            return False
+        # Parse the token
+        try:
+            ts_b36, _ = token.split("-")
+        except ValueError:
+            return False
+
+        try:
+            ts = base36_to_int(ts_b36)
+        except ValueError:
+            return False
+
+        # Check that the timestamp/uid has not been tampered with
+        for secret in [self.secret, *self.secret_fallbacks]:
+            if constant_time_compare(
+                self._make_token_with_timestamp(user, ts, secret),
+                token,
+            ):
+                break
+        else:
+            return False
+
+        # Check the timestamp is within limit.
+        if (self._num_seconds(self._now()) - ts) > self.TIMEOUT:
+            return False
+
+        return True
+
+
+class PasswordlessSignupTokenGenerator(PasswordlessLoginTokenGenerator):
+    key_salt = None
+    TIMEOUT = None
+
+    def __init__(self) -> None:
+        self.key_salt = (
+            self.key_salt or "hypha.apply.users.tokens.PasswordlessLoginTokenGenerator"
+        )
+        self.TIMEOUT = self.TIMEOUT or settings.PASSWORDLESS_SIGNUP_TIMEOUT
+        super().__init__()
+
+    def _make_hash_value(self, user, timestamp):
+        """
+        Hash the signup request's primary key, email, and some user state
+        that's sure to change after a signup is completed produce a token that is
+        invalidated when it's used.
+
+        The token field and modified field will be updated after creating or
+        updating the signup request.
+
+        Failing those things, settings.PASSWORDLESS_SIGNUP_TIMEOUT eventually
+        invalidates the token.
+
+        Running this data through salted_hmac() prevents password cracking
+        attempts using the reset token, provided the secret isn't compromised.
+        """
+        # Truncate microseconds so that tokens are consistent even if the
+        # database doesn't support microseconds.
+        modified_timestamp = user.modified.replace(microsecond=0, tzinfo=None)
+        return f"{user.pk}{user.token}{modified_timestamp}{timestamp}{user.email}"
diff --git a/hypha/apply/users/urls.py b/hypha/apply/users/urls.py
index 9d8e26b75e1aa0fec29349feebd2d22a6751e6c8..080f44d30be7f7e2f75fffabe6c1ef29c62d40c5 100644
--- a/hypha/apply/users/urls.py
+++ b/hypha/apply/users/urls.py
@@ -10,19 +10,24 @@ from .views import (
     BackupTokensView,
     EmailChangeConfirmationView,
     EmailChangeDoneView,
-    EmailChangePasswordView,
     LoginView,
+    PasswordLessLoginSignupView,
+    PasswordlessLoginView,
+    PasswordlessSignupView,
     PasswordResetConfirmView,
     PasswordResetView,
     RegisterView,
     RegistrationSuccessView,
     TWOFAAdminDisableView,
     TWOFADisableView,
-    TWOFARequiredMessageView,
     TWOFASetupView,
+    account_email_change,
     become,
     create_password,
+    elevate_check_code_view,
     oauth,
+    send_confirm_access_email_view,
+    set_password_view,
 )
 
 app_name = "users"
@@ -30,12 +35,9 @@ app_name = "users"
 
 public_urlpatterns = [
     path(
-        "login/",
-        LoginView.as_view(
-            template_name="users/login.html", redirect_authenticated_user=True
-        ),
-        name="login",
+        "auth/", PasswordLessLoginSignupView.as_view(), name="passwordless_login_signup"
     ),
+    path("login/", LoginView.as_view(), name="login"),
     # Log out
     path("logout/", auth_views.LogoutView.as_view(next_page="/"), name="logout"),
     path("register/", RegisterView.as_view(), name="register"),
@@ -44,114 +46,129 @@ public_urlpatterns = [
     ),
 ]
 
-urlpatterns = [
+account_urls = [
+    path(
+        "",
+        ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="GET")(
+            AccountView.as_view()
+        ),
+        name="account",
+    ),
+    path(
+        "change-email/",
+        account_email_change,
+        name="email_change_confirm_password",
+    ),
     path(
-        "account/",
+        "password/",
         include(
             [
                 path(
-                    "",
-                    ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="GET")(
-                        AccountView.as_view()
+                    "change/",
+                    ratelimit(
+                        key="user",
+                        rate=settings.DEFAULT_RATE_LIMIT,
+                        method="POST",
+                    )(
+                        auth_views.PasswordChangeView.as_view(
+                            template_name="users/change_password.html",
+                            success_url=reverse_lazy("users:account"),
+                        )
                     ),
-                    name="account",
-                ),
-                path(
-                    "password/",
-                    include(
-                        [
-                            path(
-                                "",
-                                EmailChangePasswordView.as_view(),
-                                name="email_change_confirm_password",
-                            ),
-                            path(
-                                "change/",
-                                ratelimit(
-                                    key="user",
-                                    rate=settings.DEFAULT_RATE_LIMIT,
-                                    method="POST",
-                                )(
-                                    auth_views.PasswordChangeView.as_view(
-                                        template_name="users/change_password.html",
-                                        success_url=reverse_lazy("users:account"),
-                                    )
-                                ),
-                                name="password_change",
-                            ),
-                            path(
-                                "reset/",
-                                PasswordResetView.as_view(),
-                                name="password_reset",
-                            ),
-                            path(
-                                "reset/done/",
-                                auth_views.PasswordResetDoneView.as_view(
-                                    template_name="users/password_reset/done.html"
-                                ),
-                                name="password_reset_done",
-                            ),
-                            path(
-                                "reset/confirm/<uidb64>/<token>/",
-                                PasswordResetConfirmView.as_view(),
-                                name="password_reset_confirm",
-                            ),
-                            path(
-                                "reset/complete/",
-                                auth_views.PasswordResetCompleteView.as_view(
-                                    template_name="users/password_reset/complete.html"
-                                ),
-                                name="password_reset_complete",
-                            ),
-                        ]
-                    ),
-                ),
-                path(
-                    "confirmation/done/",
-                    EmailChangeDoneView.as_view(),
-                    name="confirm_link_sent",
-                ),
-                path(
-                    "confirmation/<uidb64>/<token>/",
-                    EmailChangeConfirmationView.as_view(),
-                    name="confirm_email",
-                ),
-                path(
-                    "activate/<uidb64>/<token>/",
-                    ActivationView.as_view(),
-                    name="activate",
+                    name="password_change",
                 ),
-                path("activate/", create_password, name="activate_password"),
-                path("oauth", oauth, name="oauth"),
-                # Two factor redirect
                 path(
-                    "two_factor/required/",
-                    TWOFARequiredMessageView.as_view(),
-                    name="two_factor_required",
+                    "reset/",
+                    PasswordResetView.as_view(),
+                    name="password_reset",
                 ),
-                path("two_factor/setup/", TWOFASetupView.as_view(), name="setup"),
                 path(
-                    "two_factor/backup_tokens/password/",
-                    BackupTokensView.as_view(),
-                    name="backup_tokens_password",
+                    "reset/done/",
+                    auth_views.PasswordResetDoneView.as_view(
+                        template_name="users/password_reset/done.html"
+                    ),
+                    name="password_reset_done",
                 ),
-                path("two_factor/disable/", TWOFADisableView.as_view(), name="disable"),
                 path(
-                    "two_factor/admin/disable/<str:user_id>/",
-                    TWOFAAdminDisableView.as_view(),
-                    name="admin_disable",
+                    "reset/confirm/<uidb64>/<token>/",
+                    PasswordResetConfirmView.as_view(),
+                    name="password_reset_confirm",
                 ),
                 path(
-                    "sessions/trusted-device/",
-                    elevate_view,
-                    {"template_name": "elevate/elevate.html"},
-                    name="elevate",
+                    "reset/complete/",
+                    auth_views.PasswordResetCompleteView.as_view(
+                        template_name="users/password_reset/complete.html"
+                    ),
+                    name="password_reset_complete",
                 ),
             ]
         ),
     ),
+    path(
+        "confirmation/done/",
+        EmailChangeDoneView.as_view(),
+        name="confirm_link_sent",
+    ),
+    path(
+        "confirmation/<uidb64>/<token>/",
+        EmailChangeConfirmationView.as_view(),
+        name="confirm_email",
+    ),
+    path(
+        "activate/<uidb64>/<token>/",
+        ActivationView.as_view(),
+        name="activate",
+    ),
+    path("activate/", create_password, name="activate_password"),
+    path("oauth", oauth, name="oauth"),
+    # 2FA
+    path("two_factor/setup/", TWOFASetupView.as_view(), name="setup"),
+    path(
+        "two_factor/backup_tokens/",
+        BackupTokensView.as_view(),
+        name="backup_tokens",
+    ),
+    path("two_factor/disable/", TWOFADisableView.as_view(), name="disable"),
+    path(
+        "two_factor/admin/disable/<str:user_id>/",
+        TWOFAAdminDisableView.as_view(),
+        name="admin_disable",
+    ),
+    path(
+        "auth/<uidb64>/<token>/signup/",
+        PasswordlessSignupView.as_view(),
+        name="do_passwordless_signup",
+    ),
+    path(
+        "auth/<uidb64>/<token>/",
+        PasswordlessLoginView.as_view(),
+        name="do_passwordless_login",
+    ),
+    path(
+        "auth/set-user-password/",
+        set_password_view,
+        name="set_user_password",
+    ),
+    path(
+        "sessions/trusted-device/",
+        elevate_view,
+        {"template_name": "elevate/elevate.html"},
+        name="elevate",
+    ),
+    path(
+        "sessions/send-confirm-access-email/",
+        send_confirm_access_email_view,
+        name="elevate_send_confirm_access_email",
+    ),
+    path(
+        "sessions/verify-confirmation-code/",
+        elevate_check_code_view,
+        name="elevate_check_code",
+    ),
 ]
 
+urlpatterns = [path("account/", include(account_urls))]
+
 if settings.HIJACK_ENABLE:
     urlpatterns += [
         path("account/become/", become, name="become"),
diff --git a/hypha/apply/users/utils.py b/hypha/apply/users/utils.py
index e46c33ead6d40ac669555dd3114c76dee09bcc0a..6f544ea3174d5680d850a97891395b19abea3e44 100644
--- a/hypha/apply/users/utils.py
+++ b/hypha/apply/users/utils.py
@@ -1,9 +1,12 @@
+import string
+
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth.tokens import PasswordResetTokenGenerator
 from django.core.mail import send_mail
 from django.template.loader import render_to_string
 from django.urls import reverse
+from django.utils.crypto import get_random_string
 from django.utils.encoding import force_bytes
 from django.utils.http import url_has_allowed_host_and_scheme, urlsafe_base64_encode
 from django.utils.translation import gettext_lazy as _
@@ -53,7 +56,13 @@ def can_use_oauth_check(user):
     return False
 
 
-def send_activation_email(user, site=None, redirect_url=""):
+def send_activation_email(
+    user,
+    site=None,
+    email_template="users/activation/email.txt",
+    email_subject_template="users/activation/email_subject.txt",
+    redirect_url="",
+):
     """
     Send the activation email. The activation key is the username,
     signed using TimestampSigner.
@@ -82,10 +91,10 @@ def send_activation_email(user, site=None, redirect_url=""):
     if site:
         context.update(site=site)
 
-    subject = "Account details for {username} at {org_long_name}".format(**context)
+    subject = render_to_string(email_subject_template, context)
     # Force subject to a single line to avoid header-injection issues.
     subject = "".join(subject.splitlines())
-    message = render_to_string("users/activation/email.txt", context)
+    message = render_to_string(email_template, context)
     user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
 
 
@@ -157,3 +166,11 @@ def get_redirect_url(
         require_https=request.is_secure(),
     )
     return redirect_to if url_is_safe else ""
+
+
+def generate_numeric_token(length=6):
+    """
+    Generate a random 6 digit string of numbers.
+    We use this formatting to allow leading 0s.
+    """
+    return get_random_string(length, allowed_chars=string.digits)
diff --git a/hypha/apply/users/views.py b/hypha/apply/users/views.py
index 990f3279f80a05ae1d41d7695708ab34421b6982..9a9877c58b7d9117a0f2871a5a1155efd6acf8c9 100644
--- a/hypha/apply/users/views.py
+++ b/hypha/apply/users/views.py
@@ -1,4 +1,5 @@
 import datetime
+import time
 from typing import Any
 from urllib.parse import urlencode
 
@@ -15,12 +16,13 @@ from django.contrib.auth.views import (
 from django.contrib.auth.views import PasswordResetView as DjPasswordResetView
 from django.contrib.sites.shortcuts import get_current_site
 from django.core.exceptions import PermissionDenied
-from django.core.signing import BadSignature, Signer, TimestampSigner, dumps, loads
-from django.http import HttpResponseRedirect
+from django.core.signing import TimestampSigner, dumps, loads
+from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import Http404, get_object_or_404, redirect, render, resolve_url
 from django.template.loader import render_to_string
 from django.template.response import TemplateResponse
 from django.urls import reverse, reverse_lazy
+from django.utils import timezone
 from django.utils.decorators import method_decorator
 from django.utils.encoding import force_str
 from django.utils.http import urlsafe_base64_decode
@@ -30,9 +32,12 @@ from django.views.decorators.debug import sensitive_post_parameters
 from django.views.generic import UpdateView
 from django.views.generic.base import TemplateView, View
 from django.views.generic.edit import FormView
+from django_htmx.http import HttpResponseClientRedirect
 from django_otp import devices_for_user
 from django_ratelimit.decorators import ratelimit
 from elevate.mixins import ElevateMixin
+from elevate.utils import grant_elevated_privileges
+from elevate.views import redirect_to_elevate
 from hijack.views import AcquireUserView
 from two_factor.forms import AuthenticationTokenForm, BackupTokenForm
 from two_factor.utils import default_device, get_otpauth_url, totp_digits
@@ -45,17 +50,26 @@ from wagtail.models import Site
 from wagtail.users.views.users import change_user_perm
 
 from hypha.apply.home.models import ApplyHomePage
+from hypha.core.mail import MarkdownMail
 
 from .decorators import require_oauth_whitelist
 from .forms import (
     BecomeUserForm,
     CustomAuthenticationForm,
     CustomUserCreationForm,
-    EmailChangePasswordForm,
+    PasswordlessAuthForm,
     ProfileForm,
     TWOFAPasswordForm,
 )
-from .utils import get_redirect_url, send_confirmation_email
+from .models import ConfirmAccessToken, PendingSignup
+from .services import PasswordlessAuthService
+from .tokens import PasswordlessLoginTokenGenerator, PasswordlessSignupTokenGenerator
+from .utils import (
+    generate_numeric_token,
+    get_redirect_url,
+    send_activation_email,
+    send_confirmation_email,
+)
 
 User = get_user_model()
 
@@ -72,11 +86,11 @@ class RegisterView(View):
         # We keep /register in the urls in order to test (where we turn on/off
         # the setting per test), but when disabled, we want to pretend it doesn't
         # exist va 404
-        if not settings.ENABLE_REGISTRATION_WITHOUT_APPLICATION:
+        if not settings.ENABLE_PUBLIC_SIGNUP:
             raise Http404
 
         if request.user.is_authenticated:
-            return redirect("dashboard:dashboard")
+            return redirect(settings.LOGIN_REDIRECT_URL)
 
         ctx = {
             "form": self.form(),
@@ -86,7 +100,7 @@ class RegisterView(View):
 
     def post(self, request):
         # See comment in get() above about doing this here rather than in urls
-        if not settings.ENABLE_REGISTRATION_WITHOUT_APPLICATION:
+        if not settings.ENABLE_PUBLIC_SIGNUP:
             raise Http404
 
         form = self.form(data=request.POST)
@@ -136,6 +150,10 @@ class LoginView(TwoFactorLoginView):
         ("backup", BackupTokenForm),
     )
 
+    redirect_field_name = "next"
+    redirect_authenticated_user = True
+    template_name = "users/login.html"
+
     def get_context_data(self, form, **kwargs):
         context_data = super(LoginView, self).get_context_data(form, **kwargs)
         context_data["is_public_site"] = True
@@ -158,28 +176,28 @@ class AccountView(UpdateView):
     def get_object(self):
         return self.request.user
 
+    def get_form_kwargs(self) -> dict[str, Any]:
+        kwargs = super().get_form_kwargs()
+        kwargs["request"] = self.request
+        return kwargs
+
     def form_valid(self, form):
         updated_email = form.cleaned_data["email"]
         name = form.cleaned_data["full_name"]
         slack = form.cleaned_data.get("slack", "")
         user = get_object_or_404(User, id=self.request.user.id)
-        if updated_email:
+        if user.email != updated_email:
             base_url = reverse("users:email_change_confirm_password")
             query_dict = {"updated_email": updated_email, "name": name, "slack": slack}
 
             signer = TimestampSigner()
             signed_value = signer.sign(dumps(query_dict))
-            # Using session variables for redirect validation
-            token_signer = Signer()
-            self.request.session["signed_token"] = token_signer.sign(user.email)
             return redirect(
                 "{}?{}".format(base_url, urlencode({"value": signed_value}))
             )
-        return super(AccountView, self).form_valid(form)
+        return super().form_valid(form)
 
-    def get_success_url(
-        self,
-    ):
+    def get_success_url(self):
         return reverse_lazy("users:account")
 
     def get_context_data(self, **kwargs):
@@ -200,72 +218,54 @@ class AccountView(UpdateView):
         )
 
 
-@method_decorator(login_required, name="dispatch")
-class EmailChangePasswordView(FormView):
-    form_class = EmailChangePasswordForm
-    template_name = "users/email_change/confirm_password.html"
-    success_url = reverse_lazy("users:confirm_link_sent")
-    title = _("Enter Password")
+@login_required
+def account_email_change(request):
+    if request.user.has_usable_password() and not request.is_elevated():
+        return redirect_to_elevate(request.get_full_path())
 
-    def get_initial(self):
-        """
-        Validating the redirection from account via session variable
-        """
-        if "signed_token" not in self.request.session:
-            raise Http404
-        signer = Signer()
-        try:
-            signer.unsign(self.request.session["signed_token"])
-        except BadSignature as e:
-            raise Http404 from e
-        return super(EmailChangePasswordView, self).get_initial()
+    signer = TimestampSigner()
+    try:
+        unsigned_value = signer.unsign(
+            request.GET.get("value"), max_age=settings.PASSWORD_PAGE_TIMEOUT
+        )
+    except Exception:
+        messages.error(
+            request,
+            _("Password Page timed out. Try changing the email again."),
+        )
+        return redirect("users:account")
+    value = loads(unsigned_value)
 
-    def get_form_kwargs(self):
-        kwargs = super().get_form_kwargs()
-        kwargs["user"] = self.request.user
-        return kwargs
+    if slack := value["slack"] is not None:
+        request.user.slack = slack
 
-    def form_valid(self, form):
-        # Make sure redirection url is inaccessible after email is sent
-        if "signed_token" in self.request.session:
-            del self.request.session["signed_token"]
-        signer = TimestampSigner()
-        try:
-            unsigned_value = signer.unsign(
-                self.request.GET.get("value"), max_age=settings.PASSWORD_PAGE_TIMEOUT
-            )
-        except Exception:
-            messages.error(
-                self.request,
-                _("Password Page timed out. Try changing the email again."),
-            )
-            return redirect("users:account")
-        value = loads(unsigned_value)
-        form.save(**value)
-        user = self.request.user
-        if user.email != value["updated_email"]:
-            send_confirmation_email(
-                user,
-                signer.sign(dumps(value["updated_email"])),
-                updated_email=value["updated_email"],
-                site=Site.find_for_request(self.request),
-            )
-        # alert email
-        user.email_user(
-            subject="Alert! An attempt to update your email.",
-            message=render_to_string(
-                "users/email_change/update_info_email.html",
-                {
-                    "name": user.get_full_name(),
-                    "username": user.get_username(),
-                    "org_email": settings.ORG_EMAIL,
-                    "org_short_name": settings.ORG_SHORT_NAME,
-                    "org_long_name": settings.ORG_LONG_NAME,
-                },
-            ),
-            from_email=settings.DEFAULT_FROM_EMAIL,
+    request.user.full_name = value["name"]
+    request.user.save()
+
+    if request.user.email != value["updated_email"]:
+        send_confirmation_email(
+            request.user,
+            signer.sign(dumps(value["updated_email"])),
+            updated_email=value["updated_email"],
+            site=Site.find_for_request(request),
         )
-        return super(EmailChangePasswordView, self).form_valid(form)
+
+    # alert email
+    request.user.email_user(
+        subject="Alert! An attempt to update your email.",
+        message=render_to_string(
+            "users/email_change/update_info_email.html",
+            {
+                "name": request.user.get_full_name(),
+                "username": request.user.get_username(),
+                "org_email": settings.ORG_EMAIL,
+                "org_short_name": settings.ORG_SHORT_NAME,
+                "org_long_name": settings.ORG_LONG_NAME,
+            },
+        ),
+        from_email=settings.DEFAULT_FROM_EMAIL,
+    )
+    return redirect("users:confirm_link_sent")
 
 
 @method_decorator(login_required, name="dispatch")
@@ -349,10 +349,7 @@ class ActivationView(TemplateView):
         if self.valid(user, kwargs.get("token")):
             user.backend = settings.CUSTOM_AUTH_BACKEND
             login(request, user)
-            if (
-                settings.WAGTAILUSERS_PASSWORD_ENABLED
-                and settings.ENABLE_REGISTRATION_WITHOUT_APPLICATION
-            ):
+            if settings.WAGTAILUSERS_PASSWORD_ENABLED and settings.ENABLE_PUBLIC_SIGNUP:
                 # In this case, the user entered a password while registering,
                 # and so they shouldn't need to activate a password
                 return redirect("users:account")
@@ -496,7 +493,7 @@ class TWOFASetupView(TwoFactorSetupView):
     name="dispatch",
 )
 @method_decorator(login_required, name="dispatch")
-class TWOFADisableView(TwoFactorDisableView):
+class TWOFADisableView(ElevateMixin, TwoFactorDisableView):
     """
     View for disabling two-factor for a user's account.
     """
@@ -550,8 +547,18 @@ class TWOFAAdminDisableView(FormView):
         return ctx
 
 
-class TWOFARequiredMessageView(TemplateView):
-    template_name = "two_factor/core/two_factor_required.html"
+def mfa_failure_view(
+    request, reason, template_name="two_factor/core/two_factor_required.html"
+):
+    """Renders a template asking the user to setup 2FA.
+
+    Used by hypha.apply.users.middlewares.TwoFactorAuthenticationMiddleware,
+    if ENFORCE_TWO_FACTOR is enabled.
+    """
+    ctx = {
+        "reason": reason,
+    }
+    return render(request, template_name, ctx)
 
 
 class BackupTokensView(ElevateMixin, TwoFactorBackupTokensView):
@@ -611,3 +618,228 @@ class PasswordResetConfirmView(DjPasswordResetConfirmView):
                         redirect_url = f"{redirect_url}?next={next_path}"
 
                     return HttpResponseRedirect(redirect_url)
+
+
+@method_decorator(
+    ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST"),
+    name="dispatch",
+)
+@method_decorator(
+    ratelimit(key="post:email", rate=settings.DEFAULT_RATE_LIMIT, method="POST"),
+    name="dispatch",
+)
+class PasswordLessLoginSignupView(FormView):
+    """This view is used to collect the email address for passwordless login/signup.
+
+    If the email address is already associated with an account, an email is sent. If not,
+    if the registration is enabled an email is sent, to allow the user to create an account.
+
+    NOTE: This view should never expose whether an email address is associated with an account.
+    """
+
+    template_name = "users/passwordless_login_signup.html"
+    redirect_field_name = "next"
+    http_method_names = ["get", "post"]
+    form_class = PasswordlessAuthForm
+
+    def get(self, request, *args, **kwargs):
+        if request.user.is_authenticated:
+            return redirect(settings.LOGIN_REDIRECT_URL)
+
+        return super().get(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
+        ctx = super().get_context_data(**kwargs)
+        if self.request.htmx:
+            ctx["base_template"] = "includes/_partial-main.html"
+        else:
+            ctx["base_template"] = "base-apply.html"
+        ctx["redirect_url"] = get_redirect_url(self.request, self.redirect_field_name)
+        return ctx
+
+    def post(self, request):
+        form = self.get_form()
+        if form.is_valid():
+            service = PasswordlessAuthService(
+                request, redirect_field_name=self.redirect_field_name
+            )
+
+            email = form.cleaned_data["email"]
+            service.initiate_login_signup(email=email)
+
+            return TemplateResponse(
+                self.request,
+                "users/partials/passwordless_login_signup_sent.html",
+                self.get_context_data(),
+            )
+        else:
+            return self.render_to_response(self.get_context_data(form=form))
+
+
+class PasswordlessLoginView(LoginView):
+    """This view is used to capture the passwordless login token and log the user in.
+
+    If the token is valid, the user is logged in and redirected to the dashboard.
+    If the token is invalid, the user is shown invalid token page.
+
+    This view inherits from LoginView to reuse the 2FA views, if a mfa device is added
+    to the user.
+    """
+
+    def get(self, request, uidb64, token, *args, **kwargs):
+        try:
+            user = User.objects.get(pk=force_str(urlsafe_base64_decode(uidb64)))
+        except (TypeError, ValueError, OverflowError, User.DoesNotExist):
+            user = None
+
+        if user and self.check_token(user, token):
+            user.backend = settings.CUSTOM_AUTH_BACKEND
+
+            if default_device(user):
+                # User has mfa, set the user details and redirect to 2fa login
+                self.storage.reset()
+                self.storage.authenticated_user = user
+                self.storage.data["authentication_time"] = int(time.time())
+                return self.render_goto_step("token")
+
+            # No mfa, log the user in
+            login(request, user)
+
+            if redirect_url := get_redirect_url(request, self.redirect_field_name):
+                return redirect(redirect_url)
+
+            return redirect("dashboard:dashboard")
+
+        return render(request, "users/activation/invalid.html")
+
+    def check_token(self, user, token):
+        token_generator = PasswordlessLoginTokenGenerator()
+        return token_generator.check_token(user, token)
+
+
+class PasswordlessSignupView(TemplateView):
+    """This view is used to capture the passwordless login token and log the user in.
+
+    If the token is valid, the user is logged in and redirected to the dashboard.
+    If the token is invalid, the user is shown invalid token page.
+    """
+
+    redirect_field_name = "next"
+
+    def get(self, request, *args, **kwargs):
+        pending_signup = self.get_pending_signup(kwargs.get("uidb64"))
+        token = kwargs.get("token")
+        token_generator = PasswordlessSignupTokenGenerator()
+
+        if pending_signup and token_generator.check_token(pending_signup, token):
+            user = User.objects.create(email=pending_signup.email, is_active=True)
+            user.set_unusable_password()
+            user.save()
+            pending_signup.delete()
+
+            user.backend = settings.CUSTOM_AUTH_BACKEND
+            login(request, user)
+
+            redirect_url = get_redirect_url(request, self.redirect_field_name)
+
+            if redirect_url:
+                return redirect(redirect_url)
+
+            # If 2FA is enabled, redirect to setup page instead of dashboard
+            if settings.ENFORCE_TWO_FACTOR:
+                redirect_url = redirect_url or reverse("dashboard:dashboard")
+                return redirect(reverse("two_factor:setup") + f"?next={redirect_url}")
+
+            return redirect("dashboard:dashboard")
+
+        return render(request, "users/activation/invalid.html")
+
+    def get_pending_signup(self, uidb64):
+        """
+        Given the verified uid, look up and return the corresponding user
+        account if it exists, or `None` if it doesn't.
+        """
+        try:
+            return PendingSignup.objects.get(
+                **{"pk": force_str(urlsafe_base64_decode(uidb64))}
+            )
+        except (TypeError, ValueError, OverflowError, PendingSignup.DoesNotExist):
+            return None
+
+
+@login_required
+def send_confirm_access_email_view(request):
+    """Sends email with link to login in an elevated mode."""
+    token_obj, _ = ConfirmAccessToken.objects.update_or_create(
+        user=request.user, token=generate_numeric_token
+    )
+    email_context = {
+        "org_long_name": settings.ORG_LONG_NAME,
+        "org_email": settings.ORG_EMAIL,
+        "org_short_name": settings.ORG_SHORT_NAME,
+        "token": token_obj.token,
+        "username": request.user.email,
+        "site": Site.find_for_request(request),
+        "user": request.user,
+        "timeout_minutes": settings.PASSWORDLESS_LOGIN_TIMEOUT // 60,
+    }
+    subject = "Confirmation code for {org_long_name}: {token}".format(**email_context)
+    email = MarkdownMail("users/emails/confirm_access.md")
+    email.send(
+        to=request.user.email,
+        subject=subject,
+        from_email=settings.DEFAULT_FROM_EMAIL,
+        context=email_context,
+    )
+    return render(
+        request,
+        "users/partials/confirmation_code_sent.html",
+        {"redirect_url": get_redirect_url(request, "next")},
+    )
+
+
+@never_cache
+@login_required
+@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT)
+def elevate_check_code_view(request):
+    """Checks if the code is correct and if so, elevates the user session."""
+    token = request.POST.get("code")
+
+    def validate_token_and_age(token):
+        try:
+            token_obj = ConfirmAccessToken.objects.get(user=request.user, token=token)
+            token_age_in_seconds = (timezone.now() - token_obj.modified).total_seconds()
+            if token_age_in_seconds <= settings.PASSWORDLESS_LOGIN_TIMEOUT:
+                token_obj.delete()
+                return True
+        except ConfirmAccessToken.DoesNotExist:
+            return False
+
+    redirect_url = get_redirect_url(request, "next")
+    if token and validate_token_and_age(token):
+        grant_elevated_privileges(request)
+        return HttpResponseClientRedirect(redirect_url)
+
+    return render(
+        request,
+        "users/partials/confirmation_code_sent.html",
+        {"error": True, "redirect_url": redirect_url},
+    )
+
+
+@login_required
+def set_password_view(request):
+    """Sends email with link to set password to user that doesn't have usable password.
+
+    This will the case when the user signed up using passwordless signup or using oauth.
+    """
+    site = Site.find_for_request(request)
+
+    if not request.user.has_usable_password():
+        send_activation_email(
+            user=request.user,
+            site=site,
+            email_template="users/emails/set_password.txt",
+            email_subject_template="users/emails/set_password_subject.txt",
+        )
+        return HttpResponse("✓ Check your email for password set link.")
diff --git a/hypha/core/context_processors.py b/hypha/core/context_processors.py
index 88467023a0dfd2fa03284d77eb4c5cfe2c0ebbe7..d3a1cccf7be64f8581267ca15639268080323f1b 100644
--- a/hypha/core/context_processors.py
+++ b/hypha/core/context_processors.py
@@ -12,7 +12,7 @@ def global_vars(request):
         "ORG_GUIDE_URL": settings.ORG_GUIDE_URL,
         "ORG_URL": settings.ORG_URL,
         "GOOGLE_OAUTH2": settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY,
-        "ENABLE_REGISTRATION_WITHOUT_APPLICATION": settings.ENABLE_REGISTRATION_WITHOUT_APPLICATION,
+        "ENABLE_PUBLIC_SIGNUP": settings.ENABLE_PUBLIC_SIGNUP,
         "ENABLE_GOOGLE_TRANSLATE": settings.ENABLE_GOOGLE_TRANSLATE,
         "SENTRY_TRACES_SAMPLE_RATE": settings.SENTRY_TRACES_SAMPLE_RATE,
         "SENTRY_ENVIRONMENT": settings.SENTRY_ENVIRONMENT,
diff --git a/hypha/core/utils.py b/hypha/core/utils.py
index 0b3ac7295d14483d15ed9cf009e2e57f08d3a058..a07009f6ef11fa97e17ac6f54f3de42cda2d34b3 100644
--- a/hypha/core/utils.py
+++ b/hypha/core/utils.py
@@ -19,6 +19,6 @@ def markdown_to_html(text: str) -> str:
         escape=False,
         hard_wrap=True,
         renderer="html",
-        plugins=["strikethrough", "footnotes", "table"],
+        plugins=["strikethrough", "footnotes", "table", "url"],
     )
     return md(text)
diff --git a/hypha/public/home/templates/home/home_page.html b/hypha/public/home/templates/home/home_page.html
index a6ee50260e26f88beee258f2c154c5ca9e990c62..2f93125a3e409794166cdf0882343aaa0219fd0c 100644
--- a/hypha/public/home/templates/home/home_page.html
+++ b/hypha/public/home/templates/home/home_page.html
@@ -73,7 +73,7 @@
 
             <div class="header__button-container">
                 {% include "utils/includes/login_button.html" %}
-                {% if ENABLE_REGISTRATION_WITHOUT_APPLICATION %}
+                {% if ENABLE_PUBLIC_SIGNUP %}
                     {% include "utils/includes/register_button.html" %}
                 {% endif %}
             </div>
diff --git a/hypha/public/navigation/templates/navigation/primarynav-apply.html b/hypha/public/navigation/templates/navigation/primarynav-apply.html
index 0980776acb4ffaf2606d912b74fa15fca2b94516..c8b22b352f6290a05a9ed878d35a12a73d245505 100644
--- a/hypha/public/navigation/templates/navigation/primarynav-apply.html
+++ b/hypha/public/navigation/templates/navigation/primarynav-apply.html
@@ -1,15 +1,14 @@
 {% if request.user.is_authenticated %}
     <nav role="navigation" aria-label="Primary" class="w-full">
         <ul class="nav nav--primary" role="menubar">
-            {% if request.user.is_apply_staff %}
+            {% if request.user.can_access_dashboard %}
                 {% include "navigation/primarynav-apply-item.html" with name="My dashboard" url="dashboard:dashboard" %}
+            {% endif %}
+            {% if request.user.is_apply_staff %}
                 {% include "navigation/primarynav-apply-item.html" with name="Submissions" url="funds:submissions:overview" %}
                 {% include "navigation/primarynav-apply-item.html" with name="Projects" url="apply:projects:overview" %}
             {% elif request.user.is_finance or request.user.is_contracting %}
-                {% include "navigation/primarynav-apply-item.html" with name="My dashboard" url="dashboard:dashboard" %}
                 {% include "navigation/primarynav-apply-item.html" with name="Projects" url="apply:projects:overview" %}
-            {% else %}
-                {% include "navigation/primarynav-apply-item.html" with name="My dashboard" url="dashboard:dashboard" %}
             {% endif %}
         </ul>
     </nav>
diff --git a/hypha/public/utils/templates/utils/includes/login_button.html b/hypha/public/utils/templates/utils/includes/login_button.html
index 37fa3b165450795281550edff9baebde90196853..328eff33ba58c4ffd45d172b363ec5fbb42914a0 100644
--- a/hypha/public/utils/templates/utils/includes/login_button.html
+++ b/hypha/public/utils/templates/utils/includes/login_button.html
@@ -1,9 +1,30 @@
 {% load i18n %}
-<a href="{{ APPLY_SITE.root_url }}{% url 'users_public:login' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" class="button button--transparent button--contains-icons {{ class }}">
-    <svg class="icon icon--person"><use xlink:href="#person-icon"></use></svg>
-    {% if user.is_authenticated %}
-        My {{ ORG_SHORT_NAME }}
+
+{% if user.is_authenticated %}
+    {% if user.can_access_dashboard %}
+        <a
+            class="button button--transparent button--contains-icons {{ class }}"
+            href="{{ APPLY_SITE.root_url }}{% url 'dashboard:dashboard' %}"
+        >
+            <svg class="icon icon--person"><use xlink:href="#person-icon"></use></svg>
+            My {{ ORG_SHORT_NAME }}
+        </a>
     {% else %}
-        {% trans "Login" %}
+        <a
+            class="button button--transparent button--contains-icons {{ class }}"
+            href="{{ APPLY_SITE.root_url }}{% url 'users:account' %}"
+            title="Goto your account"
+        >
+            <svg class="icon icon--person"><use xlink:href="#person-icon"></use></svg>
+            {{ user }}
+        </a>
     {% endif %}
-</a>
+{% else %}
+    <a
+        class="button button--transparent button--contains-icons {{ class }}"
+        href="{{ APPLY_SITE.root_url }}{% url 'users_public:passwordless_login_signup' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}"
+    >
+        <svg class="icon icon--person"><use xlink:href="#person-icon"></use></svg>
+        {% trans "Login" %}
+    </a>
+{% endif %}
diff --git a/hypha/public/utils/templates/utils/includes/register_button.html b/hypha/public/utils/templates/utils/includes/register_button.html
index 29e10dd26a74120e40d54f491d3e38398947c496..648fb72b04833bd0ba466b5bed78184b147af02d 100644
--- a/hypha/public/utils/templates/utils/includes/register_button.html
+++ b/hypha/public/utils/templates/utils/includes/register_button.html
@@ -1,4 +1,4 @@
 {% load i18n %}
 <a href="{{ APPLY_SITE.root_url }}{% url 'users_public:register' %}{% if redirect_url %}?next={{ redirect_url }}{% endif %}" class="button button--transparent {{ class }}">
-    {% trans "Register" %}
+    {% trans "Sign up" %}
 </a>
diff --git a/hypha/settings/base.py b/hypha/settings/base.py
index 818cc03457032fda9dea0f2a77694f210658e239..73df7a87df1d8f949b42f88397eb434e9b7e25ae 100644
--- a/hypha/settings/base.py
+++ b/hypha/settings/base.py
@@ -136,14 +136,6 @@ TRANSITION_AFTER_ASSIGNED = env.bool("TRANSITION_AFTER_ASSIGNED", False)
 # Possible values are: False, 1,2,3,…
 TRANSITION_AFTER_REVIEWS = env.bool("TRANSITION_AFTER_REVIEWS", False)
 
-# Forces users to log in first in order to make an application.  This is particularly useful in conjunction
-# with ENABLE_REGISTRATION_WITHOUT_APPLICATION
-FORCE_LOGIN_FOR_APPLICATION = env.bool("FORCE_LOGIN_FOR_APPLICATION", False)
-
-# Enable users to create accounts without submitting an application.
-ENABLE_REGISTRATION_WITHOUT_APPLICATION = env.bool(
-    "ENABLE_REGISTRATION_WITHOUT_APPLICATION", False
-)
 
 # Project settings.
 
@@ -172,11 +164,24 @@ LANGUAGE_CODE = env.str("LANGUAGE_CODE", "en")
 # Number of seconds that password reset and account activation links are valid (default 259200, 3 days).
 PASSWORD_RESET_TIMEOUT = env.int("PASSWORD_RESET_TIMEOUT", 259200)
 
+# Timeout for passwordless login links (default 900, 15 minutes).
+PASSWORDLESS_LOGIN_TIMEOUT = env.int("PASSWORDLESS_LOGIN_TIMEOUT", 900)  # 15 minutes
+
+# Enable users to create accounts without submitting an application.
+ENABLE_PUBLIC_SIGNUP = env.bool("ENABLE_PUBLIC_SIGNUP", True)
+
+# Forces users to log in first in order to make an application.  This is particularly useful in conjunction
+# with ENABLE_PUBLIC_SIGNUP
+# @deprecated: This setting is deprecated and will be removed in a future release.
+FORCE_LOGIN_FOR_APPLICATION = env.bool("FORCE_LOGIN_FOR_APPLICATION", True)
+
+# Timeout for passwordless signup links (default 900, 15 minutes).
+PASSWORDLESS_SIGNUP_TIMEOUT = env.int("PASSWORDLESS_SIGNUP_TIMEOUT", 900)  # 15 minutes
+
 # Seconds to enter password on password page while email change/2FA change (default 120).
 PASSWORD_PAGE_TIMEOUT = env.int("PASSWORD_PAGE_TIMEOUT", 120)
 
 #  Template engines and options to be used with Django.
-
 TEMPLATES = [
     {
         "BACKEND": "django.template.backends.django.DjangoTemplates",
@@ -280,7 +285,7 @@ MEDIA_URL = env.str("MEDIA_URL", "/media/")
 # Wagtail settings
 
 WAGTAIL_CACHE_TIMEOUT = CACHE_CONTROL_MAX_AGE
-WAGTAIL_FRONTEND_LOGIN_URL = "/login/"
+WAGTAIL_FRONTEND_LOGIN_URL = "/auth/"
 WAGTAIL_SITE_NAME = "hypha"
 WAGTAILIMAGES_IMAGE_MODEL = "images.CustomImage"
 WAGTAILIMAGES_FEATURE_DETECTION_ENABLED = False
diff --git a/hypha/settings/django.py b/hypha/settings/django.py
index 7d9ca32d6268e36ef5d2b9c75d3062d85867f3df..9418a797e90da8f6dfb461ba11c5c99962968800 100644
--- a/hypha/settings/django.py
+++ b/hypha/settings/django.py
@@ -203,7 +203,7 @@ DATETIME_INPUT_FORMATS = [
 
 AUTH_USER_MODEL = "users.User"
 
-LOGIN_URL = "users_public:login"
+LOGIN_URL = "users_public:passwordless_login_signup"
 LOGIN_REDIRECT_URL = "dashboard:dashboard"
 
 # https://django-elevate.readthedocs.io/en/latest/config/index.html#configuration
diff --git a/hypha/settings/test.py b/hypha/settings/test.py
index 3b1e3bf01b7c9d10628b21e66de1864f7471c0b9..51ab2af72b2745ea386888c305bbd8caff48c6f6 100644
--- a/hypha/settings/test.py
+++ b/hypha/settings/test.py
@@ -30,3 +30,5 @@ TEMPLATES[0]["OPTIONS"]["debug"] = True
 
 # An extra salt to be added into the cookie signature.
 ELEVATE_COOKIE_SALT = SECRET_KEY
+
+ENFORCE_TWO_FACTOR = False
diff --git a/hypha/static_src/src/sass/apply/abstracts/_mixins.scss b/hypha/static_src/src/sass/apply/abstracts/_mixins.scss
index 36660810b28f278076114b1630d7c3683993456e..4a7e42257d17d90d03f87b229009b7f0f22ed488 100644
--- a/hypha/static_src/src/sass/apply/abstracts/_mixins.scss
+++ b/hypha/static_src/src/sass/apply/abstracts/_mixins.scss
@@ -85,7 +85,7 @@
 
 // Button mixin
 @mixin button($bg, $hover-bg) {
-    padding: 0.5em 50px;
+    padding: 0.5em 2rem;
     font-weight: $weight--bold;
     color: $color--white;
     text-align: center;
diff --git a/hypha/static_src/src/sass/apply/components/_form.scss b/hypha/static_src/src/sass/apply/components/_form.scss
index 6dd2417475800bed4af6dbd78e245c8675a3740d..0305fa36b3fbcf8cf310663fe2315c995119d93c 100644
--- a/hypha/static_src/src/sass/apply/components/_form.scss
+++ b/hypha/static_src/src/sass/apply/components/_form.scss
@@ -32,6 +32,18 @@
         }
     }
 
+    &--error-inline {
+        // stylelint-disable-next-line selector-class-pattern
+        .form__error-text {
+            position: relative;
+            max-width: 100%;
+
+            &::before {
+                display: none;
+            }
+        }
+    }
+
     &__group {
         position: relative;
         margin-top: 0.5rem;
diff --git a/hypha/static_src/src/sass/apply/components/_two-factor.scss b/hypha/static_src/src/sass/apply/components/_two-factor.scss
index 410b24f6b73fd5363d7c967c677c48ee04e9e806..c408502e6bb063d20a265ecb8ece4eedfdbfef27 100644
--- a/hypha/static_src/src/sass/apply/components/_two-factor.scss
+++ b/hypha/static_src/src/sass/apply/components/_two-factor.scss
@@ -24,24 +24,6 @@ label[for="id_generator-token"] {
     font-size: 1.2em;
 }
 
-#list-backup-tokens {
-    border: $color--mid-grey;
-    padding: 1em;
-    line-height: 1.4em;
-    font-size: larger;
-    font-family: monospace;
-    resize: none;
-    font-style: bold;
-}
-
-.d-none {
-    display: none;
-}
-
-.bg-white {
-    background-color: $color--white;
-}
-
 // 2FA token field.
 #id_generator-token {
     -moz-appearance: textfield;
diff --git a/hypha/templates/base-apply.html b/hypha/templates/base-apply.html
index 4982053b5aa3053d3c696be010eb80b984fd6982..233a893e955ab0198a7135029f078d018e36bc2d 100644
--- a/hypha/templates/base-apply.html
+++ b/hypha/templates/base-apply.html
@@ -167,8 +167,10 @@
                             {% trans "Log out" %}
                         </a>
                     {% else %}
-                        {% include "utils/includes/login_button.html" %}
-                        {% if ENABLE_REGISTRATION_WITHOUT_APPLICATION %}
+                        {% if request.path != '/auth/' %}
+                            {% include "utils/includes/login_button.html" %}
+                        {% endif %}
+                        {% if ENABLE_PUBLIC_SIGNUP and request.path != '/register/' %}
                             {% include "utils/includes/register_button.html" %}
                         {% endif %}
                     {% endif %}
diff --git a/hypha/templates/base.html b/hypha/templates/base.html
index 382565e0d3b6e29181bdb1aa33d8aa7cc0e759c8..eec53c3b83f5828ac1468002d6caf0478d29fb6d 100644
--- a/hypha/templates/base.html
+++ b/hypha/templates/base.html
@@ -1,4 +1,4 @@
-{% load static cache wagtailcore_tags wagtailimages_tags navigation_tags util_tags cookieconsent_tags %}<!doctype html>
+{% load static cache wagtailcore_tags wagtailimages_tags navigation_tags util_tags cookieconsent_tags i18n %}<!doctype html>
 {% wagtail_site as current_site %}
 <html class="no-js" lang="en">
     <head>
@@ -155,9 +155,13 @@
 
                     <div class="header__button-container">
                         {% include "utils/includes/login_button.html" %}
-                        {% if not request.user.is_authenticated and ENABLE_REGISTRATION_WITHOUT_APPLICATION %}
-                            {% include "utils/includes/register_button.html" %}
+
+                        {% if request.user.is_authenticated %}
+                            <a href="{% url 'users_public:logout' %}" class="button button--transparent button--narrow">
+                                {% trans "Log out" %}
+                            </a>
                         {% endif %}
+
                         {% if ENABLE_GOOGLE_TRANSLATE %}
                             <div class="button button--google-translate" id="google_translate_element"></div>
                         {% endif %}
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 162eec0ecc0e7cbef2a7a2208bb70922d9cc422d..43b82fdcf4dc71f52c1b2a6ac4c2550cb080bef0 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -5,6 +5,7 @@ coverage==7.3.2
 django-browser-reload==1.12.0
 django-coverage-plugin==3.1.0
 django-debug-toolbar==4.2.0
+django-dynamic-fixture==4.0.1
 djhtml==3.0.6
 dslr==0.4.0
 factory_boy==3.2.1
@@ -17,5 +18,6 @@ pytest-split==0.8.1
 pytest-xdist[psutil]==3.3.1
 responses==0.23.3
 ruff==0.1.1
+time-machine==2.13.0
 wagtail-factories==2.1.0
 Werkzeug==3.0.1
diff --git a/requirements.txt b/requirements.txt
index c2357250a8225407e3bbd2e3b52414268edf6df9..0ca6b57e96bc0cc60294d8c47cf286a2c299dad8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -46,7 +46,7 @@ python-docx<1.0.0
 htmldocx==0.0.6
 lark==1.1.7
 mailchimp3==3.0.17
-mistune==2.0.4
+mistune==3.0.1
 more-itertools==9.0.0
 phonenumberslite==8.13.23
 Pillow>=10.0.1