From 9eac45a7f9b30db7a4f70c645c84c4c17f1df936 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Thu, 29 Oct 2026 14:49:44 -0400 Subject: [PATCH] UI --- membership/members/service_groups.py | 26 +++++++++++++ .../members/tests/test_service_groups.py | 38 ++++++++++++++++++- membership/members/views.py | 6 ++- membership/service-groups.eom.json | 36 ++++++++++++++++-- membership/templates/members.html | 10 ++++- membership/tiers.eom.json | 19 +++++++--- 6 files changed, 120 insertions(+), 15 deletions(-) diff --git a/membership/members/service_groups.py b/membership/members/service_groups.py index 6f01c61..45cc8a6 100755 --- a/membership/members/service_groups.py +++ b/membership/members/service_groups.py @@ -35,3 +35,29 @@ def configured_groups(tiers, service_groups): return {tier["group_dn"] for tier in tiers.values()} | { dn for groups in service_groups.values() for dn in groups } + + +def service_names(level, service_groups): + """Public labels from the same exact-level mapping used by reconciliation.""" + labels = { + "dex users": "Dex · single sign-on", + "discourse users": "Discourse · community forums", + "gitea users": "Gitea · Git hosting", + "matrix users": "Matrix · community chat", + "mastodon users": "Mastodon · social networking", + "peertube users": "PeerTube · video sharing", + } + names = [] + for dn in service_groups.get(str(level), []): + # Show only a group's common name, never its directory hierarchy. + common_name = next((value for attribute, value, _separator in parse_dn(dn) if attribute.casefold() == "cn"), "") + if not common_name: + name = "Additional community service" + else: + # LDAP DN values escape reserved characters and UTF-8 bytes. + raw = re.sub(r"\\([,=+<>#;\"\\ ])", r"\1", common_name) + raw = re.sub(r"(?:\\[0-9a-fA-F]{2})+", lambda match: bytes.fromhex(match.group().replace("\\", "")).decode("utf-8", errors="replace"), raw) + name = labels.get(raw.casefold(), re.sub(r"\s+users$", "", raw, flags=re.IGNORECASE)) + if name not in names: + names.append(name) + return names diff --git a/membership/members/tests/test_service_groups.py b/membership/members/tests/test_service_groups.py index 9c8a1ba..e2eaa4a 100755 --- a/membership/members/tests/test_service_groups.py +++ b/membership/members/tests/test_service_groups.py @@ -15,7 +15,7 @@ from django.utils import timezone from members import billing, views from members.models import Account, CryptoInvoice, ManagedGroup, Subscription -from members.service_groups import configured_groups, validate_service_groups +from members.service_groups import configured_groups, service_names, validate_service_groups from ldap3 import Connection, MOCK_SYNC, Server BASE = "ou=groups,dc=example,dc=org" @@ -79,6 +79,42 @@ class ServiceGroupLDAPIntegrationTests(TestCase): self.assertTrue(ManagedGroup.objects.filter(account=member, dn=FREE_GROUP).exists()) +@override_settings(MEMBERSHIP_TIERS=TIERS) +class ServiceDisplayTests(TestCase): + @override_settings(MEMBERSHIP_SERVICE_GROUPS={ + "0": ["cn=Dex Users," + BASE, "cn=Matrix Users," + BASE], + "1": ["cn=Gitea Users," + BASE], + "2": ["cn=Mastodon Users," + BASE, "cn=PeerTube Users," + BASE], + }) + def test_home_displays_exact_level_services_without_ldap_or_settings_mutation(self): + with patch("members.directory.connection", side_effect=AssertionError("Public page must not contact LDAP")): + response = self.client.get("/members/") + self.assertContains(response, "Dex · single sign-on") + self.assertEqual(response.context["free_services"], ["Dex · single sign-on", "Matrix · community chat"]) + self.assertEqual(response.context["tiers"]["supporter"]["services"], ["Gitea · Git hosting"]) + self.assertEqual(response.context["tiers"]["patron"]["services"], ["Mastodon · social networking", "PeerTube · video sharing"]) + self.assertEqual(response.context["tiers"]["premier"]["services"], []) + self.assertNotContains(response, BASE) + self.assertNotIn("services", TIERS["supporter"]) + + @override_settings(MEMBERSHIP_SERVICE_GROUPS={}) + def test_unconfigured_services_do_not_advertise_access(self): + response = self.client.get("/members/") + self.assertContains(response, "No additional service access is currently configured.") + self.assertNotContains(response, "Mastodon · social networking") + + @override_settings(MEMBERSHIP_SERVICE_GROUPS={"0": [r"cn=\alert(1)\," + BASE]}) + def test_group_labels_are_html_escaped(self): + response = self.client.get("/members/") + self.assertContains(response, "<script>alert(1)</script>") + self.assertNotContains(response, "") + + def test_custom_labels_decode_dn_escapes_and_hide_hierarchy(self): + self.assertEqual(service_names(0, {"0": [r"cn=Games\, Friends Users," + BASE]}), ["Games, Friends"]) + self.assertEqual(service_names(0, {"0": [r"cn=Caf\c3\a9 Users," + BASE]}), ["Café"]) + self.assertEqual(service_names(0, {"0": ["ou=services," + BASE]}), ["Additional community service"]) + + class ServiceGroupValidationTests(SimpleTestCase): def test_valid_mapping_is_returned_unchanged(self): self.assertIs(validate_service_groups(SERVICE_GROUPS, {1, 2, 10}), SERVICE_GROUPS) diff --git a/membership/members/views.py b/membership/members/views.py index 89563d1..54c45af 100755 --- a/membership/members/views.py +++ b/membership/members/views.py @@ -26,7 +26,7 @@ from django.views.decorators.http import require_GET, require_POST, require_http from . import billing, directory, profiles, shkeeper from .forms import PasswordForm, RegistrationForm -from .service_groups import configured_groups +from .service_groups import configured_groups, service_names from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, RateWindow, Registration, Subscription, WebhookEvent @@ -76,7 +76,9 @@ def home(request): account = Account.objects.filter(pk=request.session.get("account_id")).first() if request.session.get("account_id") else None subscriptions = Subscription.objects.filter(account=account) if account else [] invoices = CryptoInvoice.objects.filter(account=account).order_by("-created_at")[:20] if account else [] - return page(request, account=account, tiers=settings.MEMBERSHIP_TIERS, subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now()) + # Presentation copies only: never mutate authorization/payment settings. + tiers = {key: {**tier, "services": service_names(tier["level"], settings.MEMBERSHIP_SERVICE_GROUPS)} for key, tier in settings.MEMBERSHIP_TIERS.items()} + return page(request, account=account, tiers=tiers, free_services=service_names(0, settings.MEMBERSHIP_SERVICE_GROUPS), subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now()) @require_http_methods(["GET", "POST"]) diff --git a/membership/service-groups.eom.json b/membership/service-groups.eom.json index 8c508ac..b2342d4 100755 --- a/membership/service-groups.eom.json +++ b/membership/service-groups.eom.json @@ -5,12 +5,40 @@ "cn=Gitea Users,ou=Services,dc=eom,dc=dev", "cn=Matrix Users,ou=Services,dc=eom,dc=dev" ], - "1": [], - "2": [], + "1": [ + "cn=Dex Users,ou=Services,dc=eom,dc=dev", + "cn=Discourse Users,ou=Services,dc=eom,dc=dev", + "cn=Gitea Users,ou=Services,dc=eom,dc=dev", + "cn=Matrix Users,ou=Services,dc=eom,dc=dev" + ], + "2": [ + "cn=Dex Users,ou=Services,dc=eom,dc=dev", + "cn=Discourse Users,ou=Services,dc=eom,dc=dev", + "cn=Gitea Users,ou=Services,dc=eom,dc=dev", + "cn=Matrix Users,ou=Services,dc=eom,dc=dev" + ], "3": [ + "cn=Dex Users,ou=Services,dc=eom,dc=dev", + "cn=Discourse Users,ou=Services,dc=eom,dc=dev", + "cn=Gitea Users,ou=Services,dc=eom,dc=dev", + "cn=Matrix Users,ou=Services,dc=eom,dc=dev", "cn=Mastodon Users,ou=Services,dc=eom,dc=dev", "cn=PeerTube Users,ou=Services,dc=eom,dc=dev" ], - "4": [], - "5": [] + "4": [ + "cn=Dex Users,ou=Services,dc=eom,dc=dev", + "cn=Discourse Users,ou=Services,dc=eom,dc=dev", + "cn=Gitea Users,ou=Services,dc=eom,dc=dev", + "cn=Matrix Users,ou=Services,dc=eom,dc=dev", + "cn=Mastodon Users,ou=Services,dc=eom,dc=dev", + "cn=PeerTube Users,ou=Services,dc=eom,dc=dev" + ], + "5": [ + "cn=Dex Users,ou=Services,dc=eom,dc=dev", + "cn=Discourse Users,ou=Services,dc=eom,dc=dev", + "cn=Gitea Users,ou=Services,dc=eom,dc=dev", + "cn=Matrix Users,ou=Services,dc=eom,dc=dev", + "cn=Mastodon Users,ou=Services,dc=eom,dc=dev", + "cn=PeerTube Users,ou=Services,dc=eom,dc=dev" + ] } diff --git a/membership/templates/members.html b/membership/templates/members.html index e21de9c..7af2e71 100755 --- a/membership/templates/members.html +++ b/membership/templates/members.html @@ -74,7 +74,9 @@

