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}
-

{pageTitle}

+
+

{pageTitle}

+ {#if isValidatorReadOnlyViewer} + + View-only access + + {/if} +

{pageSubtitle}

@@ -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}
- + {#if isRoleSectionReadOnly} + + View-only access + + {:else} + + {/if}
- {#if contributionType.is_submittable} + {#if isRoleSectionReadOnly} + + View-only access + + {:else if contributionType.is_submittable}
- {#if isValidatorReadOnlyViewer} + {#if isRoleSectionReadOnly} View-only access @@ -216,9 +214,9 @@ {/if} - + - + diff --git a/frontend/src/routes/MissionDetail.svelte b/frontend/src/routes/MissionDetail.svelte index 53a0fe9e..80e8b32c 100644 --- a/frontend/src/routes/MissionDetail.svelte +++ b/frontend/src/routes/MissionDetail.svelte @@ -5,6 +5,8 @@ import { push } from 'svelte-spa-router'; import { contributionsAPI } from '../lib/api'; import { visibleContributions } from '../lib/hiddenContributions.js'; + import { userStore } from '../lib/userStore.js'; + import { hasReadOnlyRoleSectionAccess } from '../lib/roleState.js'; import { parseMarkdown } from '../lib/markdownLoader.js'; import PortalContributionCard from '../components/portal/PortalContributionCard.svelte'; import { getCategoryButtonStyle, getCategoryGradientStyle } from '../lib/categoryPresentation.js'; @@ -53,6 +55,9 @@ let contributionSlider = $state(/** @type {HTMLElement | null} */ (null)); let category = $derived(contributionType?.category || 'global'); + let isRoleSectionReadOnly = $derived( + hasReadOnlyRoleSectionAccess($userStore.user, category) + ); let config = $derived(categoryConfig[category] || categoryConfig.global); let gradientStyle = $derived(getCategoryGradientStyle(category, config.accent)); let submitButtonStyle = $derived(getCategoryButtonStyle(config.accent)); @@ -241,18 +246,24 @@
- + {#if isRoleSectionReadOnly} + + View-only access + + {:else} + + {/if}
diff --git a/frontend/src/routes/PoapDetail.svelte b/frontend/src/routes/PoapDetail.svelte index d2bdb8e4..84fea10d 100644 --- a/frontend/src/routes/PoapDetail.svelte +++ b/frontend/src/routes/PoapDetail.svelte @@ -3,6 +3,7 @@ import { format } from '../lib/dates.js'; import { authState } from '../lib/auth.js'; import { userStore } from '../lib/userStore.js'; + import { hasReadOnlyRoleSectionAccess } from '../lib/roleState.js'; import { poapsAPI } from '../lib/api.js'; import { setPageMeta } from '../lib/meta.js'; import { truncateMetaDescription } from '../lib/metaHelpers.js'; @@ -36,6 +37,9 @@ let hasMintLinkClaim = $derived(canClaim && activeClaimDistributions.some((distribution) => distribution.method === 'mint_link')); let discordConnection = $derived($userStore.user?.discord_connection || null); let hasDiscordConnection = $derived(Boolean(discordConnection)); + let isRoleSectionReadOnly = $derived( + hasReadOnlyRoleSectionAccess($userStore.user, 'community') + ); let collectorCount = $derived(poap?.claimed_count ?? claimsCount ?? 0); let statusLabel = $derived(getPoapStatusLabel(poap)); let statusClass = $derived( @@ -185,6 +189,7 @@ } async function claimSecret() { + if (isRoleSectionReadOnly) return; if (!$authState.isAuthenticated) { signInForClaim(); return; @@ -299,7 +304,11 @@
{/if} - {#if hasSecretClaim && !hasDiscordConnection} + {#if isRoleSectionReadOnly} +
+ View-only access does not include claiming POAPs. +
+ {:else if hasSecretClaim && !hasDiscordConnection}
+ {:else if isRoleSectionReadOnly} +
+
+

View-only access

+

POAP recovery is unavailable

+

You can browse Community POAPs, but recovery and claims require the Community role.

+
+
{:else}
diff --git a/frontend/src/routes/SocialTasks.svelte b/frontend/src/routes/SocialTasks.svelte index aa79a1b1..e9ac6575 100644 --- a/frontend/src/routes/SocialTasks.svelte +++ b/frontend/src/routes/SocialTasks.svelte @@ -2,6 +2,8 @@ // @ts-nocheck import { socialTasksAPI } from '../lib/api.js'; import { authState } from '../lib/auth.js'; + import { userStore } from '../lib/userStore.js'; + import { hasReadOnlyRoleSectionAccess } from '../lib/roleState.js'; import { currentCategory } from '../stores/category.js'; import { getCategoryGradientStyle } from '../lib/categoryPresentation.js'; import { getCategoryAccent } from '../lib/categoryColors.js'; @@ -20,6 +22,9 @@ let pageCategory = $derived( $currentCategory && $currentCategory !== 'global' ? $currentCategory : 'community' ); + let isRoleSectionReadOnly = $derived( + hasReadOnlyRoleSectionAccess($userStore.user, pageCategory) + ); let accentColor = $derived(getCategoryAccent(pageCategory)); let pageGradientStyle = $derived(getCategoryGradientStyle(pageCategory, accentColor)); @@ -108,6 +113,11 @@ > {labels.title} + {#if isRoleSectionReadOnly} + + View-only access + + {/if}

{labels.subtitle}

@@ -195,7 +205,7 @@ {:else}
{#each filteredTasks as task (task.slug)} - + {/each}
{/if} diff --git a/frontend/src/tests/roleState.test.js b/frontend/src/tests/roleState.test.js index 8e3bd131..014bde24 100644 --- a/frontend/src/tests/roleState.test.js +++ b/frontend/src/tests/roleState.test.js @@ -6,6 +6,7 @@ import { roleForCategory, hasAnyRoleOrJourney, hasRoleSectionAccess, + hasReadOnlyRoleSectionAccess, } from '../lib/roleState.js'; describe('roleState.roleFunnelState', () => { @@ -71,18 +72,31 @@ describe('roleState.hasRoleSectionAccess', () => { expect(hasRoleSectionAccess({ builder: {} }, 'builder')).toBe(true); }); - it('allows the configured view-only exception for validators only', () => { - const viewer = { can_view_validator_sections: true }; + it('allows the admin-managed viewer across non-steward roles only', () => { + const viewer = { can_view_role_sections: true }; expect(hasRoleSectionAccess(viewer, 'validator')).toBe(true); - expect(hasRoleSectionAccess(viewer, 'builder')).toBe(false); - expect(hasRoleSectionAccess(viewer, 'community')).toBe(false); + expect(hasRoleSectionAccess(viewer, 'builder')).toBe(true); + expect(hasRoleSectionAccess(viewer, 'community')).toBe(true); + expect(hasRoleSectionAccess(viewer, 'steward')).toBe(false); + expect(hasRoleSectionAccess(viewer, 'global')).toBe(false); }); - it('does not turn validator viewing access into an earned role', () => { - const viewer = { can_view_validator_sections: true }; + it('identifies categories where the viewer lacks the real role', () => { + const viewer = { can_view_role_sections: true, builder: {} }; + + expect(hasReadOnlyRoleSectionAccess(viewer, 'validator')).toBe(true); + expect(hasReadOnlyRoleSectionAccess(viewer, 'community')).toBe(true); + expect(hasReadOnlyRoleSectionAccess(viewer, 'builder')).toBe(false); + expect(hasReadOnlyRoleSectionAccess(viewer, 'steward')).toBe(false); + }); + + it('does not turn viewing access into an earned role', () => { + const viewer = { can_view_role_sections: true }; expect(roleFunnelState(true, viewer, 'validator')).toBe('none'); + expect(roleFunnelState(true, viewer, 'builder')).toBe('none'); + expect(roleFunnelState(true, viewer, 'community')).toBe('none'); expect(hasAnyRoleOrJourney(viewer)).toBe(false); }); }); From eab884c0cc4c3b3c846800131e6e01d5c72376ce Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Wed, 22 Jul 2026 17:13:38 -0400 Subject: [PATCH 4/5] Address role access review feedback --- backend/poaps/tests/test_poaps.py | 53 +++++++++++++++++++ backend/poaps/views.py | 4 ++ backend/users/role_access.py | 2 +- backend/users/tests/test_role_access.py | 30 +++++++++++ ...t_role_section_viewer.py => test_views.py} | 18 +------ frontend/src/App.svelte | 3 +- frontend/src/routes/PoapClaim.svelte | 7 ++- frontend/src/routes/PoapDetail.svelte | 3 +- frontend/src/routes/PoapRecovery.svelte | 4 +- 9 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 backend/users/tests/test_role_access.py rename backend/users/tests/{test_role_section_viewer.py => test_views.py} (73%) diff --git a/backend/poaps/tests/test_poaps.py b/backend/poaps/tests/test_poaps.py index d97df408..d0b012e5 100644 --- a/backend/poaps/tests/test_poaps.py +++ b/backend/poaps/tests/test_poaps.py @@ -310,6 +310,59 @@ def test_mint_link_legacy_path_still_claims(self): self.assertEqual(link.used_count, 1) self.assertEqual(distribution.claimed_count, 1) + def test_read_only_community_viewer_cannot_claim_mint_links(self): + distribution = PoapDistribution.objects.create( + drop=self.drop, + method=PoapDistribution.METHOD_MINT_LINK, + active=True, + ) + generated_links = generate_mint_links(distribution=distribution, count=2) + 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) + + modern_link, modern_token = generated_links[0] + legacy_link, legacy_token = generated_links[1] + responses = ( + self.client.post( + '/api/v1/poaps/claim-link/', + {'token': modern_token}, + format='json', + ), + self.client.post(f'/api/v1/poaps/claim-link/{legacy_token}/'), + ) + + for response in responses: + 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()) + modern_link.refresh_from_db() + legacy_link.refresh_from_db() + distribution.refresh_from_db() + self.assertEqual(modern_link.used_count, 0) + self.assertEqual(legacy_link.used_count, 0) + self.assertEqual(distribution.claimed_count, 0) + + def test_real_creator_with_view_flag_can_claim_mint_link(self): + distribution = PoapDistribution.objects.create( + drop=self.drop, + method=PoapDistribution.METHOD_MINT_LINK, + active=True, + ) + [(_link, token)] = generate_mint_links(distribution=distribution, count=1) + 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/claim-link/', + {'token': token}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + def test_mint_link_claim_reports_missing_token(self): self.client.force_authenticate(user=self.user) diff --git a/backend/poaps/views.py b/backend/poaps/views.py index bf49fcee..dc96404f 100644 --- a/backend/poaps/views.py +++ b/backend/poaps/views.py @@ -228,6 +228,10 @@ def claim_secret(self, request, slug=None): return Response(PoapClaimSerializer(claim).data, status=status.HTTP_201_CREATED) def _claim_link_with_token(self, request, token): + read_only_response = self._read_only_community_response(request) + if read_only_response is not None: + return read_only_response + try: claim = claim_with_mint_link(token=token, user=request.user) except PoapDrop.DoesNotExist: diff --git a/backend/users/role_access.py b/backend/users/role_access.py index c5564013..30eb94c1 100644 --- a/backend/users/role_access.py +++ b/backend/users/role_access.py @@ -20,7 +20,7 @@ def user_has_role_profile(user, category): cache = getattr(user, '_role_profile_access_cache', None) if cache is None: cache = {} - setattr(user, '_role_profile_access_cache', cache) + 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) diff --git a/backend/users/tests/test_role_access.py b/backend/users/tests/test_role_access.py new file mode 100644 index 00000000..f8b45e64 --- /dev/null +++ b/backend/users/tests/test_role_access.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from builders.models import Builder +from users.models import User +from users.role_access import can_view_role_section, is_role_section_read_only + + +class RoleAccessTests(TestCase): + def setUp(self): + self.viewer = User.objects.create_user( + email='role-access-viewer@example.com', + password='testpass123', + visible=True, + can_view_role_sections=True, + ) + + 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')) diff --git a/backend/users/tests/test_role_section_viewer.py b/backend/users/tests/test_views.py similarity index 73% rename from backend/users/tests/test_role_section_viewer.py rename to backend/users/tests/test_views.py index 63ed5855..73be5e80 100644 --- a/backend/users/tests/test_role_section_viewer.py +++ b/backend/users/tests/test_views.py @@ -3,11 +3,10 @@ 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): +class RoleSectionViewerAPITests(APITestCase): def setUp(self): self.viewer = User.objects.create_user( email='role-section-viewer@example.com', @@ -35,21 +34,6 @@ def test_admin_enabled_owner_receives_flag_without_any_role(self): 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) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5ba99063..bb66392d 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -187,7 +187,8 @@ if (!authed) return false; // Re-read /users/me/ on every gated navigation so an admin toggle or - // revocation takes effect without trusting stale client state. + // revocation takes effect without trusting stale client state. loadUser + // coalesces overlapping calls, so rapid navigation shares one request. let user = null; try { user = await userStore.loadUser(); diff --git a/frontend/src/routes/PoapClaim.svelte b/frontend/src/routes/PoapClaim.svelte index 3f6122d4..c64b36d2 100644 --- a/frontend/src/routes/PoapClaim.svelte +++ b/frontend/src/routes/PoapClaim.svelte @@ -14,6 +14,7 @@ /** @type {any} */ let drop = $state(null); let attempted = $state(false); + let roleViewOnly = $state(false); let routeToken = $derived($params?.token || ''); let token = $derived(routeToken || tokenFromUrl()); @@ -42,7 +43,8 @@ /** @param {any} err */ function isAuthError(err) { const statusCode = err?.response?.status; - if (isDiscordLinkError(err)) return false; + const errorCode = err?.response?.data?.code; + if (isDiscordLinkError(err) || errorCode === 'role_view_only') return false; return statusCode === 401 || statusCode === 403; } @@ -88,6 +90,7 @@ } } catch (err) { const requestError = /** @type {any} */ (err); + roleViewOnly = requestError.response?.data?.code === 'role_view_only'; if (isAuthError(requestError)) { attempted = false; status = 'auth'; @@ -147,6 +150,7 @@ function heading() { if (status === 'claimed') return 'POAP claimed'; if (requiresDiscordLink) return 'Link Discord'; + if (roleViewOnly) return 'View-only access'; if (status === 'error') return 'Mint link not available'; if (status === 'auth') return 'Connect wallet'; return 'Claim POAP'; @@ -155,6 +159,7 @@ function statusText() { if (status === 'claimed') return 'Claimed'; if (requiresDiscordLink) return 'Discord required'; + if (roleViewOnly) return 'View only'; if (status === 'error') return 'Unavailable'; if (status === 'claiming') return 'Claiming'; if (status === 'auth') return 'Wallet required'; diff --git a/frontend/src/routes/PoapDetail.svelte b/frontend/src/routes/PoapDetail.svelte index 84fea10d..f7f50453 100644 --- a/frontend/src/routes/PoapDetail.svelte +++ b/frontend/src/routes/PoapDetail.svelte @@ -112,7 +112,8 @@ /** @param {any} err */ function isAuthError(err) { const statusCode = err?.response?.status; - if (isDiscordLinkError(err)) return false; + const errorCode = err?.response?.data?.code; + if (isDiscordLinkError(err) || errorCode === 'role_view_only') return false; return statusCode === 401 || statusCode === 403; } diff --git a/frontend/src/routes/PoapRecovery.svelte b/frontend/src/routes/PoapRecovery.svelte index 1c55854b..0258bc11 100644 --- a/frontend/src/routes/PoapRecovery.svelte +++ b/frontend/src/routes/PoapRecovery.svelte @@ -123,7 +123,9 @@ Issued At: ${new Date().toISOString()}`; /** @param {any} err */ function isAuthError(err) { const statusCode = err?.response?.status; - return statusCode === 401 || statusCode === 403; + const errorCode = err?.response?.data?.code; + return errorCode !== 'role_view_only' + && (statusCode === 401 || statusCode === 403); } async function verifyPoapWallet() { From 8502553104000d03cc86ff6f75150b6fced57bed Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Wed, 22 Jul 2026 19:09:54 -0400 Subject: [PATCH 5/5] Check role access before POAP claims --- frontend/src/routes/PoapClaim.svelte | 69 +++++++++++++--- frontend/src/tests/poapClaimAccess.test.js | 94 ++++++++++++++++++++++ 2 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 frontend/src/tests/poapClaimAccess.test.js diff --git a/frontend/src/routes/PoapClaim.svelte b/frontend/src/routes/PoapClaim.svelte index c64b36d2..15d7a949 100644 --- a/frontend/src/routes/PoapClaim.svelte +++ b/frontend/src/routes/PoapClaim.svelte @@ -4,6 +4,7 @@ import { authState } from '../lib/auth.js'; import { userStore } from '../lib/userStore.js'; import { poapsAPI } from '../lib/api.js'; + import { hasReadOnlyRoleSectionAccess } from '../lib/roleState.js'; import { clearPoapClaimRedirect } from '../lib/poapRedirect.js'; import { showError, showSuccess } from '../lib/toastStore.js'; import SocialLink from '../components/SocialLink.svelte'; @@ -15,6 +16,7 @@ let drop = $state(null); let attempted = $state(false); let roleViewOnly = $state(false); + let preparing = false; let routeToken = $derived($params?.token || ''); let token = $derived(routeToken || tokenFromUrl()); @@ -109,15 +111,64 @@ } } + async function prepareClaim() { + if (preparing || attempted || !$authState.isAuthenticated) return; + const claimToken = token; + if (!claimToken) { + status = 'error'; + message = 'This mint link is missing its claim token.'; + showError(message); + return; + } + + preparing = true; + status = 'checking'; + message = 'Checking claim access...'; + try { + const user = await userStore.loadUser(); + if (destroyed) return; + if (!$authState.isAuthenticated) { + status = 'auth'; + message = 'Connect your wallet to claim this POAP.'; + return; + } + roleViewOnly = hasReadOnlyRoleSectionAccess(user, 'community'); + if (roleViewOnly) { + attempted = true; + status = 'error'; + message = 'View-only access does not include claiming POAPs.'; + clearPoapClaimRedirect(claimToken); + return; + } + await claim(); + } catch (err) { + if (destroyed) return; + const requestError = /** @type {any} */ (err); + if (isAuthError(requestError)) { + status = 'auth'; + message = 'Connect your wallet to claim this POAP.'; + authState.setAuthenticated(false, null); + authState.resetVerification(); + requestLogin(); + return; + } + status = 'error'; + message = 'Unable to verify claim access. Try again later.'; + showError(message); + } finally { + preparing = false; + } + } + /** @param {any} updatedUser */ function handleDiscordLinked(updatedUser) { if (updatedUser) userStore.setUser(updatedUser); if (!token) return; attempted = false; - status = 'claiming'; - message = 'Claiming your POAP...'; + status = 'checking'; + message = 'Checking claim access...'; window.setTimeout(() => { - claim(); + prepareClaim(); }, 0); } @@ -131,19 +182,13 @@ message = 'Connect your wallet to claim this POAP.'; requestLogin(); } else { - claim(); + prepareClaim(); } }); $effect(() => { if ($authState.isAuthenticated && status === 'auth') { - claim(); - } - }); - - $effect(() => { - if ($authState.isAuthenticated && !$userStore.user && !$userStore.loading) { - userStore.loadUser().catch(() => {}); + prepareClaim(); } }); @@ -153,6 +198,7 @@ if (roleViewOnly) return 'View-only access'; if (status === 'error') return 'Mint link not available'; if (status === 'auth') return 'Connect wallet'; + if (status === 'checking') return 'Checking access'; return 'Claim POAP'; } @@ -162,6 +208,7 @@ if (roleViewOnly) return 'View only'; if (status === 'error') return 'Unavailable'; if (status === 'claiming') return 'Claiming'; + if (status === 'checking') return 'Checking'; if (status === 'auth') return 'Wallet required'; return 'Mint link'; } diff --git a/frontend/src/tests/poapClaimAccess.test.js b/frontend/src/tests/poapClaimAccess.test.js new file mode 100644 index 00000000..6e04d97f --- /dev/null +++ b/frontend/src/tests/poapClaimAccess.test.js @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte/svelte5'; + +const mocks = vi.hoisted(() => ({ + claimLink: vi.fn(), + clearRedirect: vi.fn(), + getCurrentUser: vi.fn(), + push: vi.fn(), + showError: vi.fn(), + showSuccess: vi.fn(), + authState: { + subscribe: vi.fn((run) => { + run({ isAuthenticated: true }); + return () => {}; + }), + resetVerification: vi.fn(), + setAuthenticated: vi.fn(), + }, +})); + +vi.mock('svelte-spa-router', () => ({ + params: { + subscribe(run) { + run({ token: 'claim-token' }); + return () => {}; + }, + }, + push: mocks.push, +})); + +vi.mock('../lib/auth.js', () => ({ + authState: mocks.authState, +})); + +vi.mock('../lib/api.js', () => ({ + getCurrentUser: mocks.getCurrentUser, + journeyAPI: {}, + poapsAPI: { + claimLink: mocks.claimLink, + }, + socialAPI: {}, +})); + +vi.mock('../lib/poapRedirect.js', () => ({ + clearPoapClaimRedirect: mocks.clearRedirect, +})); + +vi.mock('../lib/toastStore.js', () => ({ + showError: mocks.showError, + showSuccess: mocks.showSuccess, +})); + +import PoapClaim from '../routes/PoapClaim.svelte'; +import { userStore } from '../lib/userStore.js'; + + +describe('POAP mint-link access', () => { + beforeEach(() => { + vi.clearAllMocks(); + userStore.clearUser(); + mocks.claimLink.mockResolvedValue({ data: {} }); + }); + + it('waits for permission refresh and does not claim for a read-only viewer', async () => { + let resolveUser; + mocks.getCurrentUser.mockImplementationOnce(() => new Promise((resolve) => { + resolveUser = resolve; + })); + + render(PoapClaim); + + await waitFor(() => expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1)); + expect(mocks.claimLink).not.toHaveBeenCalled(); + + resolveUser({ can_view_role_sections: true, creator: null }); + + expect(await screen.findByRole('heading', { name: 'View-only access' })).toBeTruthy(); + expect(mocks.claimLink).not.toHaveBeenCalled(); + expect(mocks.clearRedirect).toHaveBeenCalledWith('claim-token'); + }); + + it('claims only after refreshed permissions allow it', async () => { + mocks.getCurrentUser.mockResolvedValue({ + can_view_role_sections: false, + creator: null, + }); + + render(PoapClaim); + + await waitFor(() => expect(mocks.claimLink).toHaveBeenCalledWith('claim-token')); + expect(mocks.getCurrentUser.mock.invocationCallOrder[0]) + .toBeLessThan(mocks.claimLink.mock.invocationCallOrder[0]); + }); +});