diff --git a/app/RSpade/CodeQuality/Rules/Manifest/ScssClassScope_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/Manifest/ScssClassScope_CodeQualityRule.php index 23960af4d..6b15f7b82 100644 --- a/app/RSpade/CodeQuality/Rules/Manifest/ScssClassScope_CodeQualityRule.php +++ b/app/RSpade/CodeQuality/Rules/Manifest/ScssClassScope_CodeQualityRule.php @@ -243,6 +243,23 @@ class ScssClassScope_CodeQualityRule extends CodeQualityRule_Abstract } if ($matched_filename === null) { + // Check if this is a BEM child class (contains __) where the parent is a known component + if (strpos($wrapper_class, '__') !== false) { + [$parent_class, $child_suffix] = explode('__', $wrapper_class, 2); + if (isset($components[$parent_class]) || isset($blade_ids[$parent_class])) { + // This is a BEM child class - provide specialized guidance + $this->add_violation( + $file, + 1, + "BEM child class '.{$wrapper_class}' must be nested within parent component block", + ".{$wrapper_class} { ... }", + $this->build_bem_child_suggestion($parent_class, $child_suffix), + 'critical' + ); + return false; + } + } + $this->add_violation( $file, 1, @@ -345,6 +362,35 @@ class ScssClassScope_CodeQualityRule extends CodeQualityRule_Abstract return implode("\n", $lines); } + /** + * Build suggestion for BEM child class at top level + */ + private function build_bem_child_suggestion(string $parent_class, string $child_suffix): string + { + $lines = []; + $lines[] = "BEM child classes must be nested within their parent component block,"; + $lines[] = "not declared at the top level of the SCSS file."; + $lines[] = ""; + $lines[] = "INSTEAD OF:"; + $lines[] = " .{$parent_class}__{$child_suffix} {"; + $lines[] = " // styles"; + $lines[] = " }"; + $lines[] = ""; + $lines[] = "USE:"; + $lines[] = " .{$parent_class} {"; + $lines[] = " &__{$child_suffix} {"; + $lines[] = " // styles"; + $lines[] = " }"; + $lines[] = " }"; + $lines[] = ""; + $lines[] = "This compiles to the same CSS (.{$parent_class}__{$child_suffix})"; + $lines[] = "but maintains proper component scoping in the source files."; + $lines[] = ""; + $lines[] = "See: php artisan rsx:man scss"; + + return implode("\n", $lines); + } + /** * Build suggestion for filename mismatch */ diff --git a/app/RSpade/Core/Models/User_Model.php b/app/RSpade/Core/Models/User_Model.php index aa338ae8f..45092f390 100644 --- a/app/RSpade/Core/Models/User_Model.php +++ b/app/RSpade/Core/Models/User_Model.php @@ -466,4 +466,37 @@ class User_Model extends Rsx_Site_Model_Abstract // Default: invitation is pending return self::INVITATION_PENDING; } + + // ========================================================================= + // AJAX FETCH + // ========================================================================= + + /** + * Ajax model fetch - allows JavaScript to load user records + * Filters out invite_* fields for security + */ + #[Ajax_Endpoint_Model_Fetch] + public static function fetch($id) + { + $user = static::withTrashed()->find($id); + + if (!$user) { + return false; + } + + $data = $user->toArray(); + + // Filter out invite_* fields - these contain sensitive invitation data + foreach (array_keys($data) as $key) { + if (str_starts_with($key, 'invite_')) { + unset($data[$key]); + } + } + + // Augment with computed properties + $data['get_full_name'] = $user->get_full_name(); + $data['get_display_name'] = $user->get_display_name(); + + return $data; + } } \ No newline at end of file diff --git a/app/RSpade/Lib/Flash/Flash_Alert.js b/app/RSpade/Lib/Flash/Flash_Alert.js index 08c4a5bca..97b2c0f29 100755 --- a/app/RSpade/Lib/Flash/Flash_Alert.js +++ b/app/RSpade/Lib/Flash/Flash_Alert.js @@ -14,6 +14,8 @@ * Features: * - Queue system prevents alert spam (2.5s minimum between alerts) * - Auto-dismiss after timeout (success: 4s, others: 6s) + * - Hover to pause: Hovering an alert pauses the fadeout timer, resumes on mouse leave + * - Text selection: Alert text can be selected and copied * - Smooth fade in/out animations * - Bootstrap alert styling with icons * - Persistent queue across page navigations (sessionStorage, tab-specific) @@ -564,14 +566,57 @@ class Flash_Alert { this._set_fadeout_start_time(message, level, new_fadeout_start_time); } - // Schedule fadeout - if (time_until_fadeout >= 0) { - setTimeout(() => { + // Track fadeout state on the wrapper element for hover pause/resume + $alert_container.data('fadeout_remaining', time_until_fadeout); + $alert_container.data('fadeout_paused', false); + + // Schedule fadeout function (reusable for resume) + const schedule_fadeout = (delay) => { + const timeout_id = setTimeout(() => { + // Don't fade if paused (safety check) + if ($alert_container.data('fadeout_paused')) return; + console.log('[Flash_Alert] Fadeout starting for:', message); // Remove from persistence queue when fadeout starts this._remove_from_persistence_queue(message, level); close_alert(1000); - }, time_until_fadeout); + }, delay); + $alert_container.data('fadeout_timeout_id', timeout_id); + $alert_container.data('fadeout_scheduled_at', Date.now()); + }; + + // Pause fadeout on hover + $alert_container.on('mouseenter', () => { + if ($alert_container.data('fadeout_paused')) return; + + const timeout_id = $alert_container.data('fadeout_timeout_id'); + const scheduled_at = $alert_container.data('fadeout_scheduled_at'); + const remaining = $alert_container.data('fadeout_remaining'); + + if (timeout_id) { + clearTimeout(timeout_id); + // Calculate how much time was remaining when paused + const elapsed = Date.now() - scheduled_at; + const new_remaining = Math.max(0, remaining - elapsed); + $alert_container.data('fadeout_remaining', new_remaining); + $alert_container.data('fadeout_paused', true); + console.log('[Flash_Alert] Paused fadeout on hover:', { message, remaining: new_remaining }); + } + }); + + // Resume fadeout on mouse leave + $alert_container.on('mouseleave', () => { + if (!$alert_container.data('fadeout_paused')) return; + + const remaining = $alert_container.data('fadeout_remaining'); + $alert_container.data('fadeout_paused', false); + console.log('[Flash_Alert] Resuming fadeout:', { message, remaining }); + schedule_fadeout(remaining); + }); + + // Schedule initial fadeout + if (time_until_fadeout >= 0) { + schedule_fadeout(time_until_fadeout); } } } diff --git a/app/RSpade/Lib/Flash/Flash_Alert.scss b/app/RSpade/Lib/Flash/Flash_Alert.scss index 058521d90..ae936b7c3 100755 --- a/app/RSpade/Lib/Flash/Flash_Alert.scss +++ b/app/RSpade/Lib/Flash/Flash_Alert.scss @@ -28,7 +28,7 @@ .alert { margin-bottom: 0; - user-select: none; + cursor: pointer; // Icon spacing i { diff --git a/app/RSpade/upstream_changes/user_model_invite_filter_12_26.txt b/app/RSpade/upstream_changes/user_model_invite_filter_12_26.txt new file mode 100755 index 000000000..257ac435d --- /dev/null +++ b/app/RSpade/upstream_changes/user_model_invite_filter_12_26.txt @@ -0,0 +1,100 @@ +USER_MODEL INVITE FIELD FILTERING - MIGRATION GUIDE +Date: 2025-12-26 + +SUMMARY + The framework User_Model now filters out all invite_* fields in its fetch() + method for security. Invitation data (invite_code, invite_accepted_at, + invite_expires_at) should never be exposed to the client via Ajax fetch. + + If your application overrides User_Model or has a custom user model that + extends it, you must apply the same filtering to your fetch() method. + +AFFECTED FILES + Any custom User_Model implementations or overrides: + - /rsx/models/user_model.php (if exists) + - Any model that stores invitation data with invite_* prefixed fields + +CHANGES REQUIRED + + 1. Add invite_* Field Filtering to Custom User Models + + If you have a custom fetch() method in a User_Model override, add the + invite_* field filtering after toArray(): + + BEFORE: + #[Ajax_Endpoint_Model_Fetch] + public static function fetch($id) + { + $user = static::withTrashed()->find($id); + if (!$user) { + return false; + } + + $data = $user->toArray(); + + // ... your customizations + + return $data; + } + + AFTER: + #[Ajax_Endpoint_Model_Fetch] + public static function fetch($id) + { + $user = static::withTrashed()->find($id); + if (!$user) { + return false; + } + + $data = $user->toArray(); + + // Filter out invite_* fields - these contain sensitive invitation data + foreach (array_keys($data) as $key) { + if (str_starts_with($key, 'invite_')) { + unset($data[$key]); + } + } + + // ... your customizations + + return $data; + } + + 2. Check Other Models with Invitation Fields + + If you have other models that store invitation data with invite_* prefix, + apply the same pattern: + + // Filter out invite_* fields - sensitive invitation data + foreach (array_keys($data) as $key) { + if (str_starts_with($key, 'invite_')) { + unset($data[$key]); + } + } + +SECURITY RATIONALE + + Invitation fields contain sensitive data that should never reach the client: + + - invite_code: The secret code used to accept an invitation. Exposing this + allows unauthorized invitation acceptance. + + - invite_expires_at: While less sensitive, combined with other data could + inform timing attacks. + + - invite_accepted_at: User activity metadata that may have privacy + implications. + +VERIFICATION + + 1. Test that User_Model.fetch() does not return invite_* fields: + + php artisan rsx:ajax User_Model fetch --args='{"id":1}' + + The response should NOT contain invite_code, invite_accepted_at, or + invite_expires_at. + + 2. If you have a custom override, verify the same for your model. + +REFERENCE + Framework User_Model: system/app/RSpade/Core/Models/User_Model.php