diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 8a551317..6e1b81c8 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -655,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..d0b012e5 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() @@ -276,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) @@ -585,6 +672,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..dc96404f 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, @@ -211,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: @@ -239,6 +260,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 e41b5038..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,6 +39,45 @@ def validate_eligibility_requirements(value): def evaluate_task_eligibility(task, user): + """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, + 'Sign in with a validator account to complete this task.', + details={'requirements': [], 'required_role': 'validator'}, + ) + + if not user_has_role_profile(user, 'validator'): + 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..fbc07762 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,146 @@ 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', + ) + + 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'} + ) + 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'} + ) + 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', + ) + 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/') + + 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/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..30eb94c1 --- /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 = {} + 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 3c182d9f..385371f0 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -330,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): @@ -530,6 +534,7 @@ class UserSerializer(serializers.ModelSerializer): email_verified_at = serializers.SerializerMethodField() is_banned = serializers.SerializerMethodField() ban_reason = serializers.SerializerMethodField() + can_view_role_sections = serializers.SerializerMethodField() class Meta: model = User @@ -545,6 +550,8 @@ class Meta: 'email', 'is_email_verified', 'email_verified_at', # Ban status 'is_banned', 'ban_reason', + # Admin-managed read-only access to non-steward role sections + 'can_view_role_sections', # Social connections 'github_connection', 'twitter_connection', 'discord_connection', # Referral fields @@ -707,6 +714,17 @@ def get_ban_reason(self, obj): return obj.ban_reason return '' + 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( + request_user + and request_user.is_authenticated + and request_user.pk == obj.pk + and obj.can_view_role_sections + ) + 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_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_views.py b/backend/users/tests/test_views.py new file mode 100644 index 00000000..73be5e80 --- /dev/null +++ b/backend/users/tests/test_views.py @@ -0,0 +1,65 @@ +from rest_framework.test import APITestCase + +from builders.models import Builder +from creators.models import Creator +from users.models import User +from validators.models import Validator + + +class RoleSectionViewerAPITests(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_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/frontend/CLAUDE.md b/frontend/CLAUDE.md index 0038d6dc..ed62bd63 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 + - 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 28f3520d..bb66392d 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 { 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,37 +175,29 @@ 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. 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); - } - + // 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 {string} category + */ 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. loadUser + // coalesces overlapping calls, so rapid navigation shares one request. + 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 (await hasSubsectionAccess(user, category)) return true; + if (hasRoleSectionAccess(user, category)) return true; // Stale-navigation guard: only redirect if still on the guarded route. const normalizePath = (value) => (value || '/').replace(/\/+$/, '') || '/'; @@ -225,6 +217,10 @@ }; } + /** + * @param {any} component + * @param {string} category + */ const roleGatedRoute = (component, category) => wrap({ component, conditions: [requireRoleForRoute(category)], 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/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..39ec06a8 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); @@ -145,7 +145,7 @@ > {#each tasks as task (task.slug)}
- +
{/each} diff --git a/frontend/src/lib/roleState.js b/frontend/src/lib/roleState.js index 31f5ba2b..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,6 +33,30 @@ export function hasEarnedRole(user, category) { return false; } +// 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 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 // 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..6c176e9f 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 { hasReadOnlyRoleSectionAccess } from '../lib/roleState.js'; const HIGHLIGHTS_PREVIEW_COUNT = 15; const PAGE_SIZE = 20; @@ -86,6 +88,9 @@ let baseRoutePath = $derived(buildBasePath($location)); let routeCategory = $derived(detectRouteCategory($location)); + let isRoleSectionReadOnly = $derived( + hasReadOnlyRoleSectionAccess($userStore.user, routeCategory) + ); let typesForCategory = $derived( category === 'all' ? allTypes : allTypes.filter(t => t.category === category) @@ -703,8 +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.' - : 'Submit impactful or pioneering work and a steward may highlight it.', - hasActiveFilters ? clearFiltersAction : submitContributionAction + : (isRoleSectionReadOnly + ? 'Highlighted contributions for this role will appear here when available.' + : 'Submit impactful or pioneering work and a steward may highlight it.'), + hasActiveFilters ? clearFiltersAction : (isRoleSectionReadOnly ? null : submitContributionAction) )} {/snippet} @@ -720,7 +727,9 @@

{hasActiveFilters ? 'No highlights match these filters.' - : 'No highlighted contributions yet. Submit impactful work and a steward may highlight it.'} + : (isRoleSectionReadOnly + ? 'No highlighted contributions for this role yet.' + : 'No highlighted contributions yet. Submit impactful work and a steward may highlight it.')}

{/snippet} @@ -731,15 +740,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 + : (isRoleSectionReadOnly + ? 'Accepted contributions for this role will appear here when available.' + : 'Be the first — submit a contribution to get started.'), + hasActiveFilters ? clearFiltersAction : (isRoleSectionReadOnly ? null : submitContributionAction) )} {/snippet}
-

{pageTitle}

+
+

{pageTitle}

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

{pageSubtitle}

@@ -998,21 +1016,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. + {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."}

- + + Submit a contribution + + + + {/if}
diff --git a/frontend/src/routes/CommunityPoaps.svelte b/frontend/src/routes/CommunityPoaps.svelte index 524c8875..6c8625bc 100644 --- a/frontend/src/routes/CommunityPoaps.svelte +++ b/frontend/src/routes/CommunityPoaps.svelte @@ -2,6 +2,8 @@ import { onMount } from 'svelte'; import { push } from 'svelte-spa-router'; import { poapsAPI } from '../lib/api.js'; + import { userStore } from '../lib/userStore.js'; + import { hasReadOnlyRoleSectionAccess } from '../lib/roleState.js'; import { showError } from '../lib/toastStore.js'; import PoapCollectionWall from '../components/poaps/PoapCollectionWall.svelte'; import CategoryIcon from '../components/portal/CategoryIcon.svelte'; @@ -24,6 +26,9 @@ const PAGE_SIZE = 48; const poapGradientStyle = getCategoryGradientStyle('community', '#7f52e1'); + let isRoleSectionReadOnly = $derived( + hasReadOnlyRoleSectionAccess($userStore.user, 'community') + ); /** @param {number} [nextPage] @param {boolean} [append] @param {boolean} [reuseAppliedFilters] */ async function loadPoaps(nextPage = 1, append = false, reuseAppliedFilters = false) { @@ -130,17 +135,23 @@
- + {#if isRoleSectionReadOnly} + + View-only access + + {:else} + + {/if}
- {#if contributionType.is_submittable} + {#if isRoleSectionReadOnly} + + View-only access + + {:else if contributionType.is_submittable}
- + {#if isRoleSectionReadOnly} + + View-only access + + {:else} + + {/if} {#if activeCategory === 'builder'} {/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/PoapClaim.svelte b/frontend/src/routes/PoapClaim.svelte index 3f6122d4..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'; @@ -14,6 +15,8 @@ /** @type {any} */ 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()); @@ -42,7 +45,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 +92,7 @@ } } catch (err) { const requestError = /** @type {any} */ (err); + roleViewOnly = requestError.response?.data?.code === 'role_view_only'; if (isAuthError(requestError)) { attempted = false; status = 'auth'; @@ -106,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); } @@ -128,35 +182,33 @@ 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(); } }); 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'; + if (status === 'checking') return 'Checking access'; return 'Claim POAP'; } 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 === 'checking') return 'Checking'; if (status === 'auth') return 'Wallet required'; return 'Mint link'; } diff --git a/frontend/src/routes/PoapDetail.svelte b/frontend/src/routes/PoapDetail.svelte index d2bdb8e4..f7f50453 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( @@ -108,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; } @@ -185,6 +190,7 @@ } async function claimSecret() { + if (isRoleSectionReadOnly) return; if (!$authState.isAuthenticated) { signInForClaim(); return; @@ -299,7 +305,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/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]); + }); +}); diff --git a/frontend/src/tests/roleState.test.js b/frontend/src/tests/roleState.test.js index 3fa7ab0f..014bde24 100644 --- a/frontend/src/tests/roleState.test.js +++ b/frontend/src/tests/roleState.test.js @@ -5,6 +5,8 @@ import { journeyPath, roleForCategory, hasAnyRoleOrJourney, + hasRoleSectionAccess, + hasReadOnlyRoleSectionAccess, } from '../lib/roleState.js'; describe('roleState.roleFunnelState', () => { @@ -64,6 +66,41 @@ 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 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(true); + expect(hasRoleSectionAccess(viewer, 'community')).toBe(true); + expect(hasRoleSectionAccess(viewer, 'steward')).toBe(false); + expect(hasRoleSectionAccess(viewer, 'global')).toBe(false); + }); + + 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); + }); +}); + 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);