Members
This commit is contained in:
@@ -11,6 +11,14 @@
|
||||
button.button { cursor: pointer; font-size: inherit; line-height: inherit; }
|
||||
.member-panel { padding: 2rem; border: 1px solid var(--border); border-radius: 1.3rem; background: var(--surface); }
|
||||
.member-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 1.5rem; }
|
||||
.service-access-panel { margin-bottom: 1.5rem; }
|
||||
.service-access-panel h3 { margin-bottom: .8rem; }
|
||||
.service-access-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: .6rem 1.5rem; margin: 0; padding: 0; list-style: none; }
|
||||
.service-access-list li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .25rem .8rem; padding-block: .35rem; border-bottom: 1px solid var(--border); }
|
||||
.service-access-link { display: inline-flex; align-items: center; gap: .7rem; color: var(--text); text-decoration: none; }
|
||||
.service-access-link:hover { color: var(--accent-strong); }
|
||||
.service-access-link img { width: 1.75rem; height: 1.75rem; object-fit: contain; border-radius: .35rem; }
|
||||
.service-access-list span { color: var(--muted); }
|
||||
.tier-card { display: flex; flex-direction: column; border-top: 3px solid var(--accent); }
|
||||
.free-tier { border-top-color: var(--green); }
|
||||
.tier-card h3 { font-size: 1.8rem; }
|
||||
|
||||
@@ -9,5 +9,5 @@ class SecurityHeaders:
|
||||
# CSRF checks. no-referrer can produce Origin: null in browsers.
|
||||
# same-origin still withholds verification URLs from external sites.
|
||||
response["Referrer-Policy"] = "same-origin"
|
||||
response["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
response["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'self' https://discourse.eom.dev https://gitea.eom.dev https://element.eom.dev https://mastodon.eom.dev https://peertube.eom.dev; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
return response
|
||||
|
||||
@@ -7,6 +7,30 @@ from ldap3.core.exceptions import LDAPInvalidDnError
|
||||
from ldap3.utils.dn import parse_dn
|
||||
|
||||
|
||||
SERVICE_LINKS = {
|
||||
"Discourse · community forums": {
|
||||
"url": "https://discourse.eom.dev/",
|
||||
"favicon": "https://discourse.eom.dev/uploads/default/optimized/1X/_129430568242d1b7f853bb13ebea28b3f6af4e7_2_32x32.png",
|
||||
},
|
||||
"Gitea · Git hosting": {
|
||||
"url": "https://gitea.eom.dev/",
|
||||
"favicon": "https://gitea.eom.dev/assets/img/favicon.svg",
|
||||
},
|
||||
"Matrix · community chat": {
|
||||
"url": "https://element.eom.dev/",
|
||||
"favicon": "https://element.eom.dev/vector-icons/144.5a63bf2.png",
|
||||
},
|
||||
"Mastodon · social networking": {
|
||||
"url": "https://mastodon.eom.dev/",
|
||||
"favicon": "https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png",
|
||||
},
|
||||
"PeerTube · video sharing": {
|
||||
"url": "https://peertube.eom.dev/",
|
||||
"favicon": "https://peertube.eom.dev/client/assets/images/favicon.png",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_service_groups(value, paid_levels):
|
||||
if not isinstance(value, dict):
|
||||
raise ImproperlyConfigured("Membership service groups must be an object mapping levels to lists of LDAP group DNs.")
|
||||
@@ -51,6 +75,8 @@ def service_names(level, service_groups):
|
||||
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 common_name.casefold() == "dex users":
|
||||
continue
|
||||
if not common_name:
|
||||
name = "Additional community service"
|
||||
else:
|
||||
@@ -61,3 +87,34 @@ def service_names(level, service_groups):
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def service_access_catalog(tiers, service_groups):
|
||||
"""List each advertised service once, with the levels that include it."""
|
||||
level_labels = {0: "Open Lab"}
|
||||
level_labels.update({tier["level"]: tier["label"] for tier in tiers.values()})
|
||||
levels = sorted(level_labels)
|
||||
service_levels = {}
|
||||
for level in levels:
|
||||
for name in service_names(level, service_groups):
|
||||
service_levels.setdefault(name, []).append(level)
|
||||
|
||||
catalog = []
|
||||
for name, included_levels in service_levels.items():
|
||||
if included_levels == levels:
|
||||
access = "Every membership level"
|
||||
else:
|
||||
ranges = []
|
||||
start = previous = included_levels[0]
|
||||
for level in included_levels[1:]:
|
||||
if level != previous + 1:
|
||||
ranges.append((start, previous))
|
||||
start = level
|
||||
previous = level
|
||||
ranges.append((start, previous))
|
||||
access = ", ".join(
|
||||
level_labels[first] if first == last else f"{level_labels[first]}–{level_labels[last]}"
|
||||
for first, last in ranges
|
||||
)
|
||||
catalog.append({"name": name, "access": access, **SERVICE_LINKS.get(name, {})})
|
||||
return catalog
|
||||
|
||||
@@ -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, service_names, validate_service_groups
|
||||
from members.service_groups import configured_groups, service_access_catalog, service_names, validate_service_groups
|
||||
from ldap3 import Connection, MOCK_SYNC, Server
|
||||
|
||||
BASE = "ou=groups,dc=example,dc=org"
|
||||
@@ -93,6 +93,8 @@ class ServiceDisplayTests(TestCase):
|
||||
for key, tier in tiers.items():
|
||||
self.assertEqual(tier["description"], deployed[key]["description"])
|
||||
self.assertEqual(tier["benefits"], deployed[key]["benefits"])
|
||||
self.assertIn(f"{tier['label']}-tier giveaways", tier["benefits"])
|
||||
self.assertFalse(any("exclusive to" in benefit.casefold() for benefit in tier["benefits"]))
|
||||
|
||||
def test_homepage_lists_services_with_clear_access_labels(self):
|
||||
root = Path(__file__).absolute().parents[3]
|
||||
@@ -142,13 +144,38 @@ class ServiceDisplayTests(TestCase):
|
||||
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"], [])
|
||||
catalog = response.context["services"]
|
||||
self.assertEqual(catalog, [
|
||||
{"name": "Matrix · community chat", "access": "Open Lab", "url": "https://element.eom.dev/", "favicon": "https://element.eom.dev/vector-icons/144.5a63bf2.png"},
|
||||
{"name": "Gitea · Git hosting", "access": "Bronze", "url": "https://gitea.eom.dev/", "favicon": "https://gitea.eom.dev/assets/img/favicon.svg"},
|
||||
{"name": "Mastodon · social networking", "access": "Silver", "url": "https://mastodon.eom.dev/", "favicon": "https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png"},
|
||||
{"name": "PeerTube · video sharing", "access": "Silver", "url": "https://peertube.eom.dev/", "favicon": "https://peertube.eom.dev/client/assets/images/favicon.png"},
|
||||
])
|
||||
self.assertNotContains(response, "Dex · single sign-on")
|
||||
self.assertContains(response, 'href="https://element.eom.dev/"')
|
||||
self.assertContains(response, 'alt="" width="32" height="32"')
|
||||
self.assertIn("https://element.eom.dev", response["Content-Security-Policy"])
|
||||
self.assertEqual(response.content.count(b"Matrix \xc2\xb7 community chat"), 1)
|
||||
self.assertContains(response, "Plus all previous benefits")
|
||||
self.assertNotContains(response, "except tier-exclusive giveaways")
|
||||
self.assertContains(response, "Support independent projects and the community</li><li>Plus all previous benefits</li>")
|
||||
self.assertContains(response, "How do membership giveaways work?")
|
||||
self.assertContains(response, 'href="https://element.eom.dev/"')
|
||||
self.assertContains(response, 'src="https://element.eom.dev/vector-icons/144.5a63bf2.png"')
|
||||
self.assertIn("https://element.eom.dev", response["Content-Security-Policy"])
|
||||
self.assertNotContains(response, BASE)
|
||||
self.assertNotIn("services", TIERS["supporter"])
|
||||
|
||||
def test_service_catalog_collapses_services_shared_by_every_level(self):
|
||||
tiers = {"bronze": {"level": 1, "label": "Bronze"}, "gold": {"level": 3, "label": "Gold"}}
|
||||
groups = {
|
||||
"0": ["cn=Matrix Users," + BASE],
|
||||
"1": ["cn=Matrix Users," + BASE],
|
||||
"3": ["cn=Matrix Users," + BASE, "cn=Mastodon Users," + BASE],
|
||||
}
|
||||
self.assertEqual(service_access_catalog(tiers, groups), [
|
||||
{"name": "Matrix · community chat", "access": "Every membership level", "url": "https://element.eom.dev/", "favicon": "https://element.eom.dev/vector-icons/144.5a63bf2.png"},
|
||||
{"name": "Mastodon · social networking", "access": "Gold", "url": "https://mastodon.eom.dev/", "favicon": "https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png"},
|
||||
])
|
||||
|
||||
@override_settings(MEMBERSHIP_SERVICE_GROUPS={})
|
||||
def test_unconfigured_services_do_not_advertise_access(self):
|
||||
|
||||
@@ -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, service_names
|
||||
from .service_groups import configured_groups, service_access_catalog
|
||||
from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm
|
||||
from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, RateWindow, Registration, Subscription, WebhookEvent
|
||||
|
||||
@@ -77,8 +77,9 @@ def home(request):
|
||||
subscriptions = Subscription.objects.filter(account=account) if account else []
|
||||
invoices = CryptoInvoice.objects.filter(account=account).order_by("-created_at")[:20] if account else []
|
||||
# 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())
|
||||
tiers = settings.MEMBERSHIP_TIERS
|
||||
services = service_access_catalog(tiers, settings.MEMBERSHIP_SERVICE_GROUPS)
|
||||
return page(request, account=account, tiers=tiers, services=services, subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now())
|
||||
|
||||
|
||||
@require_http_methods(["GET", "POST"])
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
{% else %}
|
||||
<section class="member-hero">
|
||||
<p class="eyebrow">Keep your own corner of the internet alive</p>
|
||||
<h1>A place to belong.<br>A way to give back.</h1>
|
||||
<h1>Support the work.<br>Get closer to what’s next.</h1>
|
||||
<p class="member-intro">Join free for community services and games. Choose a paid tier for exclusive content, previews and a closer say in what comes next.</p>
|
||||
{% if account %}
|
||||
<div class="member-panel account-panel">
|
||||
@@ -70,34 +70,34 @@
|
||||
</section>
|
||||
<section aria-labelledby="tiers-heading">
|
||||
<div class="section-heading"><p class="eyebrow">Your support, your choice</p><h2 id="tiers-heading">Find your membership</h2><p>Public projects and ordinary community participation stay free. Membership adds a closer connection to the work, not a barrier to joining in.</p></div>
|
||||
<div class="member-panel service-access-panel">
|
||||
<h3>Included services</h3>
|
||||
{% if services %}<ul class="service-access-list">{% for service in services %}<li><div class="service-access-identity">{% if service.url %}<a class="service-access-link" href="{{ service.url }}"><img src="{{ service.favicon }}" alt="" width="32" height="32" loading="lazy" decoding="async"><strong>{{ service.name }}</strong></a>{% else %}<strong>{{ service.name }}</strong>{% endif %}</div><span>{{ service.access }}</span></li>{% endfor %}</ul>{% else %}<p>No additional service access is currently configured.</p>{% endif %}
|
||||
</div>
|
||||
<div class="member-grid">
|
||||
<article class="member-panel tier-card free-tier">
|
||||
<p class="card-kicker">Level 0 · The starting point</p><h3>Open Lab</h3><p class="tier-price">Free <span>/ always</span></p>
|
||||
<p>Your free community account.</p>
|
||||
<ul><li>Games hosted on eom.dev</li><li>Giveaways exclusive to Open Lab members</li></ul>
|
||||
<h4>Included services</h4>
|
||||
<ul class="tier-services">{% for service in free_services %}<li>{{ service }}</li>{% empty %}<li>No additional service access is currently configured.</li>{% endfor %}</ul>
|
||||
<ul><li>Games hosted on eom.dev</li><li>Open Lab-tier giveaways</li></ul>
|
||||
{% if not account %}<a class="button button-secondary" href="{% url 'members:register' %}">Join the community</a>{% else %}<p class="tier-footnote">Your free account is ready.</p>{% endif %}
|
||||
</article>
|
||||
{% for key, tier in tiers.items %}
|
||||
<article class="member-panel tier-card">
|
||||
<p class="card-kicker">Level {{ tier.level }}</p><h3>{{ tier.label }}</h3><p class="tier-price">{{ tier.price_label }}</p>
|
||||
<p>{{ tier.description|default:"Help keep the projects and community running." }}</p>
|
||||
<ul>{% for benefit in tier.benefits %}<li>{{ benefit }}</li>{% empty %}<li>Support independent projects and the community</li>{% endfor %}</ul>
|
||||
<h4>Included services</h4>
|
||||
<ul class="tier-services">{% for service in tier.services %}<li>{{ service }}</li>{% empty %}<li>No additional service access is currently configured.</li>{% endfor %}</ul>
|
||||
<ul>{% for benefit in tier.benefits %}<li>{{ benefit }}</li>{% empty %}<li>Support independent projects and the community</li>{% endfor %}<li>Plus all previous benefits</li></ul>
|
||||
{% if account %}<div class="tier-payment-actions"><form action="{% url 'members:checkout' key %}" method="post">{% csrf_token %}<button class="button button-primary" type="submit">Subscribe with Stripe</button></form>
|
||||
{% if shkeeper_enabled and tier.usd_amount %}<a class="button button-secondary" href="{% url 'members:crypto_checkout' key %}">Pay with crypto</a>{% endif %}</div>{% else %}<a class="button button-primary" href="{% url 'members:register' %}">Create an account to join</a>{% endif %}
|
||||
</article>
|
||||
{% empty %}<div class="member-panel"><h3>Paid memberships are coming soon.</h3><p>You can create your free account now. Paid tiers will appear when their prices and groups are configured.</p></div>{% endfor %}
|
||||
</div>
|
||||
<p class="member-small">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.</p>
|
||||
<p class="member-small">The list above shows each service once and the levels that include it. 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.</p>
|
||||
<p class="member-small">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.</p>
|
||||
</section>
|
||||
<section class="member-faq" aria-labelledby="membership-faq">
|
||||
<h2 id="membership-faq">Simple, by design.</h2>
|
||||
<details><summary>Do I need a paid membership to play games?</summary><p>No. A free Open Lab (Level 0) account includes access to games hosted on eom.dev, and paid members can play too. Exclusive game servers for higher tiers may be added in the future, but none are currently offered or included as a paid benefit.</p></details>
|
||||
<details><summary>How do tier-exclusive giveaways work?</summary><p>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.</p></details>
|
||||
<details><summary>How do membership giveaways work?</summary><p>Giveaways are associated with a membership tier. Higher tiers include giveaway benefits from lower tiers, including Open Lab. Each giveaway's rules will list its availability, dates, prizes and any eligibility restrictions. Membership does not guarantee a prize.</p></details>
|
||||
<details><summary>Who can vote on the platform's direction?</summary><p>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.</p></details>
|
||||
<details><summary>What happens when a membership ends?</summary><p>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.</p></details>
|
||||
<details><summary>Where do members connect?</summary><p>On Discourse and Matrix. Ordinary community access is free; Bronze and above also receive exclusive content in those services.</p></details>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"description": "Go beyond the public feed.",
|
||||
"benefits": [
|
||||
"Exclusive Discourse and Matrix content, including work-in-progress updates",
|
||||
"Giveaways exclusive to Bronze members",
|
||||
"Bronze-tier giveaways",
|
||||
"Optional credit on the community supporters page"
|
||||
]
|
||||
},
|
||||
@@ -22,8 +22,7 @@
|
||||
"group_dn": "cn=Level 2,ou=Members,dc=eom,dc=dev",
|
||||
"description": "Try new work before release.",
|
||||
"benefits": [
|
||||
"Bronze content and community benefits",
|
||||
"Giveaways exclusive to Silver members",
|
||||
"Silver-tier giveaways",
|
||||
"Early previews, playtests and listening sessions when available"
|
||||
]
|
||||
},
|
||||
@@ -36,8 +35,7 @@
|
||||
"group_dn": "cn=Level 3,ou=Members,dc=eom,dc=dev",
|
||||
"description": "Help shape the next experiment.",
|
||||
"benefits": [
|
||||
"Silver content and community benefits",
|
||||
"Giveaways exclusive to Gold members",
|
||||
"Gold-tier giveaways",
|
||||
"Member polls on future project topics and experiments",
|
||||
"Invitations to occasional small-group feedback sessions"
|
||||
]
|
||||
@@ -51,8 +49,7 @@
|
||||
"group_dn": "cn=Level 4,ou=Members,dc=eom,dc=dev",
|
||||
"description": "Have a voice in the platform's direction.",
|
||||
"benefits": [
|
||||
"Gold content and community benefits",
|
||||
"Giveaways exclusive to Platinum members",
|
||||
"Platinum-tier giveaways",
|
||||
"Exclusive platform-direction polls"
|
||||
]
|
||||
},
|
||||
@@ -65,8 +62,7 @@
|
||||
"group_dn": "cn=Level 5,ou=Members,dc=eom,dc=dev",
|
||||
"description": "Give the independent work its biggest boost.",
|
||||
"benefits": [
|
||||
"Platinum content and platform-direction polls",
|
||||
"Giveaways exclusive to Diamond members",
|
||||
"Diamond-tier giveaways",
|
||||
"Optional Diamond credit on the supporters page"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user