Level 0 · The starting point

Open Lab

Free / always

One account for eom.dev, its community, and its services.

- + +

Included services

+ {% if not account %}Join the community{% else %}

Your free account is ready.

{% endif %}
{% for key, tier in tiers.items %} @@ -82,16 +84,20 @@

Level {{ tier.level }} · Independent work, supported together

{{ tier.label }}

{{ tier.price_label }}

{{ tier.description|default:"Help keep the projects and community running." }}

+

Included services

+ {% if account %}
{% csrf_token %}
{% if shkeeper_enabled and tier.usd_amount %}Pay with crypto{% endif %}
{% else %}Create an account to join{% endif %} {% empty %}

Paid memberships are coming soon.

You can create your free account now. Paid tiers will appear when their prices and groups are configured.

{% endfor %} -

One shared members' space, not a separate community at every price. Platinum and Diamond include the same access as Gold; choose them if you would like to contribute more.

+

Service access follows the list shown for your membership level, not an automatic combination of lower tiers. Access updates after directory synchronization; you may need to sign in again to a service. Publicly available content remains subject to each service's own rules.

Previews are shared when available, and playtests, listening sessions and feedback gatherings are occasional. Membership does not include storage quotas, personal consulting, guaranteed support or promised feature development. Recognition is always optional.

