Add flash alert UX improvements, User_Model fetch security, and SCSS-SCOPE-01 BEM guidance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-26 20:46:18 +00:00
parent fd7d3340f4
commit 0ea0341aeb
5 changed files with 229 additions and 5 deletions

View File

@@ -243,6 +243,23 @@ class ScssClassScope_CodeQualityRule extends CodeQualityRule_Abstract
} }
if ($matched_filename === null) { 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( $this->add_violation(
$file, $file,
1, 1,
@@ -345,6 +362,35 @@ class ScssClassScope_CodeQualityRule extends CodeQualityRule_Abstract
return implode("\n", $lines); 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 * Build suggestion for filename mismatch
*/ */

View File

@@ -466,4 +466,37 @@ class User_Model extends Rsx_Site_Model_Abstract
// Default: invitation is pending // Default: invitation is pending
return self::INVITATION_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;
}
} }

View File

@@ -14,6 +14,8 @@
* Features: * Features:
* - Queue system prevents alert spam (2.5s minimum between alerts) * - Queue system prevents alert spam (2.5s minimum between alerts)
* - Auto-dismiss after timeout (success: 4s, others: 6s) * - 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 * - Smooth fade in/out animations
* - Bootstrap alert styling with icons * - Bootstrap alert styling with icons
* - Persistent queue across page navigations (sessionStorage, tab-specific) * - 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); this._set_fadeout_start_time(message, level, new_fadeout_start_time);
} }
// Schedule fadeout // Track fadeout state on the wrapper element for hover pause/resume
if (time_until_fadeout >= 0) { $alert_container.data('fadeout_remaining', time_until_fadeout);
setTimeout(() => { $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); console.log('[Flash_Alert] Fadeout starting for:', message);
// Remove from persistence queue when fadeout starts // Remove from persistence queue when fadeout starts
this._remove_from_persistence_queue(message, level); this._remove_from_persistence_queue(message, level);
close_alert(1000); 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);
} }
} }
} }

View File

@@ -28,7 +28,7 @@
.alert { .alert {
margin-bottom: 0; margin-bottom: 0;
user-select: none; cursor: pointer;
// Icon spacing // Icon spacing
i { i {

View File

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