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)}
{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}{pageSubtitle}
View-only access
+You can browse Community POAPs, but recovery and claims require the Community role.
+{labels.subtitle}