Simple, by design.

+
How do tier-exclusive giveaways work?

Each membership tier, including free Open Lab, has its own exclusive giveaways when announced. Eligibility is based on the tier specified in each giveaway's rules; higher tiers do not automatically qualify for another tier's giveaway. Availability, dates, prizes and eligibility restrictions will be published for each giveaway. Membership does not guarantee a prize.

+
Who can vote on the platform's direction?

Platinum (Level 4) and Diamond (Level 5) members can take part in exclusive polls about the direction of the platform. These are separate from Gold's project-topic polls. Results help inform decisions; they are not binding commitments or guarantees of particular features.

What happens when a membership ends?

Your free eom.dev account stays. Member-only access ends when your paid membership expires. An immediate cancellation removes that access once your membership status updates.

Where do members connect?

The shared members' Matrix space is the home for informal conversations and work in progress. Private forum discussions can complement it as they become available, without splitting everyone into separate rooms for each tier.

What can I preview or take part in?

Depending on what is being made, Silver members and above may preview music, game builds, videos or new experiments and join playtests or listening sessions. Gold members and above can also take part in project-topic polls and occasional small-group feedback sessions. Polls help inform the work; they are not promises to build requested features.

diff --git a/membership/tiers.eom.json b/membership/tiers.eom.json index c087e0c..0c0a730 100755 --- a/membership/tiers.eom.json +++ b/membership/tiers.eom.json @@ -8,8 +8,9 @@ "group_dn": "cn=Level 1,ou=Members,dc=eom,dc=dev", "description": "Kick off the experiment with a private place to connect and a closer look at what is being made.", "benefits": [ - "Access to the shared members' Matrix space", + "Exclusive members-only content on Discourse and Matrix", "Behind-the-scenes notes and work-in-progress updates", + "Giveaways exclusive to Bronze members", "Optional credit on the community supporters page" ] }, @@ -22,7 +23,8 @@ "group_dn": "cn=Level 2,ou=Members,dc=eom,dc=dev", "description": "Venture behind the scenes and explore new experiments before their public release.", "benefits": [ - "Everything in Bronze", + "Bronze content and community benefits", + "Giveaways exclusive to Silver members", "Early previews of music, games, videos and experiments when available", "Invitations to member playtests and listening sessions" ] @@ -36,7 +38,8 @@ "group_dn": "cn=Level 3,ou=Members,dc=eom,dc=dev", "description": "Bring your perspective and help shape the next community experiment.", "benefits": [ - "Everything in Silver", + "Silver content and community benefits", + "Giveaways exclusive to Gold members", "Member polls on future project topics and experiments", "Invitations to occasional small-group feedback sessions" ] @@ -48,9 +51,11 @@ "price_label": "$32 USD / month", "usd_amount": "32.00", "group_dn": "cn=Level 4,ou=Members,dc=eom,dc=dev", - "description": "The same community access, with a larger contribution to keeping it independent.", + "description": "Support the platform and help guide its direction through exclusive polls.", "benefits": [ - "Everything in Gold, with the same access", + "Gold content and community benefits", + "Giveaways exclusive to Platinum members", + "Exclusive polls on the direction of the platform", "A larger contribution toward servers and public projects", "Choose this level if you would like to give more" ] @@ -64,7 +69,9 @@ "group_dn": "cn=Level 5,ou=Members,dc=eom,dc=dev", "description": "Help underwrite the public work and the community around it.", "benefits": [ - "Everything in Gold, with the same access", + "Gold content and community benefits", + "Giveaways exclusive to Diamond members", + "Exclusive polls on the direction of the platform", "Optional Diamond credit on the supporters page", "Your biggest boost for independent public projects" ]