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:
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
user-select: none;
|
cursor: pointer;
|
||||||
|
|
||||||
// Icon spacing
|
// Icon spacing
|
||||||
i {
|
i {
|
||||||
|
|||||||
100
app/RSpade/upstream_changes/user_model_invite_filter_12_26.txt
Executable file
100
app/RSpade/upstream_changes/user_model_invite_filter_12_26.txt
Executable 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
|
||||||
Reference in New Issue
Block a user