mirror of https://github.com/zulip/zulip.git
corporate: Add prototype authentication system for self-hosters.
This makes it possible for a self-hosted realm administrator to directly access a logged-page on the push notifications bouncer service, enabling billing, support contacts, and other administrator for enterprise customers to be managed without manual setup.
This commit is contained in:
parent
1ec0d5bd9d
commit
3958743b33
|
@ -15,6 +15,13 @@ from corporate.views.portico import (
|
|||
plans_view,
|
||||
team_view,
|
||||
)
|
||||
from corporate.views.remote_billing_page import (
|
||||
remote_billing_page_realm,
|
||||
remote_billing_page_server,
|
||||
remote_billing_plans_realm,
|
||||
remote_billing_plans_server,
|
||||
remote_server_billing_finalize_login,
|
||||
)
|
||||
from corporate.views.session import (
|
||||
start_card_update_stripe_session,
|
||||
start_card_update_stripe_session_for_realm_upgrade,
|
||||
|
@ -147,3 +154,18 @@ urlpatterns += [
|
|||
path("api/v1/", include(v1_api_and_json_patterns)),
|
||||
path("json/", include(v1_api_and_json_patterns)),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
path(
|
||||
"remote-billing-login/<signed_billing_access_token>", remote_server_billing_finalize_login
|
||||
),
|
||||
# Remote server billling endpoints.
|
||||
path("realm/<realm_uuid>/plans", remote_billing_plans_realm, name="remote_billing_plans_realm"),
|
||||
path(
|
||||
"server/<server_uuid>/plans",
|
||||
remote_billing_plans_server,
|
||||
name="remote_billing_plans_server",
|
||||
),
|
||||
path("realm/<realm_uuid>/billing", remote_billing_page_realm, name="remote_billing_page_realm"),
|
||||
path("server/<server_uuid>/", remote_billing_page_server, name="remote_billing_page_server"),
|
||||
]
|
||||
|
|
|
@ -0,0 +1,196 @@
|
|||
import logging
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from pydantic import Json
|
||||
|
||||
from zerver.decorator import self_hosting_management_endpoint
|
||||
from zerver.lib.exceptions import JsonableError, MissingRemoteRealmError
|
||||
from zerver.lib.remote_server import RealmDataForAnalytics, UserDataForRemoteBilling
|
||||
from zerver.lib.response import json_success
|
||||
from zerver.lib.typed_endpoint import PathOnly, typed_endpoint
|
||||
from zilencer.models import RemoteRealm, RemoteZulipServer
|
||||
|
||||
billing_logger = logging.getLogger("corporate.stripe")
|
||||
|
||||
|
||||
class RemoteBillingIdentityDict(TypedDict):
|
||||
user_uuid: str
|
||||
user_email: str
|
||||
user_full_name: str
|
||||
remote_server_uuid: str
|
||||
remote_realm_uuid: str
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@typed_endpoint
|
||||
def remote_server_billing_entry(
|
||||
request: HttpRequest,
|
||||
remote_server: RemoteZulipServer,
|
||||
*,
|
||||
user: Json[UserDataForRemoteBilling],
|
||||
realm: Json[RealmDataForAnalytics],
|
||||
) -> HttpResponse:
|
||||
if not settings.DEVELOPMENT:
|
||||
return render(request, "404.html", status=404)
|
||||
|
||||
try:
|
||||
remote_realm = RemoteRealm.objects.get(uuid=realm.uuid, server=remote_server)
|
||||
except RemoteRealm.DoesNotExist:
|
||||
# This error will prod the remote server to submit its realm info, which
|
||||
# should lead to the creation of this missing RemoteRealm registration.
|
||||
raise MissingRemoteRealmError
|
||||
|
||||
identity_dict = RemoteBillingIdentityDict(
|
||||
user_email=user.email,
|
||||
user_uuid=str(user.uuid),
|
||||
user_full_name=user.full_name,
|
||||
remote_server_uuid=str(remote_server.uuid),
|
||||
remote_realm_uuid=str(remote_realm.uuid),
|
||||
)
|
||||
|
||||
signed_identity_dict = signing.dumps(identity_dict)
|
||||
|
||||
billing_access_url = (
|
||||
f"{settings.EXTERNAL_URI_SCHEME}{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}"
|
||||
+ reverse(remote_server_billing_finalize_login, args=[signed_identity_dict])
|
||||
)
|
||||
return json_success(request, data={"billing_access_url": billing_access_url})
|
||||
|
||||
|
||||
@self_hosting_management_endpoint
|
||||
def remote_server_billing_finalize_login(
|
||||
request: HttpRequest, signed_billing_access_token: str
|
||||
) -> HttpResponse:
|
||||
try:
|
||||
identity_dict: RemoteBillingIdentityDict = signing.loads(
|
||||
signed_billing_access_token, max_age=settings.SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS
|
||||
)
|
||||
except signing.SignatureExpired:
|
||||
raise JsonableError(_("Billing access token expired."))
|
||||
except signing.BadSignature:
|
||||
raise JsonableError(_("Invalid billing access token."))
|
||||
|
||||
remote_realm_uuid = identity_dict["remote_realm_uuid"]
|
||||
|
||||
request.session["remote_billing_identities"] = {}
|
||||
request.session["remote_billing_identities"][remote_realm_uuid] = identity_dict
|
||||
|
||||
# TODO: Figure out redirects based on whether the realm/server already has a plan
|
||||
# and should be taken to /billing or doesn't have and should be taken to /plans.
|
||||
# For now we're only implemented the case where we have the RemoteRealm, and we take
|
||||
# to /plans.
|
||||
|
||||
return HttpResponseRedirect(reverse("remote_billing_plans_realm", args=(remote_realm_uuid,)))
|
||||
|
||||
|
||||
def render_tmp_remote_billing_page(
|
||||
request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str]
|
||||
) -> HttpResponse:
|
||||
authed_uuid = realm_uuid or server_uuid
|
||||
assert authed_uuid is not None
|
||||
|
||||
identity_dict = None
|
||||
|
||||
identity_dicts = request.session.get("remote_billing_identities")
|
||||
if identity_dicts is not None:
|
||||
identity_dict = identity_dicts.get(authed_uuid)
|
||||
|
||||
if identity_dict is None:
|
||||
raise JsonableError(_("User not authenticated"))
|
||||
|
||||
user_email = identity_dict["user_email"]
|
||||
user_full_name = identity_dict["user_full_name"]
|
||||
remote_server_uuid = identity_dict["remote_server_uuid"]
|
||||
remote_realm_uuid = identity_dict["remote_realm_uuid"]
|
||||
|
||||
try:
|
||||
remote_server = RemoteZulipServer.objects.get(uuid=remote_server_uuid)
|
||||
except RemoteZulipServer.DoesNotExist:
|
||||
raise JsonableError(_("Invalid remote server."))
|
||||
|
||||
try:
|
||||
# Checking for the (uuid, server) is sufficient to be secure here, since the server
|
||||
# is authenticated. the uuid_owner_secret is not needed here, it'll be used for
|
||||
# for validating transfers of a realm to a different RemoteZulipServer (in the
|
||||
# export-import process).
|
||||
remote_realm = RemoteRealm.objects.get(uuid=remote_realm_uuid, server=remote_server)
|
||||
except RemoteRealm.DoesNotExist:
|
||||
raise AssertionError(
|
||||
"The remote realm is missing despite being in the RemoteBillingIdentityDict"
|
||||
)
|
||||
|
||||
remote_server_and_realm_info = {
|
||||
"remote_server_uuid": remote_server_uuid,
|
||||
"remote_server_hostname": remote_server.hostname,
|
||||
"remote_server_contact_email": remote_server.contact_email,
|
||||
"remote_server_plan_type": remote_server.plan_type,
|
||||
"remote_realm_uuid": remote_realm_uuid,
|
||||
"remote_realm_host": remote_realm.host,
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"corporate/remote_billing.html",
|
||||
context={
|
||||
"user_email": user_email,
|
||||
"user_full_name": user_full_name,
|
||||
"remote_server_and_realm_info": remote_server_and_realm_info,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def remote_billing_plans_common(
|
||||
request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str]
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
Once implemented, this function, shared between remote_billing_plans_realm
|
||||
and remote_billing_plans_server, will return a Plans page, adjusted depending
|
||||
on whether the /realm/... or /server/... endpoint is being used
|
||||
"""
|
||||
|
||||
return render_tmp_remote_billing_page(request, realm_uuid=realm_uuid, server_uuid=server_uuid)
|
||||
|
||||
|
||||
@self_hosting_management_endpoint
|
||||
@typed_endpoint
|
||||
def remote_billing_plans_realm(request: HttpRequest, *, realm_uuid: PathOnly[str]) -> HttpResponse:
|
||||
return remote_billing_plans_common(request, realm_uuid=realm_uuid, server_uuid=None)
|
||||
|
||||
|
||||
@self_hosting_management_endpoint
|
||||
@typed_endpoint
|
||||
def remote_billing_plans_server(
|
||||
request: HttpRequest, *, server_uuid: PathOnly[str]
|
||||
) -> HttpResponse:
|
||||
return remote_billing_plans_common(request, server_uuid=server_uuid, realm_uuid=None)
|
||||
|
||||
|
||||
def remote_billing_page_common(
|
||||
request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str]
|
||||
) -> HttpResponse:
|
||||
"""
|
||||
Once implemented, this function, shared between remote_billing_page_realm
|
||||
and remote_billing_page_server, will return a Billing page, adjusted depending
|
||||
on whether the /realm/... or /server/... endpoint is being used
|
||||
"""
|
||||
|
||||
return render_tmp_remote_billing_page(request, realm_uuid=realm_uuid, server_uuid=server_uuid)
|
||||
|
||||
|
||||
@self_hosting_management_endpoint
|
||||
@typed_endpoint
|
||||
def remote_billing_page_server(request: HttpRequest, *, server_uuid: PathOnly[str]) -> HttpResponse:
|
||||
return remote_billing_page_common(request, server_uuid=server_uuid, realm_uuid=None)
|
||||
|
||||
|
||||
@self_hosting_management_endpoint
|
||||
@typed_endpoint
|
||||
def remote_billing_page_realm(request: HttpRequest, *, realm_uuid: PathOnly[str]) -> HttpResponse:
|
||||
return remote_billing_page_common(request, realm_uuid=realm_uuid, server_uuid=None)
|
|
@ -0,0 +1,22 @@
|
|||
{% extends "zerver/portico.html" %}
|
||||
{% set entrypoint = "upgrade" %}
|
||||
|
||||
{% block title %}
|
||||
<title>{{ _("Billing") }} | Zulip</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block portico_content %}
|
||||
<div id="remote-billing-page" class="register-account flex full-page">
|
||||
<div class="center-block new-style">
|
||||
<div class="pitch">
|
||||
<h1>Your remote user info: </h1>
|
||||
</div>
|
||||
<div class="white-box">
|
||||
Email: {{ user_email }}<br />
|
||||
Full name: {{ user_full_name }}<br />
|
||||
Remote server hostname: {{ remote_server_and_realm_info["remote_server_hostname"] }}<br />
|
||||
Remote realm host: {{ remote_server_and_realm_info.get("remote_realm_host") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -54,6 +54,9 @@ not_yet_fully_covered = [
|
|||
"analytics/views/installation_activity.py",
|
||||
"analytics/views/stats.py",
|
||||
"analytics/views/support.py",
|
||||
# TODO: This is a work in progress and therefore without
|
||||
# tests yet.
|
||||
"corporate/views/remote_billing_page.py",
|
||||
# Major lib files should have 100% coverage
|
||||
"zerver/actions/presence.py",
|
||||
"zerver/lib/addressee.py",
|
||||
|
|
|
@ -196,6 +196,7 @@ export function get_gear_menu_content_context() {
|
|||
is_owner: page_params.is_owner,
|
||||
is_admin: page_params.is_admin,
|
||||
is_self_hosted: page_params.realm_plan_type === 1,
|
||||
is_development_environment: page_params.development_environment,
|
||||
is_plan_limited: page_params.realm_plan_type === 2,
|
||||
is_plan_standard,
|
||||
is_plan_standard_sponsored_for_free: page_params.realm_plan_type === 4,
|
||||
|
@ -213,6 +214,7 @@ export function get_gear_menu_content_context() {
|
|||
login_link: page_params.development_environment ? "/devlogin/" : "/login/",
|
||||
promote_sponsoring_zulip: page_params.promote_sponsoring_zulip,
|
||||
show_billing: page_params.show_billing,
|
||||
show_remote_billing: page_params.show_remote_billing,
|
||||
show_plans: page_params.show_plans,
|
||||
show_webathena: page_params.show_webathena,
|
||||
sponsorship_pending: page_params.sponsorship_pending,
|
||||
|
|
|
@ -119,6 +119,14 @@
|
|||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (and is_development_environment show_remote_billing) }}
|
||||
{{! This is only shown in development environment until the UI is ready.}}
|
||||
<li class="link-item navbar-dropdown-menu-inner-list-item">
|
||||
<a href="/self-hosted-billing/" target="_blank" rel="noopener noreferrer" class="navigate-link-on-enter navbar-dropdown-menu-link">
|
||||
<i class="navbar-dropdown-icon zulip-icon zulip-icon-credit-card" aria-hidden="true"></i> {{t 'Billing' }}
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if promote_sponsoring_zulip}}
|
||||
<li class="link-item navbar-dropdown-menu-inner-list-item">
|
||||
<a href="https://zulip.com/help/support-zulip-project" target="_blank" rel="noopener noreferrer" class="navigate-link-on-enter navbar-dropdown-menu-link">
|
||||
|
|
|
@ -65,6 +65,7 @@ from zerver.models import UserProfile, get_client, get_user_profile_by_api_key
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from django.http.request import _ImmutableQueryDict
|
||||
from django.shortcuts import render
|
||||
|
||||
webhook_logger = logging.getLogger("zulip.zerver.webhooks")
|
||||
webhook_unsupported_events_logger = logging.getLogger("zulip.zerver.webhooks.unsupported")
|
||||
|
@ -74,6 +75,21 @@ ParamT = ParamSpec("ParamT")
|
|||
ReturnT = TypeVar("ReturnT")
|
||||
|
||||
|
||||
def self_hosting_management_endpoint(
|
||||
view_func: Callable[Concatenate[HttpRequest, ParamT], HttpResponse]
|
||||
) -> Callable[Concatenate[HttpRequest, ParamT], HttpResponse]: # nocoverage
|
||||
@wraps(view_func)
|
||||
def _wrapped_view_func(
|
||||
request: HttpRequest, /, *args: ParamT.args, **kwargs: ParamT.kwargs
|
||||
) -> HttpResponse:
|
||||
subdomain = get_subdomain(request)
|
||||
if not settings.DEVELOPMENT or subdomain != settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN:
|
||||
return render(request, "404.html", status=404)
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view_func
|
||||
|
||||
|
||||
def update_user_activity(
|
||||
request: HttpRequest, user_profile: UserProfile, query: Optional[str]
|
||||
) -> None:
|
||||
|
|
|
@ -46,6 +46,7 @@ class ErrorCode(Enum):
|
|||
REACTION_ALREADY_EXISTS = auto()
|
||||
REACTION_DOES_NOT_EXIST = auto()
|
||||
SERVER_NOT_READY = auto()
|
||||
MISSING_REMOTE_REALM = auto()
|
||||
|
||||
|
||||
class JsonableError(Exception):
|
||||
|
@ -570,3 +571,16 @@ class ApiParamValidationError(JsonableError):
|
|||
class ServerNotReadyError(JsonableError):
|
||||
code = ErrorCode.SERVER_NOT_READY
|
||||
http_status_code = 500
|
||||
|
||||
|
||||
class MissingRemoteRealmError(JsonableError): # nocoverage
|
||||
code: ErrorCode = ErrorCode.MISSING_REMOTE_REALM
|
||||
http_status_code = 403
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@override
|
||||
def msg_format() -> str:
|
||||
return _("Organization not registered")
|
||||
|
|
|
@ -16,6 +16,7 @@ from zerver.lib.i18n import (
|
|||
get_language_translation_data,
|
||||
)
|
||||
from zerver.lib.narrow_helpers import NarrowTerm
|
||||
from zerver.lib.push_notifications import uses_notification_bouncer
|
||||
from zerver.lib.realm_description import get_realm_rendered_description
|
||||
from zerver.lib.request import RequestNotes
|
||||
from zerver.models import Message, Realm, Stream, UserProfile
|
||||
|
@ -28,6 +29,7 @@ class BillingInfo:
|
|||
show_billing: bool
|
||||
show_plans: bool
|
||||
sponsorship_pending: bool
|
||||
show_remote_billing: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -80,6 +82,10 @@ def get_billing_info(user_profile: Optional[UserProfile]) -> BillingInfo:
|
|||
show_billing = False
|
||||
show_plans = False
|
||||
sponsorship_pending = False
|
||||
show_remote_billing = (
|
||||
user_profile is not None and user_profile.has_billing_access and uses_notification_bouncer()
|
||||
)
|
||||
|
||||
# This query runs on home page load, so we want to avoid
|
||||
# hitting the database if possible. So, we only run it for the user
|
||||
# types that can actually see the billing info.
|
||||
|
@ -101,6 +107,7 @@ def get_billing_info(user_profile: Optional[UserProfile]) -> BillingInfo:
|
|||
show_billing=show_billing,
|
||||
show_plans=show_plans,
|
||||
sponsorship_pending=sponsorship_pending,
|
||||
show_remote_billing=show_remote_billing,
|
||||
)
|
||||
|
||||
|
||||
|
@ -207,6 +214,7 @@ def build_page_params_for_home_page_load(
|
|||
two_fa_enabled=two_fa_enabled,
|
||||
apps_page_url=get_apps_page_url(),
|
||||
show_billing=billing_info.show_billing,
|
||||
show_remote_billing=billing_info.show_remote_billing,
|
||||
promote_sponsoring_zulip=promote_sponsoring_zulip_in_realm(realm),
|
||||
show_plans=billing_info.show_plans,
|
||||
sponsorship_pending=billing_info.sponsorship_pending,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import logging
|
||||
import urllib
|
||||
from typing import Any, Dict, List, Mapping, Tuple, Union
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
|
@ -11,7 +11,7 @@ from pydantic import UUID4, BaseModel, ConfigDict
|
|||
|
||||
from analytics.models import InstallationCount, RealmCount
|
||||
from version import ZULIP_VERSION
|
||||
from zerver.lib.exceptions import JsonableError
|
||||
from zerver.lib.exceptions import JsonableError, MissingRemoteRealmError
|
||||
from zerver.lib.export import floatify_datetime_fields
|
||||
from zerver.lib.outgoing_http import OutgoingSession
|
||||
from zerver.models import Realm, RealmAuditLog
|
||||
|
@ -43,6 +43,12 @@ class RealmDataForAnalytics(BaseModel):
|
|||
uuid_owner_secret: str
|
||||
|
||||
|
||||
class UserDataForRemoteBilling(BaseModel):
|
||||
uuid: UUID4
|
||||
email: str
|
||||
full_name: str
|
||||
|
||||
|
||||
def send_to_push_bouncer(
|
||||
method: str,
|
||||
endpoint: str,
|
||||
|
@ -117,6 +123,14 @@ def send_to_push_bouncer(
|
|||
from zerver.lib.push_notifications import InvalidRemotePushDeviceTokenError
|
||||
|
||||
raise InvalidRemotePushDeviceTokenError
|
||||
elif (
|
||||
endpoint == "server/billing"
|
||||
and "code" in result_dict
|
||||
and result_dict["code"] == "MISSING_REMOTE_REALM"
|
||||
): # nocoverage
|
||||
# The callers requesting this endpoint want the exception to propagate
|
||||
# so they can catch it.
|
||||
raise MissingRemoteRealmError
|
||||
else:
|
||||
# But most other errors coming from the push bouncer
|
||||
# server are client errors (e.g. never-registered token)
|
||||
|
@ -186,8 +200,11 @@ def build_analytics_data(
|
|||
)
|
||||
|
||||
|
||||
def get_realms_info_for_push_bouncer() -> List[RealmDataForAnalytics]:
|
||||
def get_realms_info_for_push_bouncer(realm_id: Optional[int] = None) -> List[RealmDataForAnalytics]:
|
||||
realms = Realm.objects.order_by("id")
|
||||
if realm_id is not None: # nocoverage
|
||||
realms = realms.filter(id=realm_id)
|
||||
|
||||
realm_info_list = [
|
||||
RealmDataForAnalytics(
|
||||
id=realm.id,
|
||||
|
|
|
@ -531,6 +531,9 @@ def write_instrumentation_reports(full_suite: bool, include_webhooks: bool) -> N
|
|||
"scim/v2/ServiceProviderConfig",
|
||||
"scim/v2/Groups(?:/(?P<uuid>[^/]+))?",
|
||||
"scim/v2/Groups/.search",
|
||||
# TODO: This endpoint and the rest of its system are a work in progress,
|
||||
# we are not testing it yet.
|
||||
"self-hosted-billing/",
|
||||
*(webhook.url for webhook in WEBHOOK_INTEGRATIONS if not include_webhooks),
|
||||
}
|
||||
|
||||
|
|
|
@ -212,6 +212,7 @@ class HomeTest(ZulipTestCase):
|
|||
"settings_send_digest_emails",
|
||||
"show_billing",
|
||||
"show_plans",
|
||||
"show_remote_billing",
|
||||
"show_webathena",
|
||||
"sponsorship_pending",
|
||||
"starred_messages",
|
||||
|
@ -364,6 +365,7 @@ class HomeTest(ZulipTestCase):
|
|||
"server_sentry_dsn",
|
||||
"show_billing",
|
||||
"show_plans",
|
||||
"show_remote_billing",
|
||||
"show_webathena",
|
||||
"sponsorship_pending",
|
||||
"test_suite",
|
||||
|
@ -831,6 +833,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertEqual(page_params["narrow"], [dict(operator="stream", operand=stream_name)])
|
||||
self.assertEqual(page_params["max_message_id"], -1)
|
||||
|
||||
@override_settings(PUSH_NOTIFICATION_BOUNCER_URL="https://push.zulip.org.example.com")
|
||||
def test_get_billing_info(self) -> None:
|
||||
user = self.example_user("desdemona")
|
||||
user.role = UserProfile.ROLE_REALM_OWNER
|
||||
|
@ -841,6 +844,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertFalse(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertTrue(billing_info.show_remote_billing)
|
||||
|
||||
# realm owner, with inactive CustomerPlan and realm plan_type SELF_HOSTED -> show only billing link
|
||||
customer = Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_id")
|
||||
|
@ -857,6 +861,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertTrue(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertTrue(billing_info.show_remote_billing)
|
||||
|
||||
# realm owner, with inactive CustomerPlan and realm plan_type LIMITED -> show billing link and plans
|
||||
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_LIMITED, acting_user=None)
|
||||
|
@ -865,6 +870,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertTrue(billing_info.show_billing)
|
||||
self.assertTrue(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertTrue(billing_info.show_remote_billing)
|
||||
|
||||
# Always false without CORPORATE_ENABLED
|
||||
with self.settings(CORPORATE_ENABLED=False):
|
||||
|
@ -872,6 +878,8 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertFalse(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
# show_remote_billing is independent of CORPORATE_ENABLED
|
||||
self.assertTrue(billing_info.show_remote_billing)
|
||||
|
||||
# Always false without a UserProfile
|
||||
with self.settings(CORPORATE_ENABLED=True):
|
||||
|
@ -879,6 +887,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertFalse(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertFalse(billing_info.show_remote_billing)
|
||||
|
||||
# realm admin, with CustomerPlan and realm plan_type LIMITED -> don't show any links
|
||||
# Only billing admin and realm owner have access to billing.
|
||||
|
@ -889,6 +898,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertFalse(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertFalse(billing_info.show_remote_billing)
|
||||
|
||||
# billing admin, with CustomerPlan and realm plan_type STANDARD -> show only billing link
|
||||
user.role = UserProfile.ROLE_MEMBER
|
||||
|
@ -900,6 +910,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertTrue(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertTrue(billing_info.show_remote_billing)
|
||||
|
||||
# billing admin, with CustomerPlan and realm plan_type PLUS -> show only billing link
|
||||
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_PLUS, acting_user=None)
|
||||
|
@ -909,6 +920,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertTrue(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertTrue(billing_info.show_remote_billing)
|
||||
|
||||
# member, with CustomerPlan and realm plan_type STANDARD -> neither billing link or plans
|
||||
do_change_realm_plan_type(user.realm, Realm.PLAN_TYPE_STANDARD, acting_user=None)
|
||||
|
@ -919,6 +931,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertFalse(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertFalse(billing_info.show_remote_billing)
|
||||
|
||||
# guest, with CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans
|
||||
user.role = UserProfile.ROLE_GUEST
|
||||
|
@ -929,6 +942,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertFalse(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertFalse(billing_info.show_remote_billing)
|
||||
|
||||
# billing admin, but no CustomerPlan and realm plan_type SELF_HOSTED -> neither billing link or plans
|
||||
user.role = UserProfile.ROLE_MEMBER
|
||||
|
@ -940,6 +954,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertFalse(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertTrue(billing_info.show_remote_billing)
|
||||
|
||||
# billing admin, with sponsorship pending and realm plan_type SELF_HOSTED -> show only sponsorship pending link
|
||||
customer.sponsorship_pending = True
|
||||
|
@ -949,6 +964,7 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertFalse(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertTrue(billing_info.sponsorship_pending)
|
||||
self.assertTrue(billing_info.show_remote_billing)
|
||||
|
||||
# billing admin, no customer object and realm plan_type SELF_HOSTED -> no links
|
||||
customer.delete()
|
||||
|
@ -957,6 +973,13 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertFalse(billing_info.show_billing)
|
||||
self.assertFalse(billing_info.show_plans)
|
||||
self.assertFalse(billing_info.sponsorship_pending)
|
||||
self.assertTrue(billing_info.show_remote_billing)
|
||||
|
||||
# If the server doesn't have the push bouncer configured,
|
||||
# don't show remote billing.
|
||||
with self.settings(PUSH_NOTIFICATION_BOUNCER_URL=None):
|
||||
billing_info = get_billing_info(user)
|
||||
self.assertFalse(billing_info.show_remote_billing)
|
||||
|
||||
def test_promote_sponsoring_zulip_in_realm(self) -> None:
|
||||
realm = get_realm("zulip")
|
||||
|
|
|
@ -1,17 +1,30 @@
|
|||
import re
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from zerver.decorator import human_users_only
|
||||
from zerver.lib.exceptions import JsonableError
|
||||
from zerver.decorator import human_users_only, zulip_login_required
|
||||
from zerver.lib.exceptions import (
|
||||
JsonableError,
|
||||
MissingRemoteRealmError,
|
||||
OrganizationOwnerRequiredError,
|
||||
)
|
||||
from zerver.lib.push_notifications import (
|
||||
InvalidPushDeviceTokenError,
|
||||
add_push_device_token,
|
||||
b64_to_hex,
|
||||
remove_push_device_token,
|
||||
send_test_push_notification,
|
||||
uses_notification_bouncer,
|
||||
)
|
||||
from zerver.lib.remote_server import (
|
||||
UserDataForRemoteBilling,
|
||||
get_realms_info_for_push_bouncer,
|
||||
send_realms_only_to_push_bouncer,
|
||||
send_to_push_bouncer,
|
||||
)
|
||||
from zerver.lib.request import REQ, has_request_variables
|
||||
from zerver.lib.response import json_success
|
||||
|
@ -101,3 +114,45 @@ def send_test_push_notification_api(
|
|||
send_test_push_notification(user_profile, devices)
|
||||
|
||||
return json_success(request)
|
||||
|
||||
|
||||
@zulip_login_required
|
||||
def self_hosting_auth_redirect(request: HttpRequest) -> HttpResponse: # nocoverage
|
||||
if not settings.DEVELOPMENT or not uses_notification_bouncer():
|
||||
return render(request, "404.html", status=404)
|
||||
|
||||
user = request.user
|
||||
assert user.is_authenticated
|
||||
assert isinstance(user, UserProfile)
|
||||
if not user.has_billing_access:
|
||||
# We may want to replace this with an html error page at some point,
|
||||
# but this endpoint shouldn't be accessible via the UI to an unauthorized
|
||||
# user - and they need to directly enter the URL in their browser. So a json
|
||||
# error may be sufficient.
|
||||
raise OrganizationOwnerRequiredError
|
||||
|
||||
realm_info = get_realms_info_for_push_bouncer(user.realm_id)[0]
|
||||
|
||||
user_info = UserDataForRemoteBilling(
|
||||
uuid=user.uuid,
|
||||
email=user.delivery_email,
|
||||
full_name=user.full_name,
|
||||
)
|
||||
|
||||
post_data = {
|
||||
"user": user_info.model_dump_json(),
|
||||
"realm": realm_info.model_dump_json(),
|
||||
}
|
||||
try:
|
||||
result = send_to_push_bouncer("POST", "server/billing", post_data)
|
||||
except MissingRemoteRealmError:
|
||||
# Upload realm info and re-try. It should work now.
|
||||
send_realms_only_to_push_bouncer()
|
||||
result = send_to_push_bouncer("POST", "server/billing", post_data)
|
||||
|
||||
if result["result"] != "success":
|
||||
raise JsonableError(_("Error returned by the bouncer: {result}").format(result=result))
|
||||
|
||||
redirect_url = result["billing_access_url"]
|
||||
assert isinstance(redirect_url, str)
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
|
|
|
@ -3,6 +3,7 @@ from typing import Any
|
|||
from django.conf.urls import include
|
||||
from django.urls import path
|
||||
|
||||
from corporate.views.remote_billing_page import remote_server_billing_entry
|
||||
from zilencer.auth import remote_server_path
|
||||
from zilencer.views import (
|
||||
deactivate_remote_server,
|
||||
|
@ -33,6 +34,9 @@ push_bouncer_patterns = [
|
|||
remote_server_path("remotes/server/analytics/status", GET=remote_server_check_analytics),
|
||||
]
|
||||
|
||||
billing_patterns = [remote_server_path("remotes/server/billing", POST=remote_server_billing_entry)]
|
||||
|
||||
urlpatterns = [
|
||||
path("api/v1/", include(push_bouncer_patterns)),
|
||||
path("api/v1/", include(billing_patterns)),
|
||||
]
|
||||
|
|
|
@ -92,6 +92,7 @@ from zerver.views.push_notifications import (
|
|||
add_apns_device_token,
|
||||
remove_android_reg_id,
|
||||
remove_apns_device_token,
|
||||
self_hosting_auth_redirect,
|
||||
send_test_push_notification_api,
|
||||
)
|
||||
from zerver.views.reactions import add_reaction, remove_reaction
|
||||
|
@ -820,6 +821,10 @@ urls += [
|
|||
path("policies/<slug:article>", policy_documentation_view),
|
||||
]
|
||||
|
||||
urls += [
|
||||
path("self-hosted-billing/", self_hosting_auth_redirect, name="self_hosting_auth_redirect"),
|
||||
]
|
||||
|
||||
if not settings.CORPORATE_ENABLED: # nocoverage
|
||||
# This conditional behavior cannot be tested directly, since
|
||||
# urls.py is not readily reloaded in Django tests. See the block
|
||||
|
|
Loading…
Reference in New Issue