diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go index 05b3cf5..c2d0f66 100644 --- a/go-backend/internal/handlers/auth_handler.go +++ b/go-backend/internal/handlers/auth_handler.go @@ -43,6 +43,8 @@ type RegisterRequest struct { AcceptPrivacy bool `json:"accept_privacy" binding:"required,eq=true"` EmailNewsletter bool `json:"email_newsletter"` EmailContact bool `json:"email_contact"` + BirthMonth int `json:"birth_month" binding:"required,min=1,max=12"` + BirthYear int `json:"birth_year" binding:"required,min=1900,max=2025"` } type LoginRequest struct { @@ -144,6 +146,8 @@ func (h *AuthHandler) Register(c *gin.Context) { ID: userID, Handle: &req.Handle, DisplayName: &req.DisplayName, + BirthMonth: req.BirthMonth, + BirthYear: req.BirthYear, } if err := h.repo.CreateProfile(c.Request.Context(), profile); err != nil { log.Printf("[Auth] Failed to create profile for %s: %v. Rolling back user.", user.ID, err) @@ -232,6 +236,25 @@ func (h *AuthHandler) Login(c *gin.Context) { return } + // Age gate: check if user is under 16 + var profile *models.Profile + profile, _ = h.repo.GetProfileByID(c.Request.Context(), user.ID.String()) + if profile != nil && profile.BirthYear > 0 { + now := time.Now() + age := now.Year() - profile.BirthYear + if int(now.Month()) < profile.BirthMonth { + age-- + } + if age < 16 { + log.Printf("[Auth] Login blocked for underage user %s (age %d)", req.Email, age) + c.JSON(http.StatusForbidden, gin.H{ + "error": "You must be at least 16 years old to use Sojorn. Please come back when you're older!", + "code": "age_restricted", + }) + return + } + } + if user.Status == models.UserStatusPending { c.JSON(http.StatusUnauthorized, gin.H{"error": "Email verification required", "code": "verify_email"}) return @@ -270,10 +293,13 @@ func (h *AuthHandler) Login(c *gin.Context) { refreshToken, _ := generateRandomString(32) _ = h.repo.StoreRefreshToken(c.Request.Context(), user.ID.String(), refreshToken, 30*24*time.Hour) - profile, err := h.repo.GetProfileByID(c.Request.Context(), user.ID.String()) - if err != nil { - log.Printf("[Auth] Failed to get profile for %s: %v", user.ID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile", "details": err.Error()}) + // Re-fetch profile if not already loaded from age check + if profile == nil { + profile, _ = h.repo.GetProfileByID(c.Request.Context(), user.ID.String()) + } + if profile == nil { + log.Printf("[Auth] Failed to get profile for %s", user.ID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile"}) return } diff --git a/go-backend/internal/handlers/settings_handler.go b/go-backend/internal/handlers/settings_handler.go index 8ffc29c..f9e925b 100644 --- a/go-backend/internal/handlers/settings_handler.go +++ b/go-backend/internal/handlers/settings_handler.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -82,6 +83,25 @@ func (h *SettingsHandler) UpdateUserSettings(c *gin.Context) { } us.UserID = userID + // Block NSFW toggle for users under 18 + if us.NSFWEnabled != nil && *us.NSFWEnabled { + profile, err := h.userRepo.GetProfileByID(c.Request.Context(), userID.String()) + if err == nil && profile != nil && profile.BirthYear > 0 { + now := time.Now() + age := now.Year() - profile.BirthYear + if int(now.Month()) < profile.BirthMonth { + age-- + } + if age < 18 { + c.JSON(http.StatusForbidden, gin.H{ + "error": "You must be at least 18 years old to enable sensitive content. This is required by law in most jurisdictions.", + "code": "age_restricted_nsfw", + }) + return + } + } + } + if err := h.userRepo.UpdateUserSettings(c.Request.Context(), &us); err != nil { log.Error().Err(err).Msg("Failed to update user settings") c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings", "details": err.Error()}) diff --git a/go-backend/internal/models/user.go b/go-backend/internal/models/user.go index 14d095a..8770ca5 100644 --- a/go-backend/internal/models/user.go +++ b/go-backend/internal/models/user.go @@ -51,6 +51,8 @@ type Profile struct { EncryptedPrivateKey *string `json:"encrypted_private_key" db:"encrypted_private_key"` HasCompletedOnboarding bool `json:"has_completed_onboarding" db:"has_completed_onboarding"` Role string `json:"role" db:"role"` + BirthMonth int `json:"birth_month" db:"birth_month"` + BirthYear int `json:"birth_year" db:"birth_year"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go index f99a9e4..ebc2607 100644 --- a/go-backend/internal/repository/user_repository.go +++ b/go-backend/internal/repository/user_repository.go @@ -29,10 +29,10 @@ func (r *UserRepository) Pool() *pgxpool.Pool { func (r *UserRepository) CreateProfile(ctx context.Context, profile *models.Profile) error { query := ` - INSERT INTO public.profiles (id, handle, display_name, bio, origin_country) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO public.profiles (id, handle, display_name, bio, origin_country, birth_month, birth_year) + VALUES ($1, $2, $3, $4, $5, $6, $7) ` - _, err := r.pool.Exec(ctx, query, profile.ID, profile.Handle, profile.DisplayName, profile.Bio, profile.OriginCountry) + _, err := r.pool.Exec(ctx, query, profile.ID, profile.Handle, profile.DisplayName, profile.Bio, profile.OriginCountry, profile.BirthMonth, profile.BirthYear) if err != nil { return fmt.Errorf("failed to create profile: %w", err) } @@ -51,11 +51,11 @@ func (r *UserRepository) CreateProfile(ctx context.Context, profile *models.Prof } func (r *UserRepository) GetProfileByID(ctx context.Context, id string) (*models.Profile, error) { - query := `SELECT id, handle, display_name, bio, avatar_url, origin_country, has_completed_onboarding, is_official, is_private, role, created_at FROM public.profiles WHERE id = $1::uuid` + query := `SELECT id, handle, display_name, bio, avatar_url, origin_country, has_completed_onboarding, is_official, is_private, role, created_at, COALESCE(birth_month, 0), COALESCE(birth_year, 0) FROM public.profiles WHERE id = $1::uuid` var p models.Profile err := r.pool.QueryRow(ctx, query, id).Scan( - &p.ID, &p.Handle, &p.DisplayName, &p.Bio, &p.AvatarURL, &p.OriginCountry, &p.HasCompletedOnboarding, &p.IsOfficial, &p.IsPrivate, &p.Role, &p.CreatedAt, + &p.ID, &p.Handle, &p.DisplayName, &p.Bio, &p.AvatarURL, &p.OriginCountry, &p.HasCompletedOnboarding, &p.IsOfficial, &p.IsPrivate, &p.Role, &p.CreatedAt, &p.BirthMonth, &p.BirthYear, ) if err != nil { return nil, err @@ -66,7 +66,8 @@ func (r *UserRepository) GetProfileByID(ctx context.Context, id string) (*models func (r *UserRepository) GetProfileByHandle(ctx context.Context, handle string) (*models.Profile, error) { query := ` SELECT id, handle, display_name, bio, avatar_url, origin_country, - has_completed_onboarding, is_official, is_private, role, created_at + has_completed_onboarding, is_official, is_private, role, created_at, + COALESCE(birth_month, 0), COALESCE(birth_year, 0) FROM public.profiles WHERE handle = $1 ` @@ -74,6 +75,7 @@ func (r *UserRepository) GetProfileByHandle(ctx context.Context, handle string) err := r.pool.QueryRow(ctx, query, handle).Scan( &p.ID, &p.Handle, &p.DisplayName, &p.Bio, &p.AvatarURL, &p.OriginCountry, &p.HasCompletedOnboarding, &p.IsOfficial, &p.IsPrivate, &p.Role, &p.CreatedAt, + &p.BirthMonth, &p.BirthYear, ) if err != nil { return nil, err diff --git a/sojorn_app/lib/models/profile.dart b/sojorn_app/lib/models/profile.dart index 8625662..66b91ab 100644 --- a/sojorn_app/lib/models/profile.dart +++ b/sojorn_app/lib/models/profile.dart @@ -21,6 +21,8 @@ class Profile { final int? registrationId; final String? encryptedPrivateKey; final bool hasCompletedOnboarding; + final int birthMonth; + final int birthYear; Profile({ required this.id, @@ -42,6 +44,8 @@ class Profile { this.registrationId, this.encryptedPrivateKey, this.hasCompletedOnboarding = false, + this.birthMonth = 0, + this.birthYear = 0, }); factory Profile.fromJson(Map json) { @@ -81,6 +85,8 @@ class Profile { registrationId: json['registration_id'] as int?, encryptedPrivateKey: json['encrypted_private_key'] as String?, hasCompletedOnboarding: json['has_completed_onboarding'] as bool? ?? false, + birthMonth: json['birth_month'] as int? ?? 0, + birthYear: json['birth_year'] as int? ?? 0, ); } @@ -105,6 +111,8 @@ class Profile { 'registration_id': registrationId, 'encrypted_private_key': encryptedPrivateKey, 'has_completed_onboarding': hasCompletedOnboarding, + 'birth_month': birthMonth, + 'birth_year': birthYear, }; } @@ -128,6 +136,8 @@ class Profile { bool? isPrivate, bool? isOfficial, bool? hasCompletedOnboarding, + int? birthMonth, + int? birthYear, }) { return Profile( id: id ?? this.id, @@ -149,6 +159,8 @@ class Profile { registrationId: registrationId ?? this.registrationId, encryptedPrivateKey: encryptedPrivateKey ?? this.encryptedPrivateKey, hasCompletedOnboarding: hasCompletedOnboarding ?? this.hasCompletedOnboarding, + birthMonth: birthMonth ?? this.birthMonth, + birthYear: birthYear ?? this.birthYear, ); } } diff --git a/sojorn_app/lib/screens/auth/sign_up_screen.dart b/sojorn_app/lib/screens/auth/sign_up_screen.dart index 3e3c4fa..92a3e64 100644 --- a/sojorn_app/lib/screens/auth/sign_up_screen.dart +++ b/sojorn_app/lib/screens/auth/sign_up_screen.dart @@ -33,6 +33,10 @@ class _SignUpScreenState extends ConsumerState { // Email preferences (single combined option) bool _emailUpdates = false; + + // Age gate + int? _birthMonth; + int? _birthYear; // Turnstile site key from environment or default production key static const String _turnstileSiteKey = String.fromEnvironment( @@ -61,6 +65,14 @@ class _SignUpScreenState extends ConsumerState { return; } + // Validate age gate + if (_birthMonth == null || _birthYear == null) { + setState(() { + _errorMessage = 'Please enter your date of birth'; + }); + return; + } + // Validate legal consent if (!_acceptTerms) { setState(() { @@ -93,6 +105,8 @@ class _SignUpScreenState extends ConsumerState { acceptPrivacy: _acceptPrivacy, emailNewsletter: _emailUpdates, emailContact: _emailUpdates, + birthMonth: _birthMonth!, + birthYear: _birthYear!, ); if (mounted) { @@ -286,6 +300,63 @@ class _SignUpScreenState extends ConsumerState { ), const SizedBox(height: AppTheme.spacingLg), + // Age Gate - Birth Month & Year + Text( + 'Date of Birth', + style: AppTheme.bodyMedium.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + 'You must be at least 16 to use Sojorn. Users under 18 cannot access sensitive content.', + style: AppTheme.textTheme.labelSmall?.copyWith( + color: AppTheme.navyText.withOpacity(0.6), + ), + ), + const SizedBox(height: AppTheme.spacingSm), + Row( + children: [ + // Month dropdown + Expanded( + flex: 3, + child: DropdownButtonFormField( + value: _birthMonth, + decoration: const InputDecoration( + labelText: 'Month', + prefixIcon: Icon(Icons.calendar_month), + ), + items: List.generate(12, (i) { + final month = i + 1; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return DropdownMenuItem(value: month, child: Text(months[i])); + }), + onChanged: (v) => setState(() => _birthMonth = v), + validator: (v) => v == null ? 'Required' : null, + ), + ), + const SizedBox(width: AppTheme.spacingSm), + // Year dropdown + Expanded( + flex: 2, + child: DropdownButtonFormField( + value: _birthYear, + decoration: const InputDecoration( + labelText: 'Year', + ), + items: List.generate( + DateTime.now().year - 1900 + 1, + (i) { + final year = DateTime.now().year - i; + return DropdownMenuItem(value: year, child: Text('$year')); + }, + ), + onChanged: (v) => setState(() => _birthYear = v), + validator: (v) => v == null ? 'Required' : null, + ), + ), + ], + ), + const SizedBox(height: AppTheme.spacingLg), + // Turnstile CAPTCHA Container( decoration: BoxDecoration( diff --git a/sojorn_app/lib/screens/profile/profile_settings_screen.dart b/sojorn_app/lib/screens/profile/profile_settings_screen.dart index 7096a44..a92bfde 100644 --- a/sojorn_app/lib/screens/profile/profile_settings_screen.dart +++ b/sojorn_app/lib/screens/profile/profile_settings_screen.dart @@ -408,6 +408,16 @@ class _ProfileSettingsScreenState extends ConsumerState { final userSettings = state.user; if (userSettings == null) return const SizedBox.shrink(); + // Calculate age from profile + final profile = state.profile; + bool isUnder18 = false; + if (profile != null && profile.birthYear > 0) { + final now = DateTime.now(); + int age = now.year - profile.birthYear; + if (now.month < profile.birthMonth) age--; + isUnder18 = age < 18; + } + return Container( decoration: BoxDecoration( color: AppTheme.cardSurface, @@ -434,14 +444,18 @@ class _ProfileSettingsScreenState extends ConsumerState { SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Show Sensitive Content (NSFW)'), - subtitle: const Text( - 'Enable to see posts marked as sensitive (violence, mature themes, etc). Disabled by default.', + subtitle: Text( + isUnder18 + ? 'You must be at least 18 years old to enable this feature. This is required by law in most jurisdictions.' + : 'Enable to see posts marked as sensitive (violence, mature themes, etc). Disabled by default.', ), value: userSettings.nsfwEnabled, activeColor: Colors.amber.shade700, - onChanged: (v) => ref.read(settingsProvider.notifier).updateUser( - userSettings.copyWith(nsfwEnabled: v), - ), + onChanged: isUnder18 + ? null + : (v) => ref.read(settingsProvider.notifier).updateUser( + userSettings.copyWith(nsfwEnabled: v), + ), ), ], ), diff --git a/sojorn_app/lib/services/auth_service.dart b/sojorn_app/lib/services/auth_service.dart index 8f6887a..1c852bd 100644 --- a/sojorn_app/lib/services/auth_service.dart +++ b/sojorn_app/lib/services/auth_service.dart @@ -263,6 +263,8 @@ class AuthService { required bool acceptPrivacy, bool emailNewsletter = false, bool emailContact = false, + required int birthMonth, + required int birthYear, }) async { try { final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register'); @@ -279,6 +281,8 @@ class AuthService { 'accept_privacy': acceptPrivacy, 'email_newsletter': emailNewsletter, 'email_contact': emailContact, + 'birth_month': birthMonth, + 'birth_year': birthYear, }), );