From a568b709685483cd56d6a3ff781882fb727b91a7 Mon Sep 17 00:00:00 2001
From: JoaquinBN
Date: Wed, 22 Jul 2026 15:09:59 -0400
Subject: [PATCH 1/5] Add read-only validator section access
---
backend/.env.example | 3 +
backend/CLAUDE.md | 1 +
backend/social_tasks/eligibility.py | 18 ++++++
backend/social_tasks/tests/test_views.py | 53 ++++++++++++++++
backend/tally/settings.py | 22 +++++++
backend/users/serializers.py | 20 ++++++
.../tests/test_validator_section_viewer.py | 57 +++++++++++++++++
frontend/CLAUDE.md | 1 +
frontend/src/App.svelte | 42 +++++++++----
frontend/src/components/Sidebar.svelte | 4 +-
.../social-tasks/SocialTaskCard.svelte | 10 ++-
.../social-tasks/SocialTasksSection.svelte | 22 ++++---
frontend/src/lib/roleState.js | 13 ++++
frontend/src/routes/AllContributions.svelte | 62 +++++++++++++------
frontend/src/routes/Contributions.svelte | 33 +++++++---
frontend/src/tests/roleState.test.js | 23 +++++++
16 files changed, 327 insertions(+), 57 deletions(-)
create mode 100644 backend/users/tests/test_validator_section_viewer.py
diff --git a/backend/.env.example b/backend/.env.example
index 72d83c46..b15fd8f3 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -34,6 +34,9 @@ SIWE_DOMAIN=localhost:5173
# Blockchain Settings - Shared RPC (both networks on same chain)
VALIDATOR_RPC_URL=https://rpc.testnet-chain.genlayer.com
+# Optional singular user ID allowed to view validator portal sections without
+# receiving the Validator role. Leave empty to disable the exception.
+VALIDATOR_SECTION_VIEWER_USER_ID=
# Optional Web3 HTTP bounds (defaults shown; retries are after the initial request)
WEB3_RPC_TIMEOUT_SECONDS=10
WEB3_RPC_MAX_RETRIES=1
diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index 8a551317..2ce6125a 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -538,6 +538,7 @@ Located in `.env` file:
- `WEB3_RPC_TIMEOUT_SECONDS` - Optional timeout in seconds for validator Web3 HTTP requests (default `10`)
- `WEB3_RPC_MAX_RETRIES` - Optional number of retries after the initial validator Web3 HTTP request (default `1`)
- `VALIDATOR_CONTRACT_ADDRESS` - Smart contract address
+- `VALIDATOR_SECTION_VIEWER_USER_ID` - Optional singular user ID that receives read-only access to validator portal sections without a Validator profile, validator metrics membership, or validator task eligibility. Empty disables the exception; malformed/non-positive values fail startup.
- `SECRET_KEY` - Django secret key
- `DEBUG` - Debug mode flag
- `ALLOWED_HOSTS` - Allowed host headers
diff --git a/backend/social_tasks/eligibility.py b/backend/social_tasks/eligibility.py
index e41b5038..f55ab7c9 100644
--- a/backend/social_tasks/eligibility.py
+++ b/backend/social_tasks/eligibility.py
@@ -33,6 +33,24 @@ def validate_eligibility_requirements(value):
def evaluate_task_eligibility(task, user):
+ # Validator tasks can award validator leaderboard points. Portal viewing
+ # exceptions must never become an alternate path to earning those points.
+ if getattr(getattr(task, 'category', None), 'slug', None) == 'validator':
+ if user is None or not getattr(user, 'is_authenticated', False):
+ return EligibilityResult(
+ False,
+ 'Sign in with a validator account to complete this task.',
+ details={'requirements': [], 'required_role': 'validator'},
+ )
+
+ from validators.models import user_has_validator_profile
+ if not user_has_validator_profile(user):
+ return EligibilityResult(
+ False,
+ 'Only validators can complete validator tasks.',
+ details={'requirements': [], 'required_role': 'validator'},
+ )
+
requirements = task.eligibility_requirements or {}
normalized = _normalize_requirements(requirements)
rules = normalized['all'] + normalized['any']
diff --git a/backend/social_tasks/tests/test_views.py b/backend/social_tasks/tests/test_views.py
index 94e08ba9..d41bea32 100644
--- a/backend/social_tasks/tests/test_views.py
+++ b/backend/social_tasks/tests/test_views.py
@@ -11,6 +11,7 @@
from social_connections.models import DiscordConnection, TwitterConnection
from social_tasks.models import SocialTask, SocialTaskCompletion
from users.models import User
+from validators.models import Validator
TEST_ENCRYPTION_KEY = Fernet.generate_key().decode()
@@ -397,6 +398,58 @@ def test_complete_locked_task_returns_403_before_verification(self):
SocialTaskCompletion.objects.filter(user=self.user, task=self.click_task).exists()
)
+ def test_validator_task_is_locked_for_view_only_non_validator(self):
+ validator_category, _ = Category.objects.get_or_create(
+ slug='validator', defaults={'name': 'Validator'}
+ )
+ task = SocialTask.objects.create(
+ slug='validator-click-task',
+ name='Validator Click Task',
+ category=validator_category,
+ points=25,
+ verification_type='click_through',
+ action_url='https://example.com/validator',
+ )
+
+ with override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=self.user.id):
+ list_response = self.client.get('/api/v1/social-tasks/?category=validator')
+ complete_response = self.client.post(
+ f'/api/v1/social-tasks/{task.slug}/complete/'
+ )
+
+ self.assertEqual(list_response.status_code, 200)
+ listed_task = list_response.json()[0]
+ self.assertEqual(listed_task['status'], 'locked')
+ self.assertFalse(listed_task['can_complete'])
+ self.assertEqual(listed_task['eligibility']['required_role'], 'validator')
+ self.assertEqual(complete_response.status_code, 403)
+ self.assertEqual(complete_response.json()['error'], 'eligibility_failed')
+ self.assertFalse(
+ SocialTaskCompletion.objects.filter(user=self.user, task=task).exists()
+ )
+
+ def test_validator_can_complete_validator_task(self):
+ validator_category, _ = Category.objects.get_or_create(
+ slug='validator', defaults={'name': 'Validator'}
+ )
+ task = SocialTask.objects.create(
+ slug='validator-role-task',
+ name='Validator Role Task',
+ category=validator_category,
+ points=25,
+ verification_type='click_through',
+ action_url='https://example.com/validator-role',
+ )
+ Validator.objects.create(user=self.user)
+
+ response = self.client.post(f'/api/v1/social-tasks/{task.slug}/complete/')
+
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(response.json()['points_awarded'], 25)
+ self.assertTrue(
+ SocialTaskCompletion.objects.filter(user=self.user, task=task).exists()
+ )
+
def test_any_requirement_allows_community_points_or_accepted_contribution(self):
self.click_task.eligibility_requirements = {
'any': [
diff --git a/backend/tally/settings.py b/backend/tally/settings.py
index a2e0839c..77b861c9 100644
--- a/backend/tally/settings.py
+++ b/backend/tally/settings.py
@@ -29,6 +29,22 @@ def get_required_env(key):
return value
+def get_optional_positive_int_env(key):
+ """Return an optional positive integer setting, failing fast if malformed."""
+ raw_value = os.environ.get(key, '').strip()
+ if not raw_value:
+ return None
+
+ try:
+ value = int(raw_value)
+ except ValueError as exc:
+ raise ValueError(f"Optional environment variable '{key}' must be a positive integer") from exc
+
+ if value <= 0:
+ raise ValueError(f"Optional environment variable '{key}' must be a positive integer")
+ return value
+
+
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
@@ -429,6 +445,12 @@ def get_port_from_argv():
# Custom user model
AUTH_USER_MODEL = 'users.User'
+# Singular, read-only portal exception. This does not grant the Validator role;
+# it only lets the configured account open validator read surfaces in the SPA.
+VALIDATOR_SECTION_VIEWER_USER_ID = get_optional_positive_int_env(
+ 'VALIDATOR_SECTION_VIEWER_USER_ID'
+)
+
# Blockchain settings
# Shared RPC URL for all networks
VALIDATOR_RPC_URL = get_required_env('VALIDATOR_RPC_URL')
diff --git a/backend/users/serializers.py b/backend/users/serializers.py
index 3c182d9f..e1ff22bf 100644
--- a/backend/users/serializers.py
+++ b/backend/users/serializers.py
@@ -1,3 +1,4 @@
+from django.conf import settings
from rest_framework import serializers
from .models import BanAppeal, User
from validators.models import Validator, ValidatorWallet, get_validator_profile
@@ -530,6 +531,7 @@ class UserSerializer(serializers.ModelSerializer):
email_verified_at = serializers.SerializerMethodField()
is_banned = serializers.SerializerMethodField()
ban_reason = serializers.SerializerMethodField()
+ can_view_validator_sections = serializers.SerializerMethodField()
class Meta:
model = User
@@ -545,6 +547,8 @@ class Meta:
'email', 'is_email_verified', 'email_verified_at',
# Ban status
'is_banned', 'ban_reason',
+ # Singular read-only validator portal exception
+ 'can_view_validator_sections',
# Social connections
'github_connection', 'twitter_connection', 'discord_connection',
# Referral fields
@@ -707,6 +711,22 @@ def get_ban_reason(self, obj):
return obj.ban_reason
return ''
+ def get_can_view_validator_sections(self, obj):
+ """Expose the singular validator viewer exception to its owner only.
+
+ The configured account remains a normal user: this flag is navigation
+ access, not role membership, and never creates a Validator profile.
+ """
+ configured_user_id = getattr(settings, 'VALIDATOR_SECTION_VIEWER_USER_ID', None)
+ request = self.context.get('request')
+ request_user = getattr(request, 'user', None)
+ return bool(
+ configured_user_id
+ and request_user
+ and request_user.is_authenticated
+ and request_user.pk == obj.pk == configured_user_id
+ )
+
def get_referral_code(self, obj):
"""Expose referral code only to the account owner or staff."""
if self._can_view_private_user_data(obj):
diff --git a/backend/users/tests/test_validator_section_viewer.py b/backend/users/tests/test_validator_section_viewer.py
new file mode 100644
index 00000000..b240aec3
--- /dev/null
+++ b/backend/users/tests/test_validator_section_viewer.py
@@ -0,0 +1,57 @@
+from django.test import override_settings
+from rest_framework.test import APITestCase
+
+from users.models import User
+from validators.models import Validator
+
+
+class ValidatorSectionViewerTests(APITestCase):
+ def setUp(self):
+ self.viewer = User.objects.create_user(
+ email='validator-section-viewer@example.com',
+ password='testpass123',
+ visible=True,
+ )
+ self.other_user = User.objects.create_user(
+ email='other-viewer@example.com',
+ password='testpass123',
+ visible=True,
+ )
+
+ def test_configured_owner_receives_view_flag_without_validator_role(self):
+ self.client.force_authenticate(user=self.viewer)
+
+ with override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=self.viewer.id):
+ response = self.client.get('/api/v1/users/me/')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(response.data['can_view_validator_sections'])
+ self.assertIsNone(response.data['validator'])
+ self.assertFalse(Validator.objects.filter(user=self.viewer).exists())
+
+ def test_unconfigured_user_does_not_receive_view_flag(self):
+ self.client.force_authenticate(user=self.other_user)
+
+ with override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=self.viewer.id):
+ response = self.client.get('/api/v1/users/me/')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertFalse(response.data['can_view_validator_sections'])
+
+ def test_view_flag_is_not_exposed_on_the_configured_users_public_profile(self):
+ self.client.force_authenticate(user=self.other_user)
+
+ with override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=self.viewer.id):
+ response = self.client.get(f'/api/v1/users/by-address/{self.viewer.id}/')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertFalse(response.data['can_view_validator_sections'])
+
+ @override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=None)
+ def test_exception_is_disabled_when_setting_is_empty(self):
+ self.client.force_authenticate(user=self.viewer)
+
+ response = self.client.get('/api/v1/users/me/')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertFalse(response.data['can_view_validator_sections'])
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 0038d6dc..217cfe8c 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -299,6 +299,7 @@ frontend/src/
- Shared utils: `src/lib/notificationUtils.js` (`asList` payload normalization, `followNotificationLink` link handling) and `src/lib/relativeTime.js` for compact timestamps
- **Sidebar**: `src/components/Sidebar.svelte`
- Side navigation with collapsible sections
+ - Validator read-only exception: `/users/me/` may return `can_view_validator_sections=true` for the single backend-configured account. This unlocks validator Contributions and Wall of Shame navigation without changing `hasEarnedRole`; Tasks and point-bearing actions remain Validator-role-only.
- Navigation structure:
- **Overview** (links to `/`) - Contains Testnet Asimov and Metrics sub-items
- **Builders** - Category-specific dashboard and pages
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 28f3520d..03a18224 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -14,7 +14,7 @@
import { authState, verifyAuth } from './lib/auth.js';
import { userStore } from './lib/userStore.js';
import { normalizeReferralCode } from './lib/referrals.js';
- import { hasEarnedRole, journeyPath, rolePath } from './lib/roleState.js';
+ import { hasEarnedRole, hasRoleSectionAccess, journeyPath, rolePath } from './lib/roleState.js';
import { installLinkInterceptor } from './lib/router.js';
import { getAnalyticsContext, initializeAnalytics, setConnectWalletIntent, templateRoute, trackEvent, trackPageView } from './lib/analytics.js';
@@ -176,14 +176,25 @@
});
// Role-gated subsection: must be authenticated AND hold the role, else bounce
- // to the role's main route (the funnel) so the user can start there. Role
- // membership (user.builder/validator/creator) is authoritative for all three
- // categories: existing members are grandfathered, the journey gates newcomers.
- function hasSubsectionAccess(user, category) {
- return hasEarnedRole(user, category);
+ // to the role's main route (the funnel) so the user can start there. Selected
+ // read-only validator routes may also admit the singular backend-configured
+ // viewer; point-bearing routes still require actual role membership.
+ /**
+ * @param {Record | null | undefined} user
+ * @param {string} category
+ * @param {boolean} allowViewOnly
+ */
+ function hasSubsectionAccess(user, category, allowViewOnly) {
+ return allowViewOnly
+ ? hasRoleSectionAccess(user, category)
+ : hasEarnedRole(user, category);
}
- function requireRoleForRoute(category) {
+ /**
+ * @param {string} category
+ * @param {{ allowViewOnly?: boolean }} options
+ */
+ function requireRoleForRoute(category, { allowViewOnly = false } = {}) {
return async (detail) => {
const authed = await requireAuthForRoute(detail);
if (!authed) return false;
@@ -205,7 +216,7 @@
user = null;
}
}
- if (await hasSubsectionAccess(user, category)) return true;
+ if (hasSubsectionAccess(user, category, allowViewOnly)) return true;
// Stale-navigation guard: only redirect if still on the guarded route.
const normalizePath = (value) => (value || '/').replace(/\/+$/, '') || '/';
@@ -225,9 +236,14 @@
};
}
- const roleGatedRoute = (component, category) => wrap({
+ /**
+ * @param {any} component
+ * @param {string} category
+ * @param {{ allowViewOnly?: boolean }} options
+ */
+ const roleGatedRoute = (component, category, options = {}) => wrap({
component,
- conditions: [requireRoleForRoute(category)],
+ conditions: [requireRoleForRoute(category, options)],
});
// Define routes
@@ -274,12 +290,12 @@
// Validators routes
'/validators': RoleFunnel,
'/validators/journey': ValidatorWaitlist,
- '/validators/contributions': roleGatedRoute(Contributions, 'validator'),
- '/validators/all-contributions': roleGatedRoute(AllContributions, 'validator'),
+ '/validators/contributions': roleGatedRoute(Contributions, 'validator', { allowViewOnly: true }),
+ '/validators/all-contributions': roleGatedRoute(AllContributions, 'validator', { allowViewOnly: true }),
'/validators/leaderboard': protectedRoute(Leaderboard),
'/validators/tasks': roleGatedRoute(SocialTasks, 'validator'),
'/validators/participants': protectedRoute(Validators),
- '/validators/wall-of-shame': roleGatedRoute(WallOfShame, 'validator'),
+ '/validators/wall-of-shame': roleGatedRoute(WallOfShame, 'validator', { allowViewOnly: true }),
'/validators/waitlist': protectedRoute(Waitlist),
'/validators/waitlist/participants': protectedRoute(WaitlistParticipants),
'/validators/waitlist/join': ValidatorWaitlist,
diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte
index f7edda06..7ae2530c 100644
--- a/frontend/src/components/Sidebar.svelte
+++ b/frontend/src/components/Sidebar.svelte
@@ -6,7 +6,7 @@
import { userStore } from '../lib/userStore.js';
import { contributionsAPI, stewardAPI } from '../lib/api.js';
import { stewardPermissions } from '../lib/stewardPermissions.js';
- import { hasEarnedRole, journeyPath, rolePath } from '../lib/roleState.js';
+ import { hasRoleSectionAccess, journeyPath, rolePath } from '../lib/roleState.js';
import { getAnalyticsContext, setConnectWalletIntent, trackEvent } from '../lib/analytics.js';
import Avatar from './Avatar.svelte';
@@ -133,7 +133,7 @@
// A signed-in user who has not earned this category's role: the role's
// subsections are shown but locked.
function isRoleLocked(category) {
- return $authState.isAuthenticated && !hasEarnedRole($userStore.user, category);
+ return $authState.isAuthenticated && !hasRoleSectionAccess($userStore.user, category);
}
// Clicking a locked role subsection nudges the user to that role's funnel
diff --git a/frontend/src/components/social-tasks/SocialTaskCard.svelte b/frontend/src/components/social-tasks/SocialTaskCard.svelte
index f4e6a897..cbee4dda 100644
--- a/frontend/src/components/social-tasks/SocialTaskCard.svelte
+++ b/frontend/src/components/social-tasks/SocialTaskCard.svelte
@@ -9,7 +9,7 @@
import { getAnalyticsContext, setConnectWalletIntent, trackEvent } from '../../lib/analytics.js';
import SocialLink from '../SocialLink.svelte';
- let { task, onCompleted = () => {}, pointsLabel = 'pts' } = $props();
+ let { task, onCompleted = () => {}, pointsLabel = 'pts', readOnly = false } = $props();
// ~5 seconds after a click-through user opens the link, we credit them.
const CLICK_THROUGH_DELAY_MS = 5000 + Math.floor(Math.random() * 500);
@@ -27,11 +27,15 @@
let clickThroughTimer = null;
let isCompleted = $derived(task.status === 'completed');
- let isLocked = $derived(!isCompleted && (task.status === 'locked' || task.can_complete === false));
+ let isLocked = $derived(
+ !isCompleted && (readOnly || task.status === 'locked' || task.can_complete === false)
+ );
let requiresVerification = $derived(task.requires_verification === true);
let category = $derived(task.category_slug || 'community');
let colors = $derived(getCategoryPillColors(category));
- let lockedMessage = $derived(task.eligibility?.message || 'Meet this task requirement first.');
+ let lockedMessage = $derived(
+ readOnly ? 'View-only access' : (task.eligibility?.message || 'Meet this task requirement first.')
+ );
let isAuthenticated = $derived($authState.isAuthenticated);
let user = $derived($userStore?.user || null);
diff --git a/frontend/src/components/social-tasks/SocialTasksSection.svelte b/frontend/src/components/social-tasks/SocialTasksSection.svelte
index 6a4fd03a..fe7638e4 100644
--- a/frontend/src/components/social-tasks/SocialTasksSection.svelte
+++ b/frontend/src/components/social-tasks/SocialTasksSection.svelte
@@ -9,7 +9,7 @@
import { getTaskLabels } from '../../lib/socialTaskLabels.js';
import SocialTaskCard from './SocialTaskCard.svelte';
- let { limit = 8 } = $props();
+ let { limit = 8, readOnly = false } = $props();
let tasks = $state([]);
let totalCount = $state(0);
@@ -128,14 +128,16 @@
{/if}
-
+ {#if !readOnly}
+
+ {/if}
@@ -145,7 +147,7 @@
>
{#each tasks as task (task.slug)}
-
+
{/each}
diff --git a/frontend/src/lib/roleState.js b/frontend/src/lib/roleState.js
index 31f5ba2b..eacb9097 100644
--- a/frontend/src/lib/roleState.js
+++ b/frontend/src/lib/roleState.js
@@ -31,6 +31,19 @@ export function hasEarnedRole(user, category) {
return false;
}
+// Read access is deliberately separate from role membership. The backend only
+// returns this flag to the one configured account on /users/me/; keeping it out
+// of hasEarnedRole prevents validator identity, funnels, submissions, and stats
+// from treating that account as a validator.
+/**
+ * @param {Record | null | undefined} user
+ * @param {string} category
+ */
+export function hasRoleSectionAccess(user, category) {
+ if (hasEarnedRole(user, category)) return true;
+ return category === 'validator' && user?.can_view_validator_sections === true;
+}
+
// "Started but not earned", from durable point-free `-welcome` markers set
// when the user clicks "Start the journey" (plus the role's deeper signals for
// back-compat: validator waitlist, community social links).
diff --git a/frontend/src/routes/AllContributions.svelte b/frontend/src/routes/AllContributions.svelte
index e27d43dd..b653faf4 100644
--- a/frontend/src/routes/AllContributions.svelte
+++ b/frontend/src/routes/AllContributions.svelte
@@ -12,6 +12,8 @@
import Pagination from '../components/Pagination.svelte';
import CategoryIcon from '../components/portal/CategoryIcon.svelte';
import { visibleContributions } from '../lib/hiddenContributions.js';
+ import { userStore } from '../lib/userStore.js';
+ import { hasEarnedRole, hasRoleSectionAccess } from '../lib/roleState.js';
const HIGHLIGHTS_PREVIEW_COUNT = 15;
const PAGE_SIZE = 20;
@@ -86,6 +88,11 @@
let baseRoutePath = $derived(buildBasePath($location));
let routeCategory = $derived(detectRouteCategory($location));
+ let isValidatorReadOnlyViewer = $derived(
+ routeCategory === 'validator'
+ && hasRoleSectionAccess($userStore.user, 'validator')
+ && !hasEarnedRole($userStore.user, 'validator')
+ );
let typesForCategory = $derived(
category === 'all' ? allTypes : allTypes.filter(t => t.category === category)
@@ -703,8 +710,10 @@
hasActiveFilters ? 'No highlights match these filters' : 'No highlighted contributions yet',
hasActiveFilters
? 'Try clearing some filters to see highlighted contributions from other categories or types.'
- : 'Submit impactful or pioneering work and a steward may highlight it.',
- hasActiveFilters ? clearFiltersAction : submitContributionAction
+ : (isValidatorReadOnlyViewer
+ ? 'Highlighted validator contributions will appear here when available.'
+ : 'Submit impactful or pioneering work and a steward may highlight it.'),
+ hasActiveFilters ? clearFiltersAction : (isValidatorReadOnlyViewer ? null : submitContributionAction)
)}
{/snippet}
@@ -720,7 +729,9 @@
{hasActiveFilters
? 'No highlights match these filters.'
- : 'No highlighted contributions yet. Submit impactful work and a steward may highlight it.'}
+ : (isValidatorReadOnlyViewer
+ ? 'No highlighted validator contributions yet.'
+ : 'No highlighted contributions yet. Submit impactful work and a steward may highlight it.')}
{/snippet}
@@ -731,15 +742,24 @@
hasActiveFilters ? 'No contributions match these filters' : 'No contributions yet',
hasActiveFilters
? 'Try clearing some filters or searching by a different name or address.'
- : 'Be the first — submit a contribution to get started.',
- hasActiveFilters ? clearFiltersAction : submitContributionAction
+ : (isValidatorReadOnlyViewer
+ ? 'Accepted validator contributions will appear here when available.'
+ : 'Be the first — submit a contribution to get started.'),
+ hasActiveFilters ? clearFiltersAction : (isValidatorReadOnlyViewer ? null : submitContributionAction)
)}
{/snippet}
@@ -998,21 +1018,25 @@
class="text-[17px] text-black leading-[28px]"
style="letter-spacing: 0.34px;"
>
- Submit a contribution and help shape the future of GenLayer. Every action counts toward the network's progress.
+ {isValidatorReadOnlyViewer
+ ? 'Explore the work supporting validator operations, testing, and network reliability.'
+ : "Submit a contribution and help shape the future of GenLayer. Every action counts toward the network's progress."}
-
+
+ Submit a contribution
+
+
+
+ {/if}
diff --git a/frontend/src/routes/Contributions.svelte b/frontend/src/routes/Contributions.svelte
index 802b57d2..7fd571f7 100644
--- a/frontend/src/routes/Contributions.svelte
+++ b/frontend/src/routes/Contributions.svelte
@@ -7,6 +7,8 @@
import { contributionsAPI } from '../lib/api';
import { visibleContributions } from '../lib/hiddenContributions.js';
import { currentCategory } from '../stores/category.js';
+ import { userStore } from '../lib/userStore.js';
+ import { hasEarnedRole, hasRoleSectionAccess } from '../lib/roleState.js';
import { push } from 'svelte-spa-router';
import { getCategoryButtonStyle, getCategoryGradientStyle } from '../lib/categoryPresentation.js';
@@ -64,6 +66,11 @@
let recentSlider = $state(/** @type {HTMLElement | null} */ (null));
let activeCategory = $derived($currentCategory || 'global');
+ let isValidatorReadOnlyViewer = $derived(
+ activeCategory === 'validator'
+ && hasRoleSectionAccess($userStore.user, 'validator')
+ && !hasEarnedRole($userStore.user, 'validator')
+ );
let pageConfig = $derived(categoryConfig[activeCategory] || categoryConfig.global);
let gradientStyle = $derived(getCategoryGradientStyle(activeCategory, pageConfig.accentColor));
let pageGradientStyle = $derived(
@@ -188,22 +195,28 @@
-
+ {#if isValidatorReadOnlyViewer}
+
+ View-only access
+
+ {:else}
+
+ {/if}
{#if activeCategory === 'builder'}
{/if}
-
+
diff --git a/frontend/src/tests/roleState.test.js b/frontend/src/tests/roleState.test.js
index 3fa7ab0f..8e3bd131 100644
--- a/frontend/src/tests/roleState.test.js
+++ b/frontend/src/tests/roleState.test.js
@@ -5,6 +5,7 @@ import {
journeyPath,
roleForCategory,
hasAnyRoleOrJourney,
+ hasRoleSectionAccess,
} from '../lib/roleState.js';
describe('roleState.roleFunnelState', () => {
@@ -64,6 +65,28 @@ describe('roleState path helpers', () => {
});
});
+describe('roleState.hasRoleSectionAccess', () => {
+ it('allows users who actually hold the requested role', () => {
+ expect(hasRoleSectionAccess({ validator: {} }, 'validator')).toBe(true);
+ expect(hasRoleSectionAccess({ builder: {} }, 'builder')).toBe(true);
+ });
+
+ it('allows the configured view-only exception for validators only', () => {
+ const viewer = { can_view_validator_sections: true };
+
+ expect(hasRoleSectionAccess(viewer, 'validator')).toBe(true);
+ expect(hasRoleSectionAccess(viewer, 'builder')).toBe(false);
+ expect(hasRoleSectionAccess(viewer, 'community')).toBe(false);
+ });
+
+ it('does not turn validator viewing access into an earned role', () => {
+ const viewer = { can_view_validator_sections: true };
+
+ expect(roleFunnelState(true, viewer, 'validator')).toBe('none');
+ expect(hasAnyRoleOrJourney(viewer)).toBe(false);
+ });
+});
+
describe('hasAnyRoleOrJourney (gates first-run UI away from new accounts)', () => {
it('is false for a brand-new / not-yet-engaged account', () => {
expect(hasAnyRoleOrJourney(null)).toBe(false);
From ffbd3be0a736e287efa21f1cde03fbb0b11fdf30 Mon Sep 17 00:00:00 2001
From: JoaquinBN
Date: Wed, 22 Jul 2026 15:34:40 -0400
Subject: [PATCH 2/5] Test anonymous validator task access
---
backend/social_tasks/tests/test_views.py | 37 ++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/backend/social_tasks/tests/test_views.py b/backend/social_tasks/tests/test_views.py
index d41bea32..6cb58e3d 100644
--- a/backend/social_tasks/tests/test_views.py
+++ b/backend/social_tasks/tests/test_views.py
@@ -428,6 +428,43 @@ def test_validator_task_is_locked_for_view_only_non_validator(self):
SocialTaskCompletion.objects.filter(user=self.user, task=task).exists()
)
+ def test_validator_task_is_locked_for_anonymous_user(self):
+ validator_category, _ = Category.objects.get_or_create(
+ slug='validator', defaults={'name': 'Validator'}
+ )
+ task = SocialTask.objects.create(
+ slug='anonymous-validator-task',
+ name='Anonymous Validator Task',
+ category=validator_category,
+ points=25,
+ verification_type='click_through',
+ action_url='https://example.com/anonymous-validator',
+ )
+ anonymous_client = APIClient()
+
+ list_response = anonymous_client.get(
+ '/api/v1/social-tasks/?category=validator'
+ )
+ complete_response = anonymous_client.post(
+ f'/api/v1/social-tasks/{task.slug}/complete/'
+ )
+
+ self.assertEqual(list_response.status_code, 200)
+ listed_task = list_response.json()[0]
+ self.assertEqual(listed_task['status'], 'locked')
+ self.assertFalse(listed_task['can_complete'])
+ self.assertEqual(
+ listed_task['eligibility']['message'],
+ 'Sign in with a validator account to complete this task.',
+ )
+ self.assertEqual(listed_task['eligibility']['required_role'], 'validator')
+ self.assertEqual(complete_response.status_code, 403)
+ self.assertEqual(
+ complete_response.json()['detail'],
+ 'Authentication credentials were not provided.',
+ )
+ self.assertFalse(SocialTaskCompletion.objects.filter(task=task).exists())
+
def test_validator_can_complete_validator_task(self):
validator_category, _ = Category.objects.get_or_create(
slug='validator', defaults={'name': 'Validator'}
From 33ca0599d0fb81887cbbcd076eb8c2dc079ef45c Mon Sep 17 00:00:00 2001
From: JoaquinBN
Date: Wed, 22 Jul 2026 16:31:16 -0400
Subject: [PATCH 3/5] Add admin-managed role view access
---
backend/.env.example | 3 -
backend/CLAUDE.md | 2 +-
.../tests/test_validator_category_gating.py | 13 +++
backend/poaps/tests/test_poaps.py | 48 +++++++++++
backend/poaps/views.py | 21 +++++
backend/social_tasks/eligibility.py | 37 +++++++--
backend/social_tasks/tests/test_views.py | 61 ++++++++++++--
backend/tally/settings.py | 22 -----
backend/users/admin.py | 11 ++-
.../0021_user_can_view_role_sections.py | 23 ++++++
backend/users/models.py | 8 ++
backend/users/role_access.py | 53 ++++++++++++
backend/users/serializers.py | 26 +++---
.../users/tests/test_role_section_viewer.py | 81 +++++++++++++++++++
.../tests/test_validator_section_viewer.py | 57 -------------
frontend/CLAUDE.md | 2 +-
frontend/src/App.svelte | 61 +++++---------
frontend/src/components/Missions.svelte | 24 ++++--
.../social-tasks/SocialTasksSection.svelte | 18 ++---
frontend/src/lib/roleState.js | 23 ++++--
frontend/src/routes/AllContributions.svelte | 32 ++++----
frontend/src/routes/CommunityPoaps.svelte | 33 +++++---
.../src/routes/ContributionTypeDetail.svelte | 11 ++-
frontend/src/routes/Contributions.svelte | 14 ++--
frontend/src/routes/MissionDetail.svelte | 35 +++++---
frontend/src/routes/PoapDetail.svelte | 11 ++-
frontend/src/routes/PoapRecovery.svelte | 13 +++
frontend/src/routes/SocialTasks.svelte | 12 ++-
frontend/src/tests/roleState.test.js | 26 ++++--
29 files changed, 550 insertions(+), 231 deletions(-)
create mode 100644 backend/users/migrations/0021_user_can_view_role_sections.py
create mode 100644 backend/users/role_access.py
create mode 100644 backend/users/tests/test_role_section_viewer.py
delete mode 100644 backend/users/tests/test_validator_section_viewer.py
diff --git a/backend/.env.example b/backend/.env.example
index b15fd8f3..72d83c46 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -34,9 +34,6 @@ SIWE_DOMAIN=localhost:5173
# Blockchain Settings - Shared RPC (both networks on same chain)
VALIDATOR_RPC_URL=https://rpc.testnet-chain.genlayer.com
-# Optional singular user ID allowed to view validator portal sections without
-# receiving the Validator role. Leave empty to disable the exception.
-VALIDATOR_SECTION_VIEWER_USER_ID=
# Optional Web3 HTTP bounds (defaults shown; retries are after the initial request)
WEB3_RPC_TIMEOUT_SECONDS=10
WEB3_RPC_MAX_RETRIES=1
diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index 2ce6125a..6e1b81c8 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -538,7 +538,6 @@ Located in `.env` file:
- `WEB3_RPC_TIMEOUT_SECONDS` - Optional timeout in seconds for validator Web3 HTTP requests (default `10`)
- `WEB3_RPC_MAX_RETRIES` - Optional number of retries after the initial validator Web3 HTTP request (default `1`)
- `VALIDATOR_CONTRACT_ADDRESS` - Smart contract address
-- `VALIDATOR_SECTION_VIEWER_USER_ID` - Optional singular user ID that receives read-only access to validator portal sections without a Validator profile, validator metrics membership, or validator task eligibility. Empty disables the exception; malformed/non-positive values fail startup.
- `SECRET_KEY` - Django secret key
- `DEBUG` - Debug mode flag
- `ALLOWED_HOSTS` - Allowed host headers
@@ -656,3 +655,4 @@ The project uses **context-aware serialization** to optimize API performance:
- URL: `/admin/`
- Requires superuser account
- Models registered in `{app}/admin.py`
+- User records include a `can_view_role_sections` checkbox for read-only access to gated Builder, Validator, and Community portal views. It does not create role profiles, enable role actions, affect role metrics, or grant Steward access.
diff --git a/backend/contributions/tests/test_validator_category_gating.py b/backend/contributions/tests/test_validator_category_gating.py
index 55c20024..2f607efb 100644
--- a/backend/contributions/tests/test_validator_category_gating.py
+++ b/backend/contributions/tests/test_validator_category_gating.py
@@ -185,6 +185,19 @@ def test_user_without_creator_profile_is_blocked_from_community_category(self):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertIn('Only creators', response.data['error'])
+ def test_read_only_role_access_does_not_grant_submission_permissions(self):
+ self.plain_user.can_view_role_sections = True
+ self.plain_user.save(update_fields=['can_view_role_sections', 'updated_at'])
+
+ for contribution_type in (
+ self.builder_type,
+ self.node_running_type,
+ self.community_type,
+ ):
+ with self.subTest(category=contribution_type.category.slug):
+ response = self._post_submission(self.plain_user, contribution_type)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
def test_user_with_creator_profile_passes_community_gating(self):
response = self._post_submission(self.creator_user, self.community_type)
# May be 201 or 400 depending on recaptcha config, but must not be 403
diff --git a/backend/poaps/tests/test_poaps.py b/backend/poaps/tests/test_poaps.py
index abf709ca..d97df408 100644
--- a/backend/poaps/tests/test_poaps.py
+++ b/backend/poaps/tests/test_poaps.py
@@ -18,6 +18,7 @@
from rest_framework import status
from rest_framework.test import APIClient
+from creators.models import Creator
from ethereum_auth.models import Nonce
from poaps.admin import PoapDistributionAdminForm, PoapDropAdmin, PoapDropAdminForm
from poaps.models import PoapClaim, PoapDistribution, PoapDrop, PoapImportBatch
@@ -145,6 +146,39 @@ def test_secret_claim_success(self):
distribution.refresh_from_db()
self.assertEqual(distribution.claimed_count, 1)
+ def test_read_only_community_viewer_cannot_claim_secret(self):
+ distribution = self._secret_distribution()
+ self.user.can_view_role_sections = True
+ self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
+ self.client.force_authenticate(user=self.user)
+
+ response = self.client.post(
+ '/api/v1/poaps/ama-session/claim-secret/',
+ {'secret': 'friend-scientist-natural'},
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(response.data['code'], 'role_view_only')
+ self.assertFalse(PoapClaim.objects.filter(drop=self.drop, user=self.user).exists())
+ distribution.refresh_from_db()
+ self.assertEqual(distribution.claimed_count, 0)
+
+ def test_real_creator_with_view_flag_can_still_claim_secret(self):
+ self._secret_distribution()
+ self.user.can_view_role_sections = True
+ self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
+ Creator.objects.create(user=self.user)
+ self.client.force_authenticate(user=self.user)
+
+ response = self.client.post(
+ '/api/v1/poaps/ama-session/claim-secret/',
+ {'secret': 'friend-scientist-natural'},
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
def test_secret_claim_requires_discord_connection(self):
distribution = self._secret_distribution()
DiscordConnection.objects.filter(user=self.user).delete()
@@ -585,6 +619,20 @@ def test_verify_wallet_attaches_unmatched_legacy_claims_without_changing_session
self.assertEqual(claim.user, self.user)
self.assertEqual(self.client.session['ethereum_address'], self.user.address)
+ def test_read_only_community_viewer_cannot_recover_poaps(self):
+ self.user.can_view_role_sections = True
+ self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
+ self.client.force_authenticate(user=self.user)
+
+ response = self.client.post(
+ '/api/v1/poaps/verify-wallet/',
+ {},
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(response.data['code'], 'role_view_only')
+
def test_verify_wallet_rejects_invalid_signature(self):
account = Account.create()
signer = Account.create()
diff --git a/backend/poaps/views.py b/backend/poaps/views.py
index dd342ebf..bf49fcee 100644
--- a/backend/poaps/views.py
+++ b/backend/poaps/views.py
@@ -13,6 +13,7 @@
from ethereum_auth.models import Nonce
from ethereum_auth.siwe_utils import get_expected_siwe_domain, get_expected_siwe_uri, normalize_origin
+from users.role_access import is_role_section_read_only
from .models import PoapClaim, PoapDistribution, PoapDrop
from .serializers import (
@@ -93,6 +94,18 @@ class PoapDropViewSet(viewsets.ReadOnlyModelViewSet):
ordering_fields = ['event_start_at', 'created_at', 'title']
ordering = ['-event_start_at']
+ def _read_only_community_response(self, request):
+ """Reject mutations exposed by gated Community views for viewers."""
+ if not is_role_section_read_only(request.user, 'community'):
+ return None
+ return Response(
+ {
+ 'error': 'View-only access does not allow POAP claims or recovery.',
+ 'code': 'role_view_only',
+ },
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
def get_throttles(self):
self.throttle_scope = (
'poap_claim_secret'
@@ -197,6 +210,10 @@ def claims(self, request, slug=None):
permission_classes=[permissions.IsAuthenticated],
)
def claim_secret(self, request, slug=None):
+ read_only_response = self._read_only_community_response(request)
+ if read_only_response is not None:
+ return read_only_response
+
try:
claim = claim_with_secret(
drop_slug=slug,
@@ -239,6 +256,10 @@ def claim_link_legacy(self, request, token=None):
@action(detail=False, methods=['post'], url_path='verify-wallet', permission_classes=[permissions.IsAuthenticated])
def verify_wallet(self, request):
+ read_only_response = self._read_only_community_response(request)
+ if read_only_response is not None:
+ return read_only_response
+
if not request.user.address:
return Response(
{'error': 'Your portal account does not have a wallet address.'},
diff --git a/backend/social_tasks/eligibility.py b/backend/social_tasks/eligibility.py
index f55ab7c9..37a236ec 100644
--- a/backend/social_tasks/eligibility.py
+++ b/backend/social_tasks/eligibility.py
@@ -2,6 +2,12 @@
from django.core.exceptions import ValidationError
+from users.role_access import (
+ VIEWABLE_ROLE_CATEGORIES,
+ is_role_section_read_only,
+ user_has_role_profile,
+)
+
SUPPORTED_RULE_TYPES = {
'accepted_submittable_contribution',
@@ -33,9 +39,31 @@ def validate_eligibility_requirements(value):
def evaluate_task_eligibility(task, user):
- # Validator tasks can award validator leaderboard points. Portal viewing
- # exceptions must never become an alternate path to earning those points.
- if getattr(getattr(task, 'category', None), 'slug', None) == 'validator':
+ """Evaluate task rules without allowing read-only access to award points."""
+ category_slug = getattr(getattr(task, 'category', None), 'slug', None)
+
+ # Journey tasks remain available to ordinary pre-role users. The explicit
+ # admin viewer flag is different: while enabled, it must never become an
+ # alternate path to completing tasks in a role the user does not hold.
+ if (
+ category_slug in VIEWABLE_ROLE_CATEGORIES
+ and user is not None
+ and getattr(user, 'is_authenticated', False)
+ and is_role_section_read_only(user, category_slug)
+ ):
+ return EligibilityResult(
+ False,
+ 'View-only access does not allow task completion.',
+ details={
+ 'requirements': [],
+ 'required_role': category_slug,
+ 'read_only': True,
+ },
+ )
+
+ # Validator tasks are never part of a pre-role journey and can award
+ # validator leaderboard points, so every non-validator remains ineligible.
+ if category_slug == 'validator':
if user is None or not getattr(user, 'is_authenticated', False):
return EligibilityResult(
False,
@@ -43,8 +71,7 @@ def evaluate_task_eligibility(task, user):
details={'requirements': [], 'required_role': 'validator'},
)
- from validators.models import user_has_validator_profile
- if not user_has_validator_profile(user):
+ if not user_has_role_profile(user, 'validator'):
return EligibilityResult(
False,
'Only validators can complete validator tasks.',
diff --git a/backend/social_tasks/tests/test_views.py b/backend/social_tasks/tests/test_views.py
index 6cb58e3d..fbc07762 100644
--- a/backend/social_tasks/tests/test_views.py
+++ b/backend/social_tasks/tests/test_views.py
@@ -411,23 +411,72 @@ def test_validator_task_is_locked_for_view_only_non_validator(self):
action_url='https://example.com/validator',
)
- with override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=self.user.id):
- list_response = self.client.get('/api/v1/social-tasks/?category=validator')
- complete_response = self.client.post(
- f'/api/v1/social-tasks/{task.slug}/complete/'
- )
+ self.user.can_view_role_sections = True
+ self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
+
+ list_response = self.client.get('/api/v1/social-tasks/?category=validator')
+ complete_response = self.client.post(
+ f'/api/v1/social-tasks/{task.slug}/complete/'
+ )
self.assertEqual(list_response.status_code, 200)
listed_task = list_response.json()[0]
self.assertEqual(listed_task['status'], 'locked')
self.assertFalse(listed_task['can_complete'])
self.assertEqual(listed_task['eligibility']['required_role'], 'validator')
+ self.assertTrue(listed_task['eligibility']['read_only'])
self.assertEqual(complete_response.status_code, 403)
self.assertEqual(complete_response.json()['error'], 'eligibility_failed')
self.assertFalse(
SocialTaskCompletion.objects.filter(user=self.user, task=task).exists()
)
+ def test_view_only_user_cannot_complete_builder_or_community_tasks(self):
+ self.user.can_view_role_sections = True
+ self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
+
+ for category_slug in ('builder', 'community'):
+ with self.subTest(category=category_slug):
+ category, _ = Category.objects.get_or_create(
+ slug=category_slug,
+ defaults={'name': category_slug.title()},
+ )
+ task = SocialTask.objects.create(
+ slug=f'view-only-{category_slug}-task',
+ name=f'View-only {category_slug} task',
+ category=category,
+ points=25,
+ verification_type='click_through',
+ action_url=f'https://example.com/{category_slug}',
+ )
+
+ list_response = self.client.get(
+ f'/api/v1/social-tasks/?category={category_slug}'
+ )
+ complete_response = self.client.post(
+ f'/api/v1/social-tasks/{task.slug}/complete/'
+ )
+
+ listed_task = next(
+ item for item in list_response.json() if item['slug'] == task.slug
+ )
+ self.assertEqual(listed_task['status'], 'locked')
+ self.assertFalse(listed_task['can_complete'])
+ self.assertEqual(
+ listed_task['eligibility']['required_role'], category_slug
+ )
+ self.assertTrue(listed_task['eligibility']['read_only'])
+ self.assertEqual(complete_response.status_code, 403)
+ self.assertEqual(
+ complete_response.json()['error'], 'eligibility_failed'
+ )
+ self.assertFalse(
+ SocialTaskCompletion.objects.filter(
+ user=self.user,
+ task=task,
+ ).exists()
+ )
+
def test_validator_task_is_locked_for_anonymous_user(self):
validator_category, _ = Category.objects.get_or_create(
slug='validator', defaults={'name': 'Validator'}
@@ -477,6 +526,8 @@ def test_validator_can_complete_validator_task(self):
verification_type='click_through',
action_url='https://example.com/validator-role',
)
+ self.user.can_view_role_sections = True
+ self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
Validator.objects.create(user=self.user)
response = self.client.post(f'/api/v1/social-tasks/{task.slug}/complete/')
diff --git a/backend/tally/settings.py b/backend/tally/settings.py
index 77b861c9..a2e0839c 100644
--- a/backend/tally/settings.py
+++ b/backend/tally/settings.py
@@ -29,22 +29,6 @@ def get_required_env(key):
return value
-def get_optional_positive_int_env(key):
- """Return an optional positive integer setting, failing fast if malformed."""
- raw_value = os.environ.get(key, '').strip()
- if not raw_value:
- return None
-
- try:
- value = int(raw_value)
- except ValueError as exc:
- raise ValueError(f"Optional environment variable '{key}' must be a positive integer") from exc
-
- if value <= 0:
- raise ValueError(f"Optional environment variable '{key}' must be a positive integer")
- return value
-
-
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
@@ -445,12 +429,6 @@ def get_port_from_argv():
# Custom user model
AUTH_USER_MODEL = 'users.User'
-# Singular, read-only portal exception. This does not grant the Validator role;
-# it only lets the configured account open validator read surfaces in the SPA.
-VALIDATOR_SECTION_VIEWER_USER_ID = get_optional_positive_int_env(
- 'VALIDATOR_SECTION_VIEWER_USER_ID'
-)
-
# Blockchain settings
# Shared RPC URL for all networks
VALIDATOR_RPC_URL = get_required_env('VALIDATOR_RPC_URL')
diff --git a/backend/users/admin.py b/backend/users/admin.py
index c86a8357..85eb47e2 100644
--- a/backend/users/admin.py
+++ b/backend/users/admin.py
@@ -59,8 +59,8 @@ class UserAdmin(CloudinaryUploadMixin, BaseUserAdmin):
},
}
- list_display = ('email', 'name', 'is_staff', 'is_active', 'visible', 'is_banned', 'address', 'is_email_verified', 'email_verified_at')
- list_filter = ('is_staff', 'is_active', 'visible', 'is_banned', 'is_email_verified')
+ list_display = ('email', 'name', 'is_staff', 'is_active', 'visible', 'can_view_role_sections', 'is_banned', 'address', 'is_email_verified', 'email_verified_at')
+ list_filter = ('is_staff', 'is_active', 'visible', 'can_view_role_sections', 'is_banned', 'is_email_verified')
search_fields = ('email', 'name', 'address', 'referral_code', 'twitter_handle', 'discord_handle', 'telegram_handle')
ordering = ('email',)
@@ -71,6 +71,13 @@ class UserAdmin(CloudinaryUploadMixin, BaseUserAdmin):
(_('Contact & Social'), {'fields': ('website', 'twitter_handle', 'discord_handle', 'telegram_handle', 'linkedin_handle')}),
(_('Referral System'), {'fields': ('referral_code', 'referred_by')}),
(_('Visibility'), {'fields': ('visible',)}),
+ (_('Read-only role access'), {
+ 'fields': ('can_view_role_sections',),
+ 'description': (
+ 'Allows this account to view gated Builder, Validator, and Community '
+ 'sections without granting those roles or any Steward access.'
+ ),
+ }),
(_('Ban Status'), {'fields': ('is_banned', 'ban_reason', 'banned_at', 'banned_by')}),
(_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
(_('Important dates'), {'fields': ('last_login', 'date_joined', 'created_at', 'updated_at')}),
diff --git a/backend/users/migrations/0021_user_can_view_role_sections.py b/backend/users/migrations/0021_user_can_view_role_sections.py
new file mode 100644
index 00000000..d2cbfac4
--- /dev/null
+++ b/backend/users/migrations/0021_user_can_view_role_sections.py
@@ -0,0 +1,23 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0020_unique_ban_appeal_per_user'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='can_view_role_sections',
+ field=models.BooleanField(
+ default=False,
+ help_text=(
+ 'Allow read-only access to gated Builder, Validator, and Community '
+ 'portal sections. This does not grant a role, interaction permissions, '
+ 'or access to Steward tools.'
+ ),
+ ),
+ ),
+ ]
diff --git a/backend/users/models.py b/backend/users/models.py
index 42b39df0..81238a24 100644
--- a/backend/users/models.py
+++ b/backend/users/models.py
@@ -52,6 +52,14 @@ class User(AbstractUser, BaseModel):
address = models.CharField(max_length=42, blank=True, null=True,
help_text="Ethereum wallet address associated with this user")
visible = models.BooleanField(default=True, help_text="Whether this user should be visible in API responses.")
+ can_view_role_sections = models.BooleanField(
+ default=False,
+ help_text=(
+ "Allow read-only access to gated Builder, Validator, and Community "
+ "portal sections. This does not grant a role, interaction permissions, "
+ "or access to Steward tools."
+ ),
+ )
# Profile fields
description = models.TextField(max_length=500, blank=True,
diff --git a/backend/users/role_access.py b/backend/users/role_access.py
new file mode 100644
index 00000000..c5564013
--- /dev/null
+++ b/backend/users/role_access.py
@@ -0,0 +1,53 @@
+"""Server-authoritative helpers for non-steward role section access."""
+
+from django.apps import apps
+
+
+VIEWABLE_ROLE_CATEGORIES = frozenset({'builder', 'validator', 'community'})
+_ROLE_PROFILE_MODELS = {
+ 'builder': ('builders', 'Builder'),
+ 'validator': ('validators', 'Validator'),
+ 'community': ('creators', 'Creator'),
+}
+
+
+def user_has_role_profile(user, category):
+ """Return whether the user holds the real profile for a portal role."""
+ user_id = getattr(user, 'pk', None)
+ if not user_id or category not in VIEWABLE_ROLE_CATEGORIES:
+ return False
+
+ cache = getattr(user, '_role_profile_access_cache', None)
+ if cache is None:
+ cache = {}
+ setattr(user, '_role_profile_access_cache', cache)
+ if category not in cache:
+ app_label, model_name = _ROLE_PROFILE_MODELS[category]
+ model = apps.get_model(app_label, model_name)
+ cache[category] = model.objects.filter(user_id=user_id).exists()
+ return cache[category]
+
+
+def user_can_view_role_sections(user):
+ """Return whether admin enabled the non-steward read-only viewer flag."""
+ return bool(
+ user
+ and getattr(user, 'is_authenticated', False)
+ and getattr(user, 'can_view_role_sections', False)
+ )
+
+
+def can_view_role_section(user, category):
+ """Authorize a gated non-steward section for a member or admin-enabled viewer."""
+ if category not in VIEWABLE_ROLE_CATEGORIES:
+ return False
+ return user_has_role_profile(user, category) or user_can_view_role_sections(user)
+
+
+def is_role_section_read_only(user, category):
+ """Return whether access to this category comes only from the viewer flag."""
+ return bool(
+ category in VIEWABLE_ROLE_CATEGORIES
+ and user_can_view_role_sections(user)
+ and not user_has_role_profile(user, category)
+ )
diff --git a/backend/users/serializers.py b/backend/users/serializers.py
index e1ff22bf..385371f0 100644
--- a/backend/users/serializers.py
+++ b/backend/users/serializers.py
@@ -1,4 +1,3 @@
-from django.conf import settings
from rest_framework import serializers
from .models import BanAppeal, User
from validators.models import Validator, ValidatorWallet, get_validator_profile
@@ -331,6 +330,10 @@ class Meta:
def to_internal_value(self, data):
if 'email' in data:
raise serializers.ValidationError({'email': 'Use email verification to change email.'})
+ if 'can_view_role_sections' in data:
+ raise serializers.ValidationError({
+ 'can_view_role_sections': 'Only an administrator can change role view access.'
+ })
return super().to_internal_value(data)
def validate_description(self, value):
@@ -531,7 +534,7 @@ class UserSerializer(serializers.ModelSerializer):
email_verified_at = serializers.SerializerMethodField()
is_banned = serializers.SerializerMethodField()
ban_reason = serializers.SerializerMethodField()
- can_view_validator_sections = serializers.SerializerMethodField()
+ can_view_role_sections = serializers.SerializerMethodField()
class Meta:
model = User
@@ -547,8 +550,8 @@ class Meta:
'email', 'is_email_verified', 'email_verified_at',
# Ban status
'is_banned', 'ban_reason',
- # Singular read-only validator portal exception
- 'can_view_validator_sections',
+ # Admin-managed read-only access to non-steward role sections
+ 'can_view_role_sections',
# Social connections
'github_connection', 'twitter_connection', 'discord_connection',
# Referral fields
@@ -711,20 +714,15 @@ def get_ban_reason(self, obj):
return obj.ban_reason
return ''
- def get_can_view_validator_sections(self, obj):
- """Expose the singular validator viewer exception to its owner only.
-
- The configured account remains a normal user: this flag is navigation
- access, not role membership, and never creates a Validator profile.
- """
- configured_user_id = getattr(settings, 'VALIDATOR_SECTION_VIEWER_USER_ID', None)
+ def get_can_view_role_sections(self, obj):
+ """Expose the admin-managed viewer flag to its owner only."""
request = self.context.get('request')
request_user = getattr(request, 'user', None)
return bool(
- configured_user_id
- and request_user
+ request_user
and request_user.is_authenticated
- and request_user.pk == obj.pk == configured_user_id
+ and request_user.pk == obj.pk
+ and obj.can_view_role_sections
)
def get_referral_code(self, obj):
diff --git a/backend/users/tests/test_role_section_viewer.py b/backend/users/tests/test_role_section_viewer.py
new file mode 100644
index 00000000..63ed5855
--- /dev/null
+++ b/backend/users/tests/test_role_section_viewer.py
@@ -0,0 +1,81 @@
+from rest_framework.test import APITestCase
+
+from builders.models import Builder
+from creators.models import Creator
+from users.models import User
+from users.role_access import can_view_role_section, is_role_section_read_only
+from validators.models import Validator
+
+
+class RoleSectionViewerTests(APITestCase):
+ def setUp(self):
+ self.viewer = User.objects.create_user(
+ email='role-section-viewer@example.com',
+ password='testpass123',
+ visible=True,
+ can_view_role_sections=True,
+ )
+ self.other_user = User.objects.create_user(
+ email='other-viewer@example.com',
+ password='testpass123',
+ visible=True,
+ )
+
+ def test_admin_enabled_owner_receives_flag_without_any_role(self):
+ self.client.force_authenticate(user=self.viewer)
+
+ response = self.client.get('/api/v1/users/me/')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(response.data['can_view_role_sections'])
+ self.assertIsNone(response.data['validator'])
+ self.assertIsNone(response.data['builder'])
+ self.assertIsNone(response.data['creator'])
+ self.assertFalse(Validator.objects.filter(user=self.viewer).exists())
+ self.assertFalse(Builder.objects.filter(user=self.viewer).exists())
+ self.assertFalse(Creator.objects.filter(user=self.viewer).exists())
+
+ def test_flag_allows_only_non_steward_role_sections(self):
+ for category in ('builder', 'validator', 'community'):
+ with self.subTest(category=category):
+ self.assertTrue(can_view_role_section(self.viewer, category))
+
+ self.assertFalse(can_view_role_section(self.viewer, 'steward'))
+ self.assertFalse(can_view_role_section(self.viewer, 'global'))
+
+ def test_real_role_overrides_view_only_status_for_its_category(self):
+ Builder.objects.create(user=self.viewer)
+
+ self.assertFalse(is_role_section_read_only(self.viewer, 'builder'))
+ self.assertTrue(is_role_section_read_only(self.viewer, 'validator'))
+ self.assertTrue(is_role_section_read_only(self.viewer, 'community'))
+
+ def test_unconfigured_user_does_not_receive_view_flag(self):
+ self.client.force_authenticate(user=self.other_user)
+
+ response = self.client.get('/api/v1/users/me/')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertFalse(response.data['can_view_role_sections'])
+
+ def test_view_flag_is_not_exposed_on_public_profile(self):
+ self.client.force_authenticate(user=self.other_user)
+
+ response = self.client.get(f'/api/v1/users/by-address/{self.viewer.id}/')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertFalse(response.data['can_view_role_sections'])
+
+ def test_profile_api_cannot_enable_view_access(self):
+ self.client.force_authenticate(user=self.other_user)
+
+ response = self.client.patch(
+ '/api/v1/users/me/',
+ {'can_view_role_sections': True},
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, 400)
+ self.assertIn('can_view_role_sections', response.data)
+ self.other_user.refresh_from_db()
+ self.assertFalse(self.other_user.can_view_role_sections)
diff --git a/backend/users/tests/test_validator_section_viewer.py b/backend/users/tests/test_validator_section_viewer.py
deleted file mode 100644
index b240aec3..00000000
--- a/backend/users/tests/test_validator_section_viewer.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from django.test import override_settings
-from rest_framework.test import APITestCase
-
-from users.models import User
-from validators.models import Validator
-
-
-class ValidatorSectionViewerTests(APITestCase):
- def setUp(self):
- self.viewer = User.objects.create_user(
- email='validator-section-viewer@example.com',
- password='testpass123',
- visible=True,
- )
- self.other_user = User.objects.create_user(
- email='other-viewer@example.com',
- password='testpass123',
- visible=True,
- )
-
- def test_configured_owner_receives_view_flag_without_validator_role(self):
- self.client.force_authenticate(user=self.viewer)
-
- with override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=self.viewer.id):
- response = self.client.get('/api/v1/users/me/')
-
- self.assertEqual(response.status_code, 200)
- self.assertTrue(response.data['can_view_validator_sections'])
- self.assertIsNone(response.data['validator'])
- self.assertFalse(Validator.objects.filter(user=self.viewer).exists())
-
- def test_unconfigured_user_does_not_receive_view_flag(self):
- self.client.force_authenticate(user=self.other_user)
-
- with override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=self.viewer.id):
- response = self.client.get('/api/v1/users/me/')
-
- self.assertEqual(response.status_code, 200)
- self.assertFalse(response.data['can_view_validator_sections'])
-
- def test_view_flag_is_not_exposed_on_the_configured_users_public_profile(self):
- self.client.force_authenticate(user=self.other_user)
-
- with override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=self.viewer.id):
- response = self.client.get(f'/api/v1/users/by-address/{self.viewer.id}/')
-
- self.assertEqual(response.status_code, 200)
- self.assertFalse(response.data['can_view_validator_sections'])
-
- @override_settings(VALIDATOR_SECTION_VIEWER_USER_ID=None)
- def test_exception_is_disabled_when_setting_is_empty(self):
- self.client.force_authenticate(user=self.viewer)
-
- response = self.client.get('/api/v1/users/me/')
-
- self.assertEqual(response.status_code, 200)
- self.assertFalse(response.data['can_view_validator_sections'])
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 217cfe8c..ed62bd63 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -299,7 +299,7 @@ frontend/src/
- Shared utils: `src/lib/notificationUtils.js` (`asList` payload normalization, `followNotificationLink` link handling) and `src/lib/relativeTime.js` for compact timestamps
- **Sidebar**: `src/components/Sidebar.svelte`
- Side navigation with collapsible sections
- - Validator read-only exception: `/users/me/` may return `can_view_validator_sections=true` for the single backend-configured account. This unlocks validator Contributions and Wall of Shame navigation without changing `hasEarnedRole`; Tasks and point-bearing actions remain Validator-role-only.
+ - Admin-managed role viewer: `/users/me/` may return `can_view_role_sections=true`. This unlocks gated Builder, Validator, and Community views without changing `hasEarnedRole`; Steward tools are excluded and point-bearing actions remain real-role-only.
- Navigation structure:
- **Overview** (links to `/`) - Contains Testnet Asimov and Metrics sub-items
- **Builders** - Category-specific dashboard and pages
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 03a18224..5ba99063 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -14,7 +14,7 @@
import { authState, verifyAuth } from './lib/auth.js';
import { userStore } from './lib/userStore.js';
import { normalizeReferralCode } from './lib/referrals.js';
- import { hasEarnedRole, hasRoleSectionAccess, journeyPath, rolePath } from './lib/roleState.js';
+ import { hasRoleSectionAccess, journeyPath, rolePath } from './lib/roleState.js';
import { installLinkInterceptor } from './lib/router.js';
import { getAnalyticsContext, initializeAnalytics, setConnectWalletIntent, templateRoute, trackEvent, trackPageView } from './lib/analytics.js';
@@ -175,48 +175,28 @@
conditions: [requireAuthForRoute],
});
- // Role-gated subsection: must be authenticated AND hold the role, else bounce
- // to the role's main route (the funnel) so the user can start there. Selected
- // read-only validator routes may also admit the singular backend-configured
- // viewer; point-bearing routes still require actual role membership.
+ // Role-gated subsection: actual members and admin-enabled read-only viewers
+ // may enter Builder, Validator, and Community views. Steward routes use their
+ // existing, separate authorization and are never included here.
/**
- * @param {Record | null | undefined} user
* @param {string} category
- * @param {boolean} allowViewOnly
*/
- function hasSubsectionAccess(user, category, allowViewOnly) {
- return allowViewOnly
- ? hasRoleSectionAccess(user, category)
- : hasEarnedRole(user, category);
- }
-
- /**
- * @param {string} category
- * @param {{ allowViewOnly?: boolean }} options
- */
- function requireRoleForRoute(category, { allowViewOnly = false } = {}) {
+ function requireRoleForRoute(category) {
return async (detail) => {
const authed = await requireAuthForRoute(detail);
if (!authed) return false;
- let user = userStore.getUser();
- if (!user) {
- try {
- user = await userStore.loadUser();
- } catch (err) {
- const status = err.response?.status;
- if (!(status === 401 || status === 403)) {
- // Membership couldn't be verified (backend down/overloaded), which
- // is not the same as "no role". Fail open: the backend enforces
- // real permissions on every API call, so the worst case is an
- // error state inside the page — far better than bouncing a member
- // back to the start of their journey.
- return true;
- }
- user = null;
- }
+ // Re-read /users/me/ on every gated navigation so an admin toggle or
+ // revocation takes effect without trusting stale client state.
+ let user = null;
+ try {
+ user = await userStore.loadUser();
+ } catch {
+ // Permission could not be verified. Fail closed rather than rendering
+ // a gated route from stale or missing client state.
+ user = null;
}
- if (hasSubsectionAccess(user, category, allowViewOnly)) return true;
+ if (hasRoleSectionAccess(user, category)) return true;
// Stale-navigation guard: only redirect if still on the guarded route.
const normalizePath = (value) => (value || '/').replace(/\/+$/, '') || '/';
@@ -239,11 +219,10 @@
/**
* @param {any} component
* @param {string} category
- * @param {{ allowViewOnly?: boolean }} options
*/
- const roleGatedRoute = (component, category, options = {}) => wrap({
+ const roleGatedRoute = (component, category) => wrap({
component,
- conditions: [requireRoleForRoute(category, options)],
+ conditions: [requireRoleForRoute(category)],
});
// Define routes
@@ -290,12 +269,12 @@
// Validators routes
'/validators': RoleFunnel,
'/validators/journey': ValidatorWaitlist,
- '/validators/contributions': roleGatedRoute(Contributions, 'validator', { allowViewOnly: true }),
- '/validators/all-contributions': roleGatedRoute(AllContributions, 'validator', { allowViewOnly: true }),
+ '/validators/contributions': roleGatedRoute(Contributions, 'validator'),
+ '/validators/all-contributions': roleGatedRoute(AllContributions, 'validator'),
'/validators/leaderboard': protectedRoute(Leaderboard),
'/validators/tasks': roleGatedRoute(SocialTasks, 'validator'),
'/validators/participants': protectedRoute(Validators),
- '/validators/wall-of-shame': roleGatedRoute(WallOfShame, 'validator', { allowViewOnly: true }),
+ '/validators/wall-of-shame': roleGatedRoute(WallOfShame, 'validator'),
'/validators/waitlist': protectedRoute(Waitlist),
'/validators/waitlist/participants': protectedRoute(WaitlistParticipants),
'/validators/waitlist/join': ValidatorWaitlist,
diff --git a/frontend/src/components/Missions.svelte b/frontend/src/components/Missions.svelte
index 589d5c38..58f305c7 100644
--- a/frontend/src/components/Missions.svelte
+++ b/frontend/src/components/Missions.svelte
@@ -10,6 +10,8 @@
import { rgbaFromHex } from '../lib/categoryPresentation.js';
import { isInteractiveTarget, stripPreviewMedia } from '../lib/domHelpers.js';
+ let { readOnly = false } = $props();
+
let missions = $state([]);
let loading = $state(true);
let countdowns = $state({});
@@ -311,14 +313,20 @@
>
Details
-
+ {#if readOnly}
+
+ View only
+
+ {:else}
+
+ {/if}
diff --git a/frontend/src/components/social-tasks/SocialTasksSection.svelte b/frontend/src/components/social-tasks/SocialTasksSection.svelte
index fe7638e4..39ec06a8 100644
--- a/frontend/src/components/social-tasks/SocialTasksSection.svelte
+++ b/frontend/src/components/social-tasks/SocialTasksSection.svelte
@@ -128,16 +128,14 @@
{/if}
- {#if !readOnly}
-
- {/if}
+
diff --git a/frontend/src/lib/roleState.js b/frontend/src/lib/roleState.js
index eacb9097..0ff0f136 100644
--- a/frontend/src/lib/roleState.js
+++ b/frontend/src/lib/roleState.js
@@ -15,6 +15,8 @@ const ROUTE_BASE = {
community: '/community',
};
+const VIEWABLE_ROLE_CATEGORIES = new Set(['builder', 'validator', 'community']);
+
export function rolePath(category) {
return ROUTE_BASE[category] || '/';
}
@@ -31,17 +33,28 @@ export function hasEarnedRole(user, category) {
return false;
}
-// Read access is deliberately separate from role membership. The backend only
-// returns this flag to the one configured account on /users/me/; keeping it out
-// of hasEarnedRole prevents validator identity, funnels, submissions, and stats
-// from treating that account as a validator.
+// Admin-managed read access is deliberately separate from role membership.
+// The backend returns the flag to its owner only; keeping it out of
+// hasEarnedRole prevents funnels, submissions, points, and stats from treating
+// a viewer as a Builder, Validator, Creator, or Steward.
/**
* @param {Record | null | undefined} user
* @param {string} category
*/
export function hasRoleSectionAccess(user, category) {
if (hasEarnedRole(user, category)) return true;
- return category === 'validator' && user?.can_view_validator_sections === true;
+ return VIEWABLE_ROLE_CATEGORIES.has(category)
+ && user?.can_view_role_sections === true;
+}
+
+/**
+ * @param {Record | null | undefined} user
+ * @param {string} category
+ */
+export function hasReadOnlyRoleSectionAccess(user, category) {
+ return VIEWABLE_ROLE_CATEGORIES.has(category)
+ && user?.can_view_role_sections === true
+ && !hasEarnedRole(user, category);
}
// "Started but not earned", from durable point-free `-welcome` markers set
diff --git a/frontend/src/routes/AllContributions.svelte b/frontend/src/routes/AllContributions.svelte
index b653faf4..6c176e9f 100644
--- a/frontend/src/routes/AllContributions.svelte
+++ b/frontend/src/routes/AllContributions.svelte
@@ -13,7 +13,7 @@
import CategoryIcon from '../components/portal/CategoryIcon.svelte';
import { visibleContributions } from '../lib/hiddenContributions.js';
import { userStore } from '../lib/userStore.js';
- import { hasEarnedRole, hasRoleSectionAccess } from '../lib/roleState.js';
+ import { hasReadOnlyRoleSectionAccess } from '../lib/roleState.js';
const HIGHLIGHTS_PREVIEW_COUNT = 15;
const PAGE_SIZE = 20;
@@ -88,10 +88,8 @@
let baseRoutePath = $derived(buildBasePath($location));
let routeCategory = $derived(detectRouteCategory($location));
- let isValidatorReadOnlyViewer = $derived(
- routeCategory === 'validator'
- && hasRoleSectionAccess($userStore.user, 'validator')
- && !hasEarnedRole($userStore.user, 'validator')
+ let isRoleSectionReadOnly = $derived(
+ hasReadOnlyRoleSectionAccess($userStore.user, routeCategory)
);
let typesForCategory = $derived(
@@ -710,10 +708,10 @@
hasActiveFilters ? 'No highlights match these filters' : 'No highlighted contributions yet',
hasActiveFilters
? 'Try clearing some filters to see highlighted contributions from other categories or types.'
- : (isValidatorReadOnlyViewer
- ? 'Highlighted validator contributions will appear here when available.'
+ : (isRoleSectionReadOnly
+ ? 'Highlighted contributions for this role will appear here when available.'
: 'Submit impactful or pioneering work and a steward may highlight it.'),
- hasActiveFilters ? clearFiltersAction : (isValidatorReadOnlyViewer ? null : submitContributionAction)
+ hasActiveFilters ? clearFiltersAction : (isRoleSectionReadOnly ? null : submitContributionAction)
)}
{/snippet}
@@ -729,8 +727,8 @@
{hasActiveFilters
? 'No highlights match these filters.'
- : (isValidatorReadOnlyViewer
- ? 'No highlighted validator contributions yet.'
+ : (isRoleSectionReadOnly
+ ? 'No highlighted contributions for this role yet.'
: 'No highlighted contributions yet. Submit impactful work and a steward may highlight it.')}
@@ -742,10 +740,10 @@
hasActiveFilters ? 'No contributions match these filters' : 'No contributions yet',
hasActiveFilters
? 'Try clearing some filters or searching by a different name or address.'
- : (isValidatorReadOnlyViewer
- ? 'Accepted validator contributions will appear here when available.'
+ : (isRoleSectionReadOnly
+ ? 'Accepted contributions for this role will appear here when available.'
: 'Be the first — submit a contribution to get started.'),
- hasActiveFilters ? clearFiltersAction : (isValidatorReadOnlyViewer ? null : submitContributionAction)
+ hasActiveFilters ? clearFiltersAction : (isRoleSectionReadOnly ? null : submitContributionAction)
)}
{/snippet}
@@ -754,7 +752,7 @@
{pageTitle}
- {#if isValidatorReadOnlyViewer}
+ {#if isRoleSectionReadOnly}
View-only access
@@ -1018,11 +1016,11 @@
class="text-[17px] text-black leading-[28px]"
style="letter-spacing: 0.34px;"
>
- {isValidatorReadOnlyViewer
- ? 'Explore the work supporting validator operations, testing, and network reliability.'
+ {isRoleSectionReadOnly
+ ? 'Explore the work and activity supporting this role.'
: "Submit a contribution and help shape the future of GenLayer. Every action counts toward the network's progress."}
- {#if !isValidatorReadOnlyViewer}
+ {#if !isRoleSectionReadOnly}