mirror of https://github.com/zulip/zulip.git
remote_billing: Implement confirmation flow for legacy servers.
For the last form (with Full Name and ToS consent field), this pretty shamelessly re-uses and directly renders the corporate/remote_realm_billing_finalize_login_confirmation.html template. That's probably good in terms of re-use, but calls for a clean-up commit that will generalize the name of this template and the classes/ids in the HTML.
This commit is contained in:
parent
bba02044f5
commit
abdfdeffe4
|
@ -4,7 +4,7 @@ __revision__ = "$Id: models.py 28 2009-10-22 15:03:02Z jarek.zgoda $"
|
||||||
import secrets
|
import secrets
|
||||||
from base64 import b32encode
|
from base64 import b32encode
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import List, Mapping, Optional, Union
|
from typing import List, Mapping, Optional, Union, cast
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -30,6 +30,9 @@ from zerver.models import (
|
||||||
UserProfile,
|
UserProfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.ZILENCER_ENABLED:
|
||||||
|
from zilencer.models import PreregistrationRemoteServerBillingUser
|
||||||
|
|
||||||
|
|
||||||
class ConfirmationKeyError(Exception):
|
class ConfirmationKeyError(Exception):
|
||||||
WRONG_LENGTH = 1
|
WRONG_LENGTH = 1
|
||||||
|
@ -56,7 +59,7 @@ def generate_key() -> str:
|
||||||
return b32encode(secrets.token_bytes(15)).decode().lower()
|
return b32encode(secrets.token_bytes(15)).decode().lower()
|
||||||
|
|
||||||
|
|
||||||
ConfirmationObjT: TypeAlias = Union[
|
NoZilencerConfirmationObjT: TypeAlias = Union[
|
||||||
MultiuseInvite,
|
MultiuseInvite,
|
||||||
PreregistrationRealm,
|
PreregistrationRealm,
|
||||||
PreregistrationUser,
|
PreregistrationUser,
|
||||||
|
@ -64,6 +67,11 @@ ConfirmationObjT: TypeAlias = Union[
|
||||||
UserProfile,
|
UserProfile,
|
||||||
RealmReactivationStatus,
|
RealmReactivationStatus,
|
||||||
]
|
]
|
||||||
|
ZilencerConfirmationObjT: TypeAlias = Union[
|
||||||
|
NoZilencerConfirmationObjT, "PreregistrationRemoteServerBillingUser"
|
||||||
|
]
|
||||||
|
|
||||||
|
ConfirmationObjT = Union[NoZilencerConfirmationObjT, ZilencerConfirmationObjT]
|
||||||
|
|
||||||
|
|
||||||
def get_object_from_key(
|
def get_object_from_key(
|
||||||
|
@ -130,6 +138,7 @@ def create_confirmation_link(
|
||||||
if no_associated_realm_object:
|
if no_associated_realm_object:
|
||||||
realm = None
|
realm = None
|
||||||
else:
|
else:
|
||||||
|
obj = cast(NoZilencerConfirmationObjT, obj)
|
||||||
assert not isinstance(obj, PreregistrationRealm)
|
assert not isinstance(obj, PreregistrationRealm)
|
||||||
realm = obj.realm
|
realm = obj.realm
|
||||||
|
|
||||||
|
@ -187,6 +196,7 @@ class Confirmation(models.Model):
|
||||||
MULTIUSE_INVITE = 6
|
MULTIUSE_INVITE = 6
|
||||||
REALM_CREATION = 7
|
REALM_CREATION = 7
|
||||||
REALM_REACTIVATION = 8
|
REALM_REACTIVATION = 8
|
||||||
|
REMOTE_SERVER_BILLING_LEGACY_LOGIN = 9
|
||||||
type = models.PositiveSmallIntegerField()
|
type = models.PositiveSmallIntegerField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -223,6 +233,10 @@ _properties = {
|
||||||
Confirmation.REALM_CREATION: ConfirmationType("get_prereg_key_and_redirect"),
|
Confirmation.REALM_CREATION: ConfirmationType("get_prereg_key_and_redirect"),
|
||||||
Confirmation.REALM_REACTIVATION: ConfirmationType("realm_reactivation"),
|
Confirmation.REALM_REACTIVATION: ConfirmationType("realm_reactivation"),
|
||||||
}
|
}
|
||||||
|
if settings.ZILENCER_ENABLED:
|
||||||
|
_properties[Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN] = ConfirmationType(
|
||||||
|
"remote_billing_legacy_server_from_login_confirmation_link"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str:
|
def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str:
|
||||||
|
|
|
@ -11,7 +11,7 @@ from typing_extensions import Concatenate, ParamSpec
|
||||||
from corporate.lib.remote_billing_util import (
|
from corporate.lib.remote_billing_util import (
|
||||||
RemoteBillingIdentityExpiredError,
|
RemoteBillingIdentityExpiredError,
|
||||||
get_remote_realm_from_session,
|
get_remote_realm_from_session,
|
||||||
get_remote_server_from_session,
|
get_remote_server_and_user_from_session,
|
||||||
)
|
)
|
||||||
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
|
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
|
||||||
from zerver.lib.exceptions import RemoteBillingAuthenticationError
|
from zerver.lib.exceptions import RemoteBillingAuthenticationError
|
||||||
|
@ -153,7 +153,14 @@ def authenticated_remote_server_management_endpoint(
|
||||||
raise TypeError("server_uuid must be a string") # nocoverage
|
raise TypeError("server_uuid must be a string") # nocoverage
|
||||||
|
|
||||||
try:
|
try:
|
||||||
remote_server = get_remote_server_from_session(request, server_uuid=server_uuid)
|
remote_server, remote_billing_user = get_remote_server_and_user_from_session(
|
||||||
|
request, server_uuid=server_uuid
|
||||||
|
)
|
||||||
|
if remote_billing_user is None:
|
||||||
|
# This should only be possible if the user hasn't finished the confirmation flow
|
||||||
|
# and doesn't have a fully authenticated session yet. They should not be attempting
|
||||||
|
# to access this endpoint yet.
|
||||||
|
raise RemoteBillingAuthenticationError
|
||||||
except (RemoteBillingIdentityExpiredError, RemoteBillingAuthenticationError):
|
except (RemoteBillingIdentityExpiredError, RemoteBillingAuthenticationError):
|
||||||
# In this flow, we can only redirect to our local "legacy server flow login" page.
|
# In this flow, we can only redirect to our local "legacy server flow login" page.
|
||||||
# That means that we can do it universally whether the user has an expired
|
# That means that we can do it universally whether the user has an expired
|
||||||
|
@ -167,7 +174,10 @@ def authenticated_remote_server_management_endpoint(
|
||||||
|
|
||||||
return HttpResponseRedirect(url)
|
return HttpResponseRedirect(url)
|
||||||
|
|
||||||
billing_session = RemoteServerBillingSession(remote_server)
|
assert remote_billing_user is not None
|
||||||
|
billing_session = RemoteServerBillingSession(
|
||||||
|
remote_server, remote_billing_user=remote_billing_user
|
||||||
|
)
|
||||||
return view_func(request, billing_session)
|
return view_func(request, billing_session)
|
||||||
|
|
||||||
return _wrapped_view_func
|
return _wrapped_view_func
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Literal, Optional, TypedDict, Union, cast
|
from typing import Literal, Optional, Tuple, TypedDict, Union, cast
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.timezone import now as timezone_now
|
from django.utils.timezone import now as timezone_now
|
||||||
|
@ -7,7 +7,7 @@ from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from zerver.lib.exceptions import JsonableError, RemoteBillingAuthenticationError
|
from zerver.lib.exceptions import JsonableError, RemoteBillingAuthenticationError
|
||||||
from zerver.lib.timestamp import datetime_to_timestamp
|
from zerver.lib.timestamp import datetime_to_timestamp
|
||||||
from zilencer.models import RemoteRealm, RemoteZulipServer
|
from zilencer.models import RemoteRealm, RemoteServerBillingUser, RemoteZulipServer
|
||||||
|
|
||||||
billing_logger = logging.getLogger("corporate.stripe")
|
billing_logger = logging.getLogger("corporate.stripe")
|
||||||
|
|
||||||
|
@ -39,6 +39,7 @@ class LegacyServerIdentityDict(TypedDict):
|
||||||
# to add more information as appropriate.
|
# to add more information as appropriate.
|
||||||
remote_server_uuid: str
|
remote_server_uuid: str
|
||||||
|
|
||||||
|
remote_billing_user_id: Optional[int]
|
||||||
authenticated_at: int
|
authenticated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
@ -128,12 +129,13 @@ def get_remote_realm_from_session(
|
||||||
return remote_realm
|
return remote_realm
|
||||||
|
|
||||||
|
|
||||||
def get_remote_server_from_session(
|
def get_remote_server_and_user_from_session(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
server_uuid: str,
|
server_uuid: str,
|
||||||
) -> RemoteZulipServer:
|
) -> Tuple[RemoteZulipServer, Optional[RemoteServerBillingUser]]:
|
||||||
identity_dict: Optional[LegacyServerIdentityDict] = get_identity_dict_from_session(
|
identity_dict = cast(
|
||||||
request, realm_uuid=None, server_uuid=server_uuid
|
Optional[LegacyServerIdentityDict],
|
||||||
|
get_identity_dict_from_session(request, realm_uuid=None, server_uuid=server_uuid),
|
||||||
)
|
)
|
||||||
|
|
||||||
if identity_dict is None:
|
if identity_dict is None:
|
||||||
|
@ -148,4 +150,15 @@ def get_remote_server_from_session(
|
||||||
if remote_server.deactivated:
|
if remote_server.deactivated:
|
||||||
raise JsonableError(_("Registration is deactivated"))
|
raise JsonableError(_("Registration is deactivated"))
|
||||||
|
|
||||||
return remote_server
|
remote_billing_user_id = identity_dict.get("remote_billing_user_id")
|
||||||
|
if remote_billing_user_id is None:
|
||||||
|
return remote_server, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
remote_billing_user = RemoteServerBillingUser.objects.get(
|
||||||
|
id=remote_billing_user_id, remote_server=remote_server
|
||||||
|
)
|
||||||
|
except RemoteServerBillingUser.DoesNotExist:
|
||||||
|
remote_billing_user = None
|
||||||
|
|
||||||
|
return remote_server, remote_billing_user
|
||||||
|
|
|
@ -61,6 +61,7 @@ from zilencer.models import (
|
||||||
RemoteRealm,
|
RemoteRealm,
|
||||||
RemoteRealmAuditLog,
|
RemoteRealmAuditLog,
|
||||||
RemoteRealmBillingUser,
|
RemoteRealmBillingUser,
|
||||||
|
RemoteServerBillingUser,
|
||||||
RemoteZulipServer,
|
RemoteZulipServer,
|
||||||
RemoteZulipServerAuditLog,
|
RemoteZulipServerAuditLog,
|
||||||
get_remote_realm_guest_and_non_guest_count,
|
get_remote_realm_guest_and_non_guest_count,
|
||||||
|
@ -3132,9 +3133,11 @@ class RemoteServerBillingSession(BillingSession): # nocoverage
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
remote_server: RemoteZulipServer,
|
remote_server: RemoteZulipServer,
|
||||||
|
remote_billing_user: Optional[RemoteServerBillingUser] = None,
|
||||||
support_staff: Optional[UserProfile] = None,
|
support_staff: Optional[UserProfile] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.remote_server = remote_server
|
self.remote_server = remote_server
|
||||||
|
self.remote_billing_user = remote_billing_user
|
||||||
if support_staff is not None:
|
if support_staff is not None:
|
||||||
assert support_staff.is_staff
|
assert support_staff.is_staff
|
||||||
self.support_session = True
|
self.support_session = True
|
||||||
|
|
|
@ -4,6 +4,7 @@ from unittest import mock
|
||||||
|
|
||||||
import responses
|
import responses
|
||||||
import time_machine
|
import time_machine
|
||||||
|
from django.conf import settings
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from django.utils.timezone import now as timezone_now
|
from django.utils.timezone import now as timezone_now
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
@ -18,7 +19,7 @@ from zerver.lib.remote_server import send_realms_only_to_push_bouncer
|
||||||
from zerver.lib.test_classes import BouncerTestCase
|
from zerver.lib.test_classes import BouncerTestCase
|
||||||
from zerver.lib.timestamp import datetime_to_timestamp
|
from zerver.lib.timestamp import datetime_to_timestamp
|
||||||
from zerver.models import UserProfile
|
from zerver.models import UserProfile
|
||||||
from zilencer.models import RemoteRealm, RemoteRealmBillingUser
|
from zilencer.models import RemoteRealm, RemoteRealmBillingUser, RemoteServerBillingUser
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
|
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
|
||||||
|
@ -360,6 +361,110 @@ class LegacyServerLoginTest(BouncerTestCase):
|
||||||
self.uuid = self.server.uuid
|
self.uuid = self.server.uuid
|
||||||
self.secret = self.server.api_key
|
self.secret = self.server.api_key
|
||||||
|
|
||||||
|
def execute_remote_billing_authentication_flow(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
full_name: str,
|
||||||
|
next_page: Optional[str] = None,
|
||||||
|
expect_tos: bool = True,
|
||||||
|
confirm_tos: bool = True,
|
||||||
|
return_without_clicking_confirmation_link: bool = False,
|
||||||
|
) -> "TestHttpResponse":
|
||||||
|
now = timezone_now()
|
||||||
|
with time_machine.travel(now, tick=False):
|
||||||
|
payload = {"server_org_id": self.uuid, "server_org_secret": self.secret}
|
||||||
|
if next_page is not None:
|
||||||
|
payload["next_page"] = next_page
|
||||||
|
result = self.client_post(
|
||||||
|
"/serverlogin/",
|
||||||
|
payload,
|
||||||
|
subdomain="selfhosting",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
self.assert_in_success_response(["Enter your email address"], result)
|
||||||
|
if next_page is not None:
|
||||||
|
self.assert_in_success_response(
|
||||||
|
[f'<input type="hidden" name="next_page" value="{next_page}" />'], result
|
||||||
|
)
|
||||||
|
self.assert_in_success_response([f'action="/serverlogin/{self.uuid!s}/confirm/"'], result)
|
||||||
|
|
||||||
|
# Verify the partially-authed data that should have been stored in the session. The flow
|
||||||
|
# isn't complete yet however, and this won't give the user access to authenticated endpoints,
|
||||||
|
# only allow them to proceed with confirmation.
|
||||||
|
identity_dict = LegacyServerIdentityDict(
|
||||||
|
remote_server_uuid=str(self.server.uuid),
|
||||||
|
authenticated_at=datetime_to_timestamp(now),
|
||||||
|
remote_billing_user_id=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.session["remote_billing_identities"][f"remote_server:{self.uuid!s}"],
|
||||||
|
identity_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {"email": email}
|
||||||
|
if next_page is not None:
|
||||||
|
payload["next_page"] = next_page
|
||||||
|
with time_machine.travel(now, tick=False):
|
||||||
|
result = self.client_post(
|
||||||
|
f"/serverlogin/{self.uuid!s}/confirm/",
|
||||||
|
payload,
|
||||||
|
subdomain="selfhosting",
|
||||||
|
)
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
self.assert_in_success_response(
|
||||||
|
["To complete the login process, check your email account", email], result
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmation_url = self.get_confirmation_url_from_outbox(
|
||||||
|
email,
|
||||||
|
url_pattern=(
|
||||||
|
f"{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}" + r"(\S+)>"
|
||||||
|
),
|
||||||
|
email_body_contains="Click the link below to complete the login process",
|
||||||
|
)
|
||||||
|
if return_without_clicking_confirmation_link:
|
||||||
|
return result
|
||||||
|
|
||||||
|
with time_machine.travel(now, tick=False):
|
||||||
|
result = self.client_get(confirmation_url, subdomain="selfhosting")
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
self.assert_in_success_response(
|
||||||
|
[f"Log in to Zulip server billing for {self.server.hostname}", email], result
|
||||||
|
)
|
||||||
|
self.assert_in_success_response([f'action="{confirmation_url}"'], result)
|
||||||
|
if expect_tos:
|
||||||
|
self.assert_in_success_response(["I agree", "Terms of Service"], result)
|
||||||
|
|
||||||
|
payload = {"full_name": full_name}
|
||||||
|
if confirm_tos:
|
||||||
|
payload["tos_consent"] = "true"
|
||||||
|
with time_machine.travel(now, tick=False):
|
||||||
|
result = self.client_post(confirmation_url, payload, subdomain="selfhosting")
|
||||||
|
if result.status_code >= 400:
|
||||||
|
# Early return for the caller to assert about the error.
|
||||||
|
return result
|
||||||
|
|
||||||
|
# The user should now be fully authenticated.
|
||||||
|
|
||||||
|
# This should have been created in the process:
|
||||||
|
remote_billing_user = RemoteServerBillingUser.objects.get(
|
||||||
|
remote_server=self.server, email=email
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the session looks as it should:
|
||||||
|
identity_dict = LegacyServerIdentityDict(
|
||||||
|
remote_server_uuid=str(self.server.uuid),
|
||||||
|
authenticated_at=datetime_to_timestamp(now),
|
||||||
|
remote_billing_user_id=remote_billing_user.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.session["remote_billing_identities"][f"remote_server:{self.uuid!s}"],
|
||||||
|
identity_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def test_server_login_get(self) -> None:
|
def test_server_login_get(self) -> None:
|
||||||
result = self.client_get("/serverlogin/", subdomain="selfhosting")
|
result = self.client_get("/serverlogin/", subdomain="selfhosting")
|
||||||
self.assertEqual(result.status_code, 200)
|
self.assertEqual(result.status_code, 200)
|
||||||
|
@ -400,27 +505,13 @@ class LegacyServerLoginTest(BouncerTestCase):
|
||||||
self.assert_in_success_response(["Your server registration has been deactivated."], result)
|
self.assert_in_success_response(["Your server registration has been deactivated."], result)
|
||||||
|
|
||||||
def test_server_login_success_with_no_plan(self) -> None:
|
def test_server_login_success_with_no_plan(self) -> None:
|
||||||
now = timezone_now()
|
hamlet = self.example_user("hamlet")
|
||||||
with time_machine.travel(now, tick=False):
|
result = self.execute_remote_billing_authentication_flow(
|
||||||
result = self.client_post(
|
hamlet.delivery_email, hamlet.full_name, expect_tos=True, confirm_tos=True
|
||||||
"/serverlogin/",
|
|
||||||
{"server_org_id": self.uuid, "server_org_secret": self.secret},
|
|
||||||
subdomain="selfhosting",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result.status_code, 302)
|
self.assertEqual(result.status_code, 302)
|
||||||
self.assertEqual(result["Location"], f"/server/{self.uuid}/plans/")
|
self.assertEqual(result["Location"], f"/server/{self.uuid}/plans/")
|
||||||
|
|
||||||
# Verify the authed data that should have been stored in the session.
|
|
||||||
identity_dict = LegacyServerIdentityDict(
|
|
||||||
remote_server_uuid=str(self.server.uuid),
|
|
||||||
authenticated_at=datetime_to_timestamp(now),
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
self.client.session["remote_billing_identities"][f"remote_server:{self.uuid!s}"],
|
|
||||||
identity_dict,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting")
|
result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting")
|
||||||
# The server has no plan, so the /billing page redirects to /upgrade
|
# The server has no plan, so the /billing page redirects to /upgrade
|
||||||
self.assertEqual(result.status_code, 302)
|
self.assertEqual(result.status_code, 302)
|
||||||
|
@ -433,7 +524,24 @@ class LegacyServerLoginTest(BouncerTestCase):
|
||||||
result = self.client_get(result["Location"], subdomain="selfhosting")
|
result = self.client_get(result["Location"], subdomain="selfhosting")
|
||||||
self.assert_in_success_response([f"Upgrade {self.server.hostname}"], result)
|
self.assert_in_success_response([f"Upgrade {self.server.hostname}"], result)
|
||||||
|
|
||||||
|
def test_server_login_success_consent_is_not_re_asked(self) -> None:
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
result = self.execute_remote_billing_authentication_flow(
|
||||||
|
hamlet.delivery_email, hamlet.full_name, expect_tos=True, confirm_tos=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result.status_code, 302)
|
||||||
|
self.assertEqual(result["Location"], f"/server/{self.uuid}/plans/")
|
||||||
|
|
||||||
|
# Now go through the flow again, but this time we should not be asked to re-confirm ToS.
|
||||||
|
result = self.execute_remote_billing_authentication_flow(
|
||||||
|
hamlet.delivery_email, hamlet.full_name, expect_tos=False, confirm_tos=False
|
||||||
|
)
|
||||||
|
self.assertEqual(result.status_code, 302)
|
||||||
|
self.assertEqual(result["Location"], f"/server/{self.uuid}/plans/")
|
||||||
|
|
||||||
def test_server_login_success_with_next_page(self) -> None:
|
def test_server_login_success_with_next_page(self) -> None:
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
|
||||||
# First test an invalid next_page value.
|
# First test an invalid next_page value.
|
||||||
result = self.client_post(
|
result = self.client_post(
|
||||||
"/serverlogin/",
|
"/serverlogin/",
|
||||||
|
@ -442,15 +550,10 @@ class LegacyServerLoginTest(BouncerTestCase):
|
||||||
)
|
)
|
||||||
self.assert_json_error(result, "Invalid next_page", 400)
|
self.assert_json_error(result, "Invalid next_page", 400)
|
||||||
|
|
||||||
result = self.client_post(
|
result = self.execute_remote_billing_authentication_flow(
|
||||||
"/serverlogin/",
|
hamlet.delivery_email, hamlet.full_name, next_page="sponsorship"
|
||||||
{
|
|
||||||
"server_org_id": self.uuid,
|
|
||||||
"server_org_secret": self.secret,
|
|
||||||
"next_page": "sponsorship",
|
|
||||||
},
|
|
||||||
subdomain="selfhosting",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# We should be redirected to the page dictated by the next_page param.
|
# We should be redirected to the page dictated by the next_page param.
|
||||||
self.assertEqual(result.status_code, 302)
|
self.assertEqual(result.status_code, 302)
|
||||||
self.assertEqual(result["Location"], f"/server/{self.uuid}/sponsorship/")
|
self.assertEqual(result["Location"], f"/server/{self.uuid}/sponsorship/")
|
||||||
|
@ -478,6 +581,7 @@ class LegacyServerLoginTest(BouncerTestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_server_billing_unauthed(self) -> None:
|
def test_server_billing_unauthed(self) -> None:
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
now = timezone_now()
|
now = timezone_now()
|
||||||
# Try to open a page with no auth at all.
|
# Try to open a page with no auth at all.
|
||||||
result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting")
|
result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting")
|
||||||
|
@ -490,17 +594,30 @@ class LegacyServerLoginTest(BouncerTestCase):
|
||||||
['<input type="hidden" name="next_page" value="billing" />'], result
|
['<input type="hidden" name="next_page" value="billing" />'], result
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The full auth flow involves clicking a confirmation link, upon which the user is
|
||||||
|
# granted an authenticated session. However, in the first part of the process,
|
||||||
|
# an intermittent session state is created to transition between endpoints.
|
||||||
|
# The bottom line is that this session state should *not* grant the user actual
|
||||||
|
# access to the billing management endpoints.
|
||||||
|
# We verify that here by simulating the user *not* clicking the confirmation link,
|
||||||
|
# and then trying to access billing management with the intermittent session state.
|
||||||
|
with time_machine.travel(now, tick=False):
|
||||||
|
self.execute_remote_billing_authentication_flow(
|
||||||
|
hamlet.delivery_email,
|
||||||
|
hamlet.full_name,
|
||||||
|
next_page="upgrade",
|
||||||
|
return_without_clicking_confirmation_link=True,
|
||||||
|
)
|
||||||
|
result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting")
|
||||||
|
self.assertEqual(result.status_code, 302)
|
||||||
|
# Redirects to the login form with appropriate next_page value.
|
||||||
|
self.assertEqual(result["Location"], "/serverlogin/?next_page=billing")
|
||||||
|
|
||||||
# Now authenticate, going to the /upgrade page since we'll be able to access
|
# Now authenticate, going to the /upgrade page since we'll be able to access
|
||||||
# it directly without annoying extra redirects.
|
# it directly without annoying extra redirects.
|
||||||
with time_machine.travel(now, tick=False):
|
with time_machine.travel(now, tick=False):
|
||||||
result = self.client_post(
|
result = self.execute_remote_billing_authentication_flow(
|
||||||
"/serverlogin/",
|
hamlet.delivery_email, hamlet.full_name, next_page="upgrade"
|
||||||
{
|
|
||||||
"server_org_id": self.uuid,
|
|
||||||
"server_org_secret": self.secret,
|
|
||||||
"next_page": "upgrade",
|
|
||||||
},
|
|
||||||
subdomain="selfhosting",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result.status_code, 302)
|
self.assertEqual(result.status_code, 302)
|
||||||
|
@ -521,3 +638,12 @@ class LegacyServerLoginTest(BouncerTestCase):
|
||||||
result = self.client_get(f"/server/{self.uuid}/upgrade/", subdomain="selfhosting")
|
result = self.client_get(f"/server/{self.uuid}/upgrade/", subdomain="selfhosting")
|
||||||
self.assertEqual(result.status_code, 302)
|
self.assertEqual(result.status_code, 302)
|
||||||
self.assertEqual(result["Location"], "/serverlogin/?next_page=upgrade")
|
self.assertEqual(result["Location"], "/serverlogin/?next_page=upgrade")
|
||||||
|
|
||||||
|
def test_remote_billing_authentication_flow_tos_consent_failure(self) -> None:
|
||||||
|
hamlet = self.example_user("hamlet")
|
||||||
|
|
||||||
|
result = self.execute_remote_billing_authentication_flow(
|
||||||
|
hamlet.email, hamlet.full_name, expect_tos=True, confirm_tos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_json_error(result, "You must accept the Terms of Service to proceed.")
|
||||||
|
|
|
@ -32,6 +32,8 @@ from corporate.views.portico import (
|
||||||
team_view,
|
team_view,
|
||||||
)
|
)
|
||||||
from corporate.views.remote_billing_page import (
|
from corporate.views.remote_billing_page import (
|
||||||
|
remote_billing_legacy_server_confirm_login,
|
||||||
|
remote_billing_legacy_server_from_login_confirmation_link,
|
||||||
remote_billing_legacy_server_login,
|
remote_billing_legacy_server_login,
|
||||||
remote_realm_billing_finalize_login,
|
remote_realm_billing_finalize_login,
|
||||||
)
|
)
|
||||||
|
@ -222,6 +224,16 @@ urlpatterns += [
|
||||||
remote_billing_legacy_server_login,
|
remote_billing_legacy_server_login,
|
||||||
name="remote_billing_legacy_server_login",
|
name="remote_billing_legacy_server_login",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"serverlogin/<server_uuid>/confirm/",
|
||||||
|
remote_billing_legacy_server_confirm_login,
|
||||||
|
name="remote_billing_legacy_server_confirm_login",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"serverlogin/do_confirm/<confirmation_key>",
|
||||||
|
remote_billing_legacy_server_from_login_confirmation_link,
|
||||||
|
name="remote_billing_legacy_server_from_login_confirmation_link",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"realm/<realm_uuid>/billing/event_status/",
|
"realm/<realm_uuid>/billing/event_status/",
|
||||||
remote_realm_event_status_page,
|
remote_realm_event_status_page,
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Literal, Optional
|
from typing import Any, Dict, Literal, Optional
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
|
@ -13,22 +14,38 @@ from django.utils.translation import gettext as _
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from pydantic import Json
|
from pydantic import Json
|
||||||
|
|
||||||
|
from confirmation.models import (
|
||||||
|
Confirmation,
|
||||||
|
ConfirmationKeyError,
|
||||||
|
create_confirmation_link,
|
||||||
|
get_object_from_key,
|
||||||
|
render_confirmation_key_error,
|
||||||
|
)
|
||||||
from corporate.lib.decorator import self_hosting_management_endpoint
|
from corporate.lib.decorator import self_hosting_management_endpoint
|
||||||
from corporate.lib.remote_billing_util import (
|
from corporate.lib.remote_billing_util import (
|
||||||
REMOTE_BILLING_SESSION_VALIDITY_SECONDS,
|
REMOTE_BILLING_SESSION_VALIDITY_SECONDS,
|
||||||
LegacyServerIdentityDict,
|
LegacyServerIdentityDict,
|
||||||
RemoteBillingIdentityDict,
|
RemoteBillingIdentityDict,
|
||||||
|
RemoteBillingIdentityExpiredError,
|
||||||
RemoteBillingUserDict,
|
RemoteBillingUserDict,
|
||||||
get_identity_dict_from_session,
|
get_identity_dict_from_session,
|
||||||
|
get_remote_server_and_user_from_session,
|
||||||
|
)
|
||||||
|
from zerver.lib.exceptions import (
|
||||||
|
JsonableError,
|
||||||
|
MissingRemoteRealmError,
|
||||||
|
RemoteBillingAuthenticationError,
|
||||||
)
|
)
|
||||||
from zerver.lib.exceptions import JsonableError, MissingRemoteRealmError
|
|
||||||
from zerver.lib.remote_server import RealmDataForAnalytics, UserDataForRemoteBilling
|
from zerver.lib.remote_server import RealmDataForAnalytics, UserDataForRemoteBilling
|
||||||
from zerver.lib.response import json_success
|
from zerver.lib.response import json_success
|
||||||
|
from zerver.lib.send_email import FromAddress, send_email
|
||||||
from zerver.lib.timestamp import datetime_to_timestamp
|
from zerver.lib.timestamp import datetime_to_timestamp
|
||||||
from zerver.lib.typed_endpoint import PathOnly, typed_endpoint
|
from zerver.lib.typed_endpoint import PathOnly, typed_endpoint
|
||||||
from zilencer.models import (
|
from zilencer.models import (
|
||||||
|
PreregistrationRemoteServerBillingUser,
|
||||||
RemoteRealm,
|
RemoteRealm,
|
||||||
RemoteRealmBillingUser,
|
RemoteRealmBillingUser,
|
||||||
|
RemoteServerBillingUser,
|
||||||
RemoteZulipServer,
|
RemoteZulipServer,
|
||||||
get_remote_server_by_uuid,
|
get_remote_server_by_uuid,
|
||||||
)
|
)
|
||||||
|
@ -154,7 +171,7 @@ def remote_realm_billing_finalize_login(
|
||||||
context = {
|
context = {
|
||||||
"remote_server_uuid": remote_server_uuid,
|
"remote_server_uuid": remote_server_uuid,
|
||||||
"remote_realm_uuid": remote_realm_uuid,
|
"remote_realm_uuid": remote_realm_uuid,
|
||||||
"remote_realm_host": remote_realm.host,
|
"host": remote_realm.host,
|
||||||
"user_email": user_email,
|
"user_email": user_email,
|
||||||
"user_full_name": user_full_name,
|
"user_full_name": user_full_name,
|
||||||
"tos_consent_needed": tos_consent_needed,
|
"tos_consent_needed": tos_consent_needed,
|
||||||
|
@ -332,14 +349,190 @@ def remote_billing_legacy_server_login(
|
||||||
if remote_server.deactivated:
|
if remote_server.deactivated:
|
||||||
context.update({"error_message": _("Your server registration has been deactivated.")})
|
context.update({"error_message": _("Your server registration has been deactivated.")})
|
||||||
return render(request, "corporate/legacy_server_login.html", context)
|
return render(request, "corporate/legacy_server_login.html", context)
|
||||||
|
|
||||||
remote_server_uuid = str(remote_server.uuid)
|
remote_server_uuid = str(remote_server.uuid)
|
||||||
|
|
||||||
|
# We will want to render a page with a form that POSTs user-filled data to
|
||||||
|
# the next endpoint in the flow. That endpoint needs to know the user is already
|
||||||
|
# authenticated as a billing admin for this remote server, so we need to store
|
||||||
|
# our usual IdentityDict structure in the session.
|
||||||
request.session["remote_billing_identities"] = {}
|
request.session["remote_billing_identities"] = {}
|
||||||
request.session["remote_billing_identities"][
|
request.session["remote_billing_identities"][
|
||||||
f"remote_server:{remote_server_uuid}"
|
f"remote_server:{remote_server_uuid}"
|
||||||
] = LegacyServerIdentityDict(
|
] = LegacyServerIdentityDict(
|
||||||
remote_server_uuid=remote_server_uuid,
|
remote_server_uuid=remote_server_uuid,
|
||||||
authenticated_at=datetime_to_timestamp(timezone_now()),
|
authenticated_at=datetime_to_timestamp(timezone_now()),
|
||||||
|
# The lack of remote_billing_user_id indicates the auth hasn't been completed.
|
||||||
|
# This means access to authenticated endpoints will be denied. Only proceeding
|
||||||
|
# to the next step in the flow is permitted with this.
|
||||||
|
remote_billing_user_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"remote_server_hostname": remote_server.hostname,
|
||||||
|
"next_page": next_page,
|
||||||
|
"action_url": reverse(
|
||||||
|
remote_billing_legacy_server_confirm_login, args=(str(remote_server.uuid),)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"corporate/remote_billing_legacy_server_confirm_login_form.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@self_hosting_management_endpoint
|
||||||
|
@typed_endpoint
|
||||||
|
def remote_billing_legacy_server_confirm_login(
|
||||||
|
request: HttpRequest,
|
||||||
|
*,
|
||||||
|
server_uuid: PathOnly[str],
|
||||||
|
email: str,
|
||||||
|
next_page: VALID_NEXT_PAGES_TYPE = None,
|
||||||
|
) -> HttpResponse:
|
||||||
|
"""
|
||||||
|
Takes the POST from the above form and sends confirmation email to the provided
|
||||||
|
email address in order to verify. Only the confirmation link will grant
|
||||||
|
a fully authenticated session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
remote_server, remote_billing_user = get_remote_server_and_user_from_session(
|
||||||
|
request, server_uuid=server_uuid
|
||||||
|
)
|
||||||
|
if remote_billing_user is not None:
|
||||||
|
# This session is already fully authenticated, it doesn't make sense for
|
||||||
|
# the user to be here. Just raise an exception so it's immediately caught
|
||||||
|
# and the user is redirected to the beginning of the login flow where
|
||||||
|
# they can re-auth.
|
||||||
|
raise RemoteBillingAuthenticationError
|
||||||
|
except (RemoteBillingIdentityExpiredError, RemoteBillingAuthenticationError):
|
||||||
|
return HttpResponse(
|
||||||
|
reverse("remote_billing_legacy_server_login") + f"?next_page={next_page}"
|
||||||
|
)
|
||||||
|
|
||||||
|
obj = PreregistrationRemoteServerBillingUser.objects.create(
|
||||||
|
email=email,
|
||||||
|
remote_server=remote_server,
|
||||||
|
next_page=next_page,
|
||||||
|
)
|
||||||
|
url = create_confirmation_link(
|
||||||
|
obj,
|
||||||
|
Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN,
|
||||||
|
# Use the same expiration time as for the signed access token,
|
||||||
|
# since this is similarly transient in nature.
|
||||||
|
validity_in_minutes=int(REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS / 60),
|
||||||
|
no_associated_realm_object=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# create_confirmation_link will create the url on the root subdomain, so we need to
|
||||||
|
# do a hacky approach to change it into the self hosting management subdomain.
|
||||||
|
new_hostname = f"{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}"
|
||||||
|
split_url = urlsplit(url)
|
||||||
|
modified_url = split_url._replace(netloc=new_hostname)
|
||||||
|
final_url = urlunsplit(modified_url)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"remote_server_hostname": remote_server.hostname,
|
||||||
|
"remote_server_uuid": str(remote_server.uuid),
|
||||||
|
"confirmation_url": final_url,
|
||||||
|
}
|
||||||
|
send_email(
|
||||||
|
"zerver/emails/remote_billing_legacy_server_confirm_login",
|
||||||
|
to_emails=[email],
|
||||||
|
from_address=FromAddress.tokenized_no_reply_address(),
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"corporate/remote_billing_legacy_server_confirm_login_sent.html",
|
||||||
|
context={"email": email},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@self_hosting_management_endpoint
|
||||||
|
@typed_endpoint
|
||||||
|
def remote_billing_legacy_server_from_login_confirmation_link(
|
||||||
|
request: HttpRequest,
|
||||||
|
*,
|
||||||
|
confirmation_key: PathOnly[str],
|
||||||
|
full_name: Optional[str] = None,
|
||||||
|
tos_consent: Literal[None, "true"] = None,
|
||||||
|
) -> HttpResponse:
|
||||||
|
"""
|
||||||
|
The user comes here via the confirmation link they received via email.
|
||||||
|
"""
|
||||||
|
if request.method not in ["GET", "POST"]:
|
||||||
|
return HttpResponseNotAllowed(["GET", "POST"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
prereg_object = get_object_from_key(
|
||||||
|
confirmation_key,
|
||||||
|
[Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN],
|
||||||
|
# These links are reusable.
|
||||||
|
mark_object_used=False,
|
||||||
|
)
|
||||||
|
except ConfirmationKeyError as exception:
|
||||||
|
return render_confirmation_key_error(request, exception)
|
||||||
|
assert isinstance(prereg_object, PreregistrationRemoteServerBillingUser)
|
||||||
|
remote_server = prereg_object.remote_server
|
||||||
|
remote_server_uuid = str(remote_server.uuid)
|
||||||
|
|
||||||
|
# If this user (identified by email) already did this flow, meaning the have a RemoteServerBillingUser,
|
||||||
|
# then we don't re-do the ToS consent again.
|
||||||
|
tos_consent_needed = not RemoteServerBillingUser.objects.filter(
|
||||||
|
remote_server=remote_server, email=prereg_object.email
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
context = {
|
||||||
|
"remote_server_uuid": remote_server_uuid,
|
||||||
|
"host": remote_server.hostname,
|
||||||
|
"user_email": prereg_object.email,
|
||||||
|
"tos_consent_needed": tos_consent_needed,
|
||||||
|
"action_url": reverse(
|
||||||
|
remote_billing_legacy_server_from_login_confirmation_link,
|
||||||
|
args=(confirmation_key,),
|
||||||
|
),
|
||||||
|
"legacy_server_confirmation_flow": True,
|
||||||
|
}
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
# TODO: We're re-using the template, so it should be renamed
|
||||||
|
# to a more general name.
|
||||||
|
"corporate/remote_realm_billing_finalize_login_confirmation.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert request.method == "POST"
|
||||||
|
|
||||||
|
if tos_consent_needed and not tos_consent:
|
||||||
|
# This shouldn't be possible without tampering with the form, so we
|
||||||
|
# don't need a pretty error.
|
||||||
|
raise JsonableError(_("You must accept the Terms of Service to proceed."))
|
||||||
|
|
||||||
|
next_page = prereg_object.next_page
|
||||||
|
|
||||||
|
remote_billing_user, created = RemoteServerBillingUser.objects.update_or_create(
|
||||||
|
defaults={"full_name": full_name},
|
||||||
|
email=prereg_object.email,
|
||||||
|
remote_server=remote_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh IdentityDict in the session. (Or create it
|
||||||
|
# if the user came here e.g. in a different browser than they
|
||||||
|
# started the login flow in.)
|
||||||
|
request.session["remote_billing_identities"] = {}
|
||||||
|
request.session["remote_billing_identities"][
|
||||||
|
f"remote_server:{remote_server_uuid}"
|
||||||
|
] = LegacyServerIdentityDict(
|
||||||
|
remote_server_uuid=remote_server_uuid,
|
||||||
|
authenticated_at=datetime_to_timestamp(timezone_now()),
|
||||||
|
# Having a remote_billing_user_id indicates the auth has been completed.
|
||||||
|
# The user will now be granted access to authenticated endpoints.
|
||||||
|
remote_billing_user_id=remote_billing_user.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert next_page in VALID_NEXT_PAGES
|
assert next_page in VALID_NEXT_PAGES
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
{% extends "zerver/portico_signup.html" %}
|
||||||
|
{% set entrypoint = "upgrade" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
<title>{{ _("Login confirmation - email") }} | Zulip</title>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block portico_content %}
|
||||||
|
<div class="register-account flex full-page">
|
||||||
|
<div class="center-block new-style">
|
||||||
|
<div class="pitch">
|
||||||
|
<h1>Enter your email address</h1>
|
||||||
|
|
||||||
|
<p>Next, we will send a verification email to the address you provide.</p>
|
||||||
|
</div>
|
||||||
|
<div class="white-box">
|
||||||
|
<form id="server-confirm-login-form" method="post" action="{{ action_url }}">
|
||||||
|
{{ csrf_input }}
|
||||||
|
{% if next_page %}
|
||||||
|
<input type="hidden" name="next_page" value="{{ next_page }}" />
|
||||||
|
{% endif %}
|
||||||
|
<div class="input-box server-login-form-field">
|
||||||
|
<label for="email" class="inline-block label-title">Email</label>
|
||||||
|
<input id="email" name="email" class="email required" type="email" />
|
||||||
|
</div>
|
||||||
|
<div class="upgrade-button-container">
|
||||||
|
<button type="submit" id="server-confirm-login-button" class="stripe-button-el invoice-button">
|
||||||
|
<span class="server-login-button-text">Continue</span>
|
||||||
|
<img class="loader server-login-button-loader" src="{{ static('images/loading/loader-white.svg') }}" alt="" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,29 @@
|
||||||
|
{% extends "zerver/portico_signup.html" %}
|
||||||
|
{% set entrypoint = "upgrade" %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
<title>{{ _("Confirm your email address") }} | Zulip</title>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block portico_content %}
|
||||||
|
<div class="app portico-page">
|
||||||
|
<div class="app-main portico-page-container center-block flex full-page account-creation account-email-confirm-container new-style">
|
||||||
|
<div class="inline-block">
|
||||||
|
|
||||||
|
<div class="get-started">
|
||||||
|
<h1>{{ _("Confirm your email address") }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="white-box">
|
||||||
|
<p>{% trans %}To complete the login process, check your email account (<span class="user_email semi-bold">{{ email }}</span>) for a confirmation email from Zulip.{% endtrans %}</p>
|
||||||
|
|
||||||
|
{% include 'zerver/dev_env_email_access_details.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block customhead %}
|
||||||
|
{{ super() }}
|
||||||
|
{% endblock %}
|
|
@ -9,15 +9,23 @@
|
||||||
<div id="remote-realm-confirm-login-page" class="register-account flex full-page">
|
<div id="remote-realm-confirm-login-page" class="register-account flex full-page">
|
||||||
<div class="center-block new-style">
|
<div class="center-block new-style">
|
||||||
<div class="pitch">
|
<div class="pitch">
|
||||||
<h1>Log in to Zulip server billing for {{ remote_realm_host }}</h1>
|
<h1>Log in to Zulip server billing for {{ host }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="white-box">
|
<div class="white-box">
|
||||||
<p>Click <b>Continue</b> to log in to Zulip server
|
<p>Click <b>Continue</b> to log in to Zulip server
|
||||||
billing with the account below.</p>
|
billing with the account below.</p>
|
||||||
|
{% if not legacy_server_confirmation_flow %}
|
||||||
Full name: {{ user_full_name }}<br />
|
Full name: {{ user_full_name }}<br />
|
||||||
|
{% endif %}
|
||||||
Email: {{ user_email }}<br />
|
Email: {{ user_email }}<br />
|
||||||
<form id="remote-realm-confirm-login-form" method="post" action="{{ action_url }}">
|
<form id="remote-realm-confirm-login-form" method="post" action="{{ action_url }}">
|
||||||
{{ csrf_input }}
|
{{ csrf_input }}
|
||||||
|
{% if legacy_server_confirmation_flow %}
|
||||||
|
<div class="input-box remote-realm-confirm-login-form-field">
|
||||||
|
<label for="full_name" class="inline-block label-title">Full name</label>
|
||||||
|
<input id="full_name" name="full_name" class="required" type="text"/>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if tos_consent_needed %}
|
{% if tos_consent_needed %}
|
||||||
<div class="input-group terms-of-service">
|
<div class="input-group terms-of-service">
|
||||||
<label for="id_terms" class="inline-block checkbox">
|
<label for="id_terms" class="inline-block checkbox">
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends "zerver/emails/email_base_default.html" %}
|
||||||
|
|
||||||
|
{% block illustration %}
|
||||||
|
<img src="{{ email_images_base_url }}/registration_confirmation.png" alt=""/>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>
|
||||||
|
{{ _("You have initiated login to the Zulip server billing management system for the following server:") }}
|
||||||
|
<ul>
|
||||||
|
<li>{% trans %}Hostname: {{ remote_server_hostname }}{% endtrans %}</li>
|
||||||
|
<li>{% trans %}zulip_org_id: {{ remote_server_uuid }}{% endtrans %}</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ _("Click the button below to complete the login process.") }}
|
||||||
|
<a class="button" href="{{ confirmation_url }}">{{ _("Confirm login") }}</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{macros.contact_us_zulip_cloud(support_email)}}
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1 @@
|
||||||
|
{% trans %}Confirm login to Zulip server billing management{% endtrans %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{{ _("You have initiated login to the Zulip server billing management system for the following server:") }}
|
||||||
|
* {% trans %}Hostname: {{ remote_server_hostname }}{% endtrans %}
|
||||||
|
|
||||||
|
* {% trans %}zulip_org_id: {{ remote_server_uuid }}{% endtrans %}
|
||||||
|
|
||||||
|
{{ _("Click the link below to complete the login process;") }}
|
||||||
|
<{{ confirmation_url }}>
|
||||||
|
|
||||||
|
{% trans %}Do you have questions or feedback to share? Contact us at {{ support_email }} — we'd love to help!{% endtrans %}
|
|
@ -148,6 +148,8 @@ IGNORED_PHRASES = [
|
||||||
r"guest",
|
r"guest",
|
||||||
# Used in pills for deactivated users.
|
# Used in pills for deactivated users.
|
||||||
r"deactivated",
|
r"deactivated",
|
||||||
|
# This is a reference to a setting/secret and should be lowercase.
|
||||||
|
r"zulip_org_id",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Sort regexes in descending order of their lengths. As a result, the
|
# Sort regexes in descending order of their lengths. As a result, the
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import $ from "jquery";
|
import $ from "jquery";
|
||||||
|
|
||||||
export function initialize(): void {
|
export function initialize(): void {
|
||||||
$("#server-login-form").validate({
|
$("#server-login-form, #server-confirm-login-form").validate({
|
||||||
errorClass: "text-error",
|
errorClass: "text-error",
|
||||||
wrapper: "div",
|
wrapper: "div",
|
||||||
submitHandler(form) {
|
submitHandler(form) {
|
||||||
$("#server-login-form").find(".loader").css("display", "inline-block");
|
$("#server-login-form").find(".loader").css("display", "inline-block");
|
||||||
$("#server-login-button .server-login-button-text").hide();
|
$("#server-login-button .server-login-button-text").hide();
|
||||||
|
$("#server-confirm-login-form").find(".loader").css("display", "inline-block");
|
||||||
|
$("#server-confirm-login-button .server-login-button-text").hide();
|
||||||
|
|
||||||
form.submit();
|
form.submit();
|
||||||
},
|
},
|
||||||
|
@ -14,10 +16,12 @@ export function initialize(): void {
|
||||||
// this removes all previous errors that were put on screen
|
// this removes all previous errors that were put on screen
|
||||||
// by the server.
|
// by the server.
|
||||||
$("#server-login-form .alert.alert-error").remove();
|
$("#server-login-form .alert.alert-error").remove();
|
||||||
|
$("#server-confirm-login-form .alert.alert-error").remove();
|
||||||
},
|
},
|
||||||
showErrors(error_map) {
|
showErrors(error_map) {
|
||||||
if (error_map.password) {
|
if (error_map.password) {
|
||||||
$("#server-login-form .alert.alert-error").remove();
|
$("#server-login-form .alert.alert-error").remove();
|
||||||
|
$("#server-confirm-login-form .alert.alert-error").remove();
|
||||||
}
|
}
|
||||||
this.defaultShowErrors!();
|
this.defaultShowErrors!();
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Generated by Django 4.2.8 on 2023-12-08 19:13
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("zilencer", "0046_remotezulipserver_last_audit_log_update"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PreregistrationRemoteServerBillingUser",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("email", models.EmailField(max_length=254)),
|
||||||
|
("status", models.IntegerField(default=0)),
|
||||||
|
("next_page", models.TextField(null=True)),
|
||||||
|
(
|
||||||
|
"remote_server",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="zilencer.remotezulipserver"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="RemoteServerBillingUser",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("email", models.EmailField(max_length=254)),
|
||||||
|
("full_name", models.TextField(default="")),
|
||||||
|
(
|
||||||
|
"remote_server",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="zilencer.remotezulipserver"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"unique_together": {("remote_server", "email")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -165,6 +165,32 @@ class RemoteRealmBillingUser(models.Model):
|
||||||
tos_version = models.TextField(default=TOS_VERSION_BEFORE_FIRST_LOGIN)
|
tos_version = models.TextField(default=TOS_VERSION_BEFORE_FIRST_LOGIN)
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractRemoteServerBillingUser(models.Model):
|
||||||
|
remote_server = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
email = models.EmailField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteServerBillingUser(AbstractRemoteServerBillingUser):
|
||||||
|
full_name = models.TextField(default="")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [
|
||||||
|
("remote_server", "email"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PreregistrationRemoteServerBillingUser(AbstractRemoteServerBillingUser):
|
||||||
|
# status: whether an object has been confirmed.
|
||||||
|
# if confirmed, set to confirmation.settings.STATUS_USED
|
||||||
|
status = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
next_page = models.TextField(null=True)
|
||||||
|
|
||||||
|
|
||||||
class RemoteZulipServerAuditLog(AbstractRealmAuditLog):
|
class RemoteZulipServerAuditLog(AbstractRealmAuditLog):
|
||||||
"""Audit data associated with a remote Zulip server (not specific to a
|
"""Audit data associated with a remote Zulip server (not specific to a
|
||||||
realm). Used primarily for tracking registration and billing
|
realm). Used primarily for tracking registration and billing
|
||||||
|
|
Loading…
Reference in New Issue