Add client-side Permission class and resolved_permissions to rsxapp

Refactor date/time classes to reduce code redundancy

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-01-13 08:35:06 +00:00
parent f70ca09f78
commit 8ef798c30f
11 changed files with 783 additions and 466 deletions

View File

@@ -285,6 +285,10 @@ abstract class Rsx_Bundle_Abstract
$rsxapp_data['site'] = Session::get_site(); $rsxapp_data['site'] = Session::get_site();
$rsxapp_data['csrf'] = Session::get_csrf_token(); $rsxapp_data['csrf'] = Session::get_csrf_token();
// Add resolved permissions for client-side permission checks
$user = Session::get_user();
$rsxapp_data['resolved_permissions'] = $user ? $user->get_resolved_permissions() : [];
// Add browser error logging flag (enabled in both dev and production) // Add browser error logging flag (enabled in both dev and production)
if (config('rsx.log_browser_errors', false)) { if (config('rsx.log_browser_errors', false)) {
$rsxapp_data['log_browser_errors'] = true; $rsxapp_data['log_browser_errors'] = true;

129
app/RSpade/Core/Js/Permission.js Executable file
View File

@@ -0,0 +1,129 @@
/**
* Permission - Client-side permission checking
*
* Provides permission and role checking for JavaScript using pre-resolved
* permissions from window.rsxapp.resolved_permissions. This mirrors the
* PHP Permission class functionality for client-side UI logic.
*
* The resolved_permissions array is computed server-side by applying:
* 1. Role default permissions
* 2. Supplementary GRANTs (added)
* 3. Supplementary DENYs (removed)
*
* This ensures JS permission checks match PHP exactly.
*
* Usage:
* // Check authentication
* if (Permission.is_logged_in()) { ... }
*
* // Check specific permission
* if (Permission.has_permission(User_Model.PERM_EDIT_DATA)) { ... }
*
* // Check multiple permissions
* if (Permission.has_any_permission([User_Model.PERM_EDIT_DATA, User_Model.PERM_VIEW_DATA])) { ... }
* if (Permission.has_all_permissions([User_Model.PERM_MANAGE_SITE_USERS, User_Model.PERM_VIEW_USER_ACTIVITY])) { ... }
*
* // Check role level
* if (Permission.has_role(User_Model.ROLE_MANAGER)) { ... }
*
* // Check if can admin a role (for UI showing role assignment options)
* if (Permission.can_admin_role(User_Model.ROLE_USER)) { ... }
*/
class Permission {
/**
* Check if user is logged in
*
* @returns {boolean}
*/
static is_logged_in() {
return window.rsxapp?.is_auth === true;
}
/**
* Get current user object or null
*
* @returns {Object|null}
*/
static get_user() {
return window.rsxapp?.user ?? null;
}
/**
* Check if current user has a specific permission
*
* @param {number} permission - Permission constant (e.g., User_Model.PERM_EDIT_DATA)
* @returns {boolean}
*/
static has_permission(permission) {
const permissions = window.rsxapp?.resolved_permissions ?? [];
return permissions.includes(permission);
}
/**
* Check if current user has ANY of the specified permissions
*
* @param {number[]} permissions - Array of permission constants
* @returns {boolean}
*/
static has_any_permission(permissions) {
const resolved = window.rsxapp?.resolved_permissions ?? [];
return permissions.some(p => resolved.includes(p));
}
/**
* Check if current user has ALL of the specified permissions
*
* @param {number[]} permissions - Array of permission constants
* @returns {boolean}
*/
static has_all_permissions(permissions) {
const resolved = window.rsxapp?.resolved_permissions ?? [];
return permissions.every(p => resolved.includes(p));
}
/**
* Check if current user has at least the specified role level
*
* "At least" means same or higher privilege (lower role_id number).
* Example: has_role(ROLE_MANAGER) returns true for Site Admins and above.
*
* @param {number} role_id - Role constant (e.g., User_Model.ROLE_MANAGER)
* @returns {boolean}
*/
static has_role(role_id) {
const user = window.rsxapp?.user;
if (!user) {
return false;
}
// Lower role_id = higher privilege
return user.role_id <= role_id;
}
/**
* Check if current user can administer users with the given role
*
* Prevents privilege escalation - users can only assign roles
* at or below their own permission level.
*
* @param {number} role_id - Role constant (e.g., User_Model.ROLE_USER)
* @returns {boolean}
*/
static can_admin_role(role_id) {
const user = window.rsxapp?.user;
if (!user) {
return false;
}
const can_admin = user.role_id__can_admin_roles ?? [];
return can_admin.includes(role_id);
}
/**
* Get all resolved permissions for current user
*
* @returns {number[]} Array of permission IDs
*/
static get_resolved_permissions() {
return window.rsxapp?.resolved_permissions ?? [];
}
}

View File

@@ -420,134 +420,77 @@ class Rsx_Date {
// COMPONENT EXTRACTORS // COMPONENT EXTRACTORS
// ========================================================================= // =========================================================================
/**
* Parse and convert to JS Date object (internal helper)
* Returns null if invalid, throws on datetime input
*/
static _to_js_date(date) {
const parsed = this.parse(date);
if (!parsed) return null;
const [year, month, day] = parsed.split('-').map(Number);
return new Date(year, month - 1, day);
}
/** /**
* Get day of month (1-31) * Get day of month (1-31)
*
* @param {*} date
* @returns {number|null}
*/ */
static day(date) { static day(date) {
const parsed = this.parse(date); const parsed = this.parse(date);
if (!parsed) { return parsed ? parseInt(parsed.split('-')[2], 10) : null;
return null;
}
return parseInt(parsed.split('-')[2], 10);
} }
/** /**
* Get day of week (0=Sunday, 6=Saturday) * Get day of week (0=Sunday, 6=Saturday)
*
* @param {*} date
* @returns {number|null}
*/ */
static dow(date) { static dow(date) {
const parsed = this.parse(date); return this._to_js_date(date)?.getDay() ?? null;
if (!parsed) {
return null;
}
const [year, month, day] = parsed.split('-').map(Number);
const d = new Date(year, month - 1, day);
return d.getDay();
} }
/** /**
* Get full day name ("Monday", "Tuesday", etc.) * Get full day name ("Monday", "Tuesday", etc.)
*
* @param {*} date
* @returns {string}
*/ */
static dow_human(date) { static dow_human(date) {
const parsed = this.parse(date); const d = this._to_js_date(date);
if (!parsed) { return d ? new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(d) : '';
return '';
}
const [year, month, day] = parsed.split('-').map(Number);
const d = new Date(year, month - 1, day);
return new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(d);
} }
/** /**
* Get short day name ("Mon", "Tue", etc.) * Get short day name ("Mon", "Tue", etc.)
*
* @param {*} date
* @returns {string}
*/ */
static dow_short(date) { static dow_short(date) {
const parsed = this.parse(date); const d = this._to_js_date(date);
if (!parsed) { return d ? new Intl.DateTimeFormat('en-US', { weekday: 'short' }).format(d) : '';
return '';
}
const [year, month, day] = parsed.split('-').map(Number);
const d = new Date(year, month - 1, day);
return new Intl.DateTimeFormat('en-US', { weekday: 'short' }).format(d);
} }
/** /**
* Get month (1-12) * Get month (1-12)
*
* @param {*} date
* @returns {number|null}
*/ */
static month(date) { static month(date) {
const parsed = this.parse(date); const parsed = this.parse(date);
if (!parsed) { return parsed ? parseInt(parsed.split('-')[1], 10) : null;
return null;
}
return parseInt(parsed.split('-')[1], 10);
} }
/** /**
* Get full month name ("January", "February", etc.) * Get full month name ("January", "February", etc.)
*
* @param {*} date
* @returns {string}
*/ */
static month_human(date) { static month_human(date) {
const parsed = this.parse(date); const d = this._to_js_date(date);
if (!parsed) { return d ? new Intl.DateTimeFormat('en-US', { month: 'long' }).format(d) : '';
return '';
}
const [year, month, day] = parsed.split('-').map(Number);
const d = new Date(year, month - 1, day);
return new Intl.DateTimeFormat('en-US', { month: 'long' }).format(d);
} }
/** /**
* Get short month name ("Jan", "Feb", etc.) * Get short month name ("Jan", "Feb", etc.)
*
* @param {*} date
* @returns {string}
*/ */
static month_human_short(date) { static month_human_short(date) {
const parsed = this.parse(date); const d = this._to_js_date(date);
if (!parsed) { return d ? new Intl.DateTimeFormat('en-US', { month: 'short' }).format(d) : '';
return '';
}
const [year, month, day] = parsed.split('-').map(Number);
const d = new Date(year, month - 1, day);
return new Intl.DateTimeFormat('en-US', { month: 'short' }).format(d);
} }
/** /**
* Get year (e.g., 2025) * Get year (e.g., 2025)
*
* @param {*} date
* @returns {number|null}
*/ */
static year(date) { static year(date) {
const parsed = this.parse(date); const parsed = this.parse(date);
if (!parsed) { return parsed ? parseInt(parsed.split('-')[0], 10) : null;
return null;
}
return parseInt(parsed.split('-')[0], 10);
} }
} }

View File

@@ -615,151 +615,89 @@ class Rsx_Time {
// ========================================================================= // =========================================================================
/** /**
* Get day of month (1-31) * Format component with validation (internal helper). Uses user's timezone.
* Uses user's timezone
*
* @param {*} time
* @returns {number|null}
*/ */
static day(time) { static _format_component(time, options) {
const date = this.parse(time); return this.parse(time) ? this.format_in_timezone(time, options) : null;
if (!date) return null;
return parseInt(this.format_in_timezone(time, { day: 'numeric' }), 10);
} }
/** /**
* Get day of week (0=Sunday, 6=Saturday) * Get day of month (1-31). Uses user's timezone.
* Uses user's timezone */
* static day(time) {
* @param {*} time const v = this._format_component(time, { day: 'numeric' });
* @returns {number|null} return v ? parseInt(v, 10) : null;
}
/**
* Get day of week (0=Sunday, 6=Saturday). Uses user's timezone.
*/ */
static dow(time) { static dow(time) {
const date = this.parse(time); const dayName = this._format_component(time, { weekday: 'short' });
if (!date) return null; if (!dayName) return null;
// Get the day name and map to number
const dayName = this.format_in_timezone(time, { weekday: 'short' });
const dayMap = { 'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6 }; const dayMap = { 'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6 };
return dayMap[dayName] ?? null; return dayMap[dayName] ?? null;
} }
/** /**
* Get full day name ("Monday", "Tuesday", etc.) * Get full day name ("Monday", "Tuesday", etc.). Uses user's timezone.
* Uses user's timezone
*
* @param {*} time
* @returns {string}
*/ */
static dow_human(time) { static dow_human(time) {
const date = this.parse(time); return this._format_component(time, { weekday: 'long' }) ?? '';
if (!date) return '';
return this.format_in_timezone(time, { weekday: 'long' });
} }
/** /**
* Get short day name ("Mon", "Tue", etc.) * Get short day name ("Mon", "Tue", etc.). Uses user's timezone.
* Uses user's timezone
*
* @param {*} time
* @returns {string}
*/ */
static dow_short(time) { static dow_short(time) {
const date = this.parse(time); return this._format_component(time, { weekday: 'short' }) ?? '';
if (!date) return '';
return this.format_in_timezone(time, { weekday: 'short' });
} }
/** /**
* Get month (1-12) * Get month (1-12). Uses user's timezone.
* Uses user's timezone
*
* @param {*} time
* @returns {number|null}
*/ */
static month(time) { static month(time) {
const date = this.parse(time); const v = this._format_component(time, { month: '2-digit' });
if (!date) return null; return v ? parseInt(v, 10) : null;
// Use 2-digit to get padded month, then parse
const monthStr = this.format_in_timezone(time, { month: '2-digit' });
return parseInt(monthStr, 10);
} }
/** /**
* Get full month name ("January", "February", etc.) * Get full month name ("January", "February", etc.). Uses user's timezone.
* Uses user's timezone
*
* @param {*} time
* @returns {string}
*/ */
static month_human(time) { static month_human(time) {
const date = this.parse(time); return this._format_component(time, { month: 'long' }) ?? '';
if (!date) return '';
return this.format_in_timezone(time, { month: 'long' });
} }
/** /**
* Get short month name ("Jan", "Feb", etc.) * Get short month name ("Jan", "Feb", etc.). Uses user's timezone.
* Uses user's timezone
*
* @param {*} time
* @returns {string}
*/ */
static month_human_short(time) { static month_human_short(time) {
const date = this.parse(time); return this._format_component(time, { month: 'short' }) ?? '';
if (!date) return '';
return this.format_in_timezone(time, { month: 'short' });
} }
/** /**
* Get year (e.g., 2025) * Get year (e.g., 2025). Uses user's timezone.
* Uses user's timezone
*
* @param {*} time
* @returns {number|null}
*/ */
static year(time) { static year(time) {
const date = this.parse(time); const v = this._format_component(time, { year: 'numeric' });
if (!date) return null; return v ? parseInt(v, 10) : null;
return parseInt(this.format_in_timezone(time, { year: 'numeric' }), 10);
} }
/** /**
* Get hour (0-23) * Get hour (0-23). Uses user's timezone.
* Uses user's timezone
*
* @param {*} time
* @returns {number|null}
*/ */
static hour(time) { static hour(time) {
const date = this.parse(time); const v = this._format_component(time, { hour: '2-digit', hour12: false });
if (!date) return null; if (!v) return null;
const hour = parseInt(v, 10);
// Use hour12: false and 2-digit for consistent 24-hour format return hour === 24 ? 0 : hour; // "24" returned for midnight in some locales
const hourStr = this.format_in_timezone(time, { hour: '2-digit', hour12: false });
// "24" is returned for midnight in some locales, treat as 0
const hour = parseInt(hourStr, 10);
return hour === 24 ? 0 : hour;
} }
/** /**
* Get minute (0-59) * Get minute (0-59). Uses user's timezone.
* Uses user's timezone
*
* @param {*} time
* @returns {number|null}
*/ */
static minute(time) { static minute(time) {
const date = this.parse(time); const v = this._format_component(time, { minute: '2-digit' });
if (!date) return null; return v ? parseInt(v, 10) : null;
return parseInt(this.format_in_timezone(time, { minute: '2-digit' }), 10);
} }
} }

View File

@@ -102,7 +102,7 @@ class User_Model extends Rsx_Site_Model_Abstract
use SoftDeletes; use SoftDeletes;
/** /**
* Cached supplementary permissions for this user * Cached supplementary permissions for this user (avoids repeated DB queries)
* @var array|null * @var array|null
*/ */
protected $_supplementary_permissions = null; protected $_supplementary_permissions = null;
@@ -238,38 +238,53 @@ class User_Model extends Rsx_Site_Model_Abstract
// ========================================================================= // =========================================================================
/** /**
* Check if user has a specific permission * Get all resolved permissions for this user
* *
* Resolution order: * Returns the final permission array after applying:
* 1. DISABLED role = deny all * 1. Role default permissions
* 2. Supplementary DENY = deny * 2. Supplementary GRANTs (added)
* 3. Supplementary GRANT = grant * 3. Supplementary DENYs (removed)
* 4. Role default permissions = grant if included *
* 5. Deny * @return array Array of permission IDs the user has
*/
public function get_resolved_permissions(): array
{
// Disabled users have no permissions
if ($this->role_id === self::ROLE_DISABLED) {
return [];
}
// Start with role default permissions
$permissions = $this->role_id__permissions ?? [];
// Load supplementary overrides (DB query is cached)
$supplementary = $this->_load_supplementary_permissions();
// Add supplementary GRANTs
foreach ($supplementary['grants'] as $perm_id) {
if (!in_array($perm_id, $permissions, true)) {
$permissions[] = $perm_id;
}
}
// Remove supplementary DENYs
$permissions = array_values(array_diff($permissions, $supplementary['denies']));
// Sort for consistent ordering
sort($permissions);
return $permissions;
}
/**
* Check if user has a specific permission
* *
* @param int $permission Permission constant (PERM_*) * @param int $permission Permission constant (PERM_*)
* @return bool * @return bool
*/ */
public function has_permission(int $permission): bool public function has_permission(int $permission): bool
{ {
// Disabled users have no permissions return in_array($permission, $this->get_resolved_permissions(), true);
if ($this->role_id === self::ROLE_DISABLED) {
return false;
}
// Check supplementary DENY (overrides everything)
if ($this->has_supplementary_deny($permission)) {
return false;
}
// Check supplementary GRANT
if ($this->has_supplementary_grant($permission)) {
return true;
}
// Check role default permissions
$role_permissions = $this->role_id_permissions ?? [];
return in_array($permission, $role_permissions, true);
} }
/** /**
@@ -360,9 +375,9 @@ class User_Model extends Rsx_Site_Model_Abstract
} }
/** /**
* Clear cached supplementary permissions (call after modifying) * Clear cached supplementary permissions (call after modifying user_permissions table)
*/ */
public function clear_supplementary_cache(): void public function clear_permission_cache(): void
{ {
$this->_supplementary_permissions = null; $this->_supplementary_permissions = null;
} }

View File

@@ -85,8 +85,21 @@ class Rsx_Date
return null; return null;
} }
// .expect file will document expected behaviors /**
// See Rsx_Date.php.expect for behavioral specifications * Parse and convert to Carbon object (internal helper)
* Returns null if invalid, throws on datetime input
*
* @param mixed $date
* @return Carbon|null
*/
private static function _to_carbon($date): ?Carbon
{
$parsed = static::parse($date);
if (!$parsed) {
return null;
}
return Carbon::createFromFormat('Y-m-d', $parsed);
}
/** /**
* Check if input is a valid date-only value (not datetime) * Check if input is a valid date-only value (not datetime)
@@ -402,135 +415,69 @@ class Rsx_Date
/** /**
* Get day of month (1-31) * Get day of month (1-31)
*
* @param mixed $date
* @return int|null
*/ */
public static function day($date): ?int public static function day($date): ?int
{ {
$parsed = static::parse($date); $parsed = static::parse($date);
if (!$parsed) { return $parsed ? (int) explode('-', $parsed)[2] : null;
return null;
}
return (int) explode('-', $parsed)[2];
} }
/** /**
* Get day of week (0=Sunday, 6=Saturday) * Get day of week (0=Sunday, 6=Saturday)
*
* @param mixed $date
* @return int|null
*/ */
public static function dow($date): ?int public static function dow($date): ?int
{ {
$parsed = static::parse($date); return static::_to_carbon($date)?->dayOfWeek;
if (!$parsed) {
return null;
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->dayOfWeek;
} }
/** /**
* Get full day name ("Monday", "Tuesday", etc.) * Get full day name ("Monday", "Tuesday", etc.)
*
* @param mixed $date
* @return string
*/ */
public static function dow_human($date): string public static function dow_human($date): string
{ {
$parsed = static::parse($date); return static::_to_carbon($date)?->format('l') ?? '';
if (!$parsed) {
return '';
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->format('l');
} }
/** /**
* Get short day name ("Mon", "Tue", etc.) * Get short day name ("Mon", "Tue", etc.)
*
* @param mixed $date
* @return string
*/ */
public static function dow_short($date): string public static function dow_short($date): string
{ {
$parsed = static::parse($date); return static::_to_carbon($date)?->format('D') ?? '';
if (!$parsed) {
return '';
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->format('D');
} }
/** /**
* Get month (1-12) * Get month (1-12)
*
* @param mixed $date
* @return int|null
*/ */
public static function month($date): ?int public static function month($date): ?int
{ {
$parsed = static::parse($date); $parsed = static::parse($date);
if (!$parsed) { return $parsed ? (int) explode('-', $parsed)[1] : null;
return null;
}
return (int) explode('-', $parsed)[1];
} }
/** /**
* Get full month name ("January", "February", etc.) * Get full month name ("January", "February", etc.)
*
* @param mixed $date
* @return string
*/ */
public static function month_human($date): string public static function month_human($date): string
{ {
$parsed = static::parse($date); return static::_to_carbon($date)?->format('F') ?? '';
if (!$parsed) {
return '';
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->format('F');
} }
/** /**
* Get short month name ("Jan", "Feb", etc.) * Get short month name ("Jan", "Feb", etc.)
*
* @param mixed $date
* @return string
*/ */
public static function month_human_short($date): string public static function month_human_short($date): string
{ {
$parsed = static::parse($date); return static::_to_carbon($date)?->format('M') ?? '';
if (!$parsed) {
return '';
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->format('M');
} }
/** /**
* Get year (e.g., 2025) * Get year (e.g., 2025)
*
* @param mixed $date
* @return int|null
*/ */
public static function year($date): ?int public static function year($date): ?int
{ {
$parsed = static::parse($date); $parsed = static::parse($date);
if (!$parsed) { return $parsed ? (int) explode('-', $parsed)[0] : null;
return null;
}
return (int) explode('-', $parsed)[0];
} }
// ========================================================================= // =========================================================================

View File

@@ -619,163 +619,93 @@ class Rsx_Time
// ========================================================================= // =========================================================================
/** /**
* Get day of month (1-31) * Parse and convert to user's timezone (internal helper)
* Uses user's timezone * Returns null if invalid, throws on date-only input
* */
* @param mixed $time private static function _to_user_carbon($time): ?Carbon
* @return int|null {
$carbon = static::parse($time);
return $carbon ? static::to_user_timezone($carbon) : null;
}
/**
* Get day of month (1-31). Uses user's timezone.
*/ */
public static function day($time): ?int public static function day($time): ?int
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->day;
if (!$carbon) {
return null;
}
return (int) static::to_user_timezone($carbon)->format('j');
} }
/** /**
* Get day of week (0=Sunday, 6=Saturday) * Get day of week (0=Sunday, 6=Saturday). Uses user's timezone.
* Uses user's timezone
*
* @param mixed $time
* @return int|null
*/ */
public static function dow($time): ?int public static function dow($time): ?int
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->dayOfWeek;
if (!$carbon) {
return null;
}
return static::to_user_timezone($carbon)->dayOfWeek;
} }
/** /**
* Get full day name ("Monday", "Tuesday", etc.) * Get full day name ("Monday", "Tuesday", etc.). Uses user's timezone.
* Uses user's timezone
*
* @param mixed $time
* @return string
*/ */
public static function dow_human($time): string public static function dow_human($time): string
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->format('l') ?? '';
if (!$carbon) {
return '';
}
return static::to_user_timezone($carbon)->format('l');
} }
/** /**
* Get short day name ("Mon", "Tue", etc.) * Get short day name ("Mon", "Tue", etc.). Uses user's timezone.
* Uses user's timezone
*
* @param mixed $time
* @return string
*/ */
public static function dow_short($time): string public static function dow_short($time): string
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->format('D') ?? '';
if (!$carbon) {
return '';
}
return static::to_user_timezone($carbon)->format('D');
} }
/** /**
* Get month (1-12) * Get month (1-12). Uses user's timezone.
* Uses user's timezone
*
* @param mixed $time
* @return int|null
*/ */
public static function month($time): ?int public static function month($time): ?int
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->month;
if (!$carbon) {
return null;
}
return (int) static::to_user_timezone($carbon)->format('n');
} }
/** /**
* Get full month name ("January", "February", etc.) * Get full month name ("January", "February", etc.). Uses user's timezone.
* Uses user's timezone
*
* @param mixed $time
* @return string
*/ */
public static function month_human($time): string public static function month_human($time): string
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->format('F') ?? '';
if (!$carbon) {
return '';
}
return static::to_user_timezone($carbon)->format('F');
} }
/** /**
* Get short month name ("Jan", "Feb", etc.) * Get short month name ("Jan", "Feb", etc.). Uses user's timezone.
* Uses user's timezone
*
* @param mixed $time
* @return string
*/ */
public static function month_human_short($time): string public static function month_human_short($time): string
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->format('M') ?? '';
if (!$carbon) {
return '';
}
return static::to_user_timezone($carbon)->format('M');
} }
/** /**
* Get year (e.g., 2025) * Get year (e.g., 2025). Uses user's timezone.
* Uses user's timezone
*
* @param mixed $time
* @return int|null
*/ */
public static function year($time): ?int public static function year($time): ?int
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->year;
if (!$carbon) {
return null;
}
return (int) static::to_user_timezone($carbon)->format('Y');
} }
/** /**
* Get hour (0-23) * Get hour (0-23). Uses user's timezone.
* Uses user's timezone
*
* @param mixed $time
* @return int|null
*/ */
public static function hour($time): ?int public static function hour($time): ?int
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->hour;
if (!$carbon) {
return null;
}
return (int) static::to_user_timezone($carbon)->format('G');
} }
/** /**
* Get minute (0-59) * Get minute (0-59). Uses user's timezone.
* Uses user's timezone
*
* @param mixed $time
* @return int|null
*/ */
public static function minute($time): ?int public static function minute($time): ?int
{ {
$carbon = static::parse($time); return static::_to_user_carbon($time)?->minute;
if (!$carbon) {
return null;
}
return (int) static::to_user_timezone($carbon)->format('i');
} }
// ========================================================================= // =========================================================================

View File

@@ -4,17 +4,19 @@ NAME
acls - Role-based access control with supplementary permissions acls - Role-based access control with supplementary permissions
SYNOPSIS SYNOPSIS
// Check if current user has a permission PHP (server-side):
Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS) Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)
Permission::has_role(User_Model::ROLE_SITE_ADMIN)
$user->has_permission(User_Model::PERM_EDIT_DATA)
$user->get_resolved_permissions() // All permissions as array
$user->can_admin_role($target_user->role_id)
// Check if current user has at least a certain role JavaScript (client-side):
Permission::has_role(User_Model::ROLE_SITE_ADMIN) Permission.has_permission(User_Model.PERM_EDIT_DATA)
Permission.has_any_permission([User_Model.PERM_EDIT_DATA, User_Model.PERM_VIEW_DATA])
// Check on specific user instance Permission.has_all_permissions([...])
$user->has_permission(User_Model::PERM_EDIT_DATA) Permission.has_role(User_Model.ROLE_MANAGER)
Permission.can_admin_role(User_Model.ROLE_USER)
// Check if user can administer another user's role
$user->can_admin_role($target_user->role_id)
DESCRIPTION DESCRIPTION
RSpade provides a role-based access control (RBAC) system where: RSpade provides a role-based access control (RBAC) system where:
@@ -72,19 +74,20 @@ ARCHITECTURE
Role Hierarchy Role Hierarchy
ID Constant Label Can Admin Roles ID Constant Label Can Admin Roles
-- -------- ----- --------------- --- -------- ----- ---------------
1 ROLE_ROOT_ADMIN Root Admin 2,3,4,5,6,7 100 ROLE_DEVELOPER Developer 200-800 (system only)
2 ROLE_SITE_OWNER Site Owner 3,4,5,6,7 200 ROLE_ROOT_ADMIN Root Admin 300-800 (system only)
3 ROLE_SITE_ADMIN Site Admin 4,5,6,7 300 ROLE_SITE_OWNER Site Owner 400-800
4 ROLE_MANAGER Manager 5,6,7 400 ROLE_SITE_ADMIN Site Admin 500-800
5 ROLE_USER User (none) 500 ROLE_MANAGER Manager 600-800
6 ROLE_VIEWER Viewer (none) 600 ROLE_USER User (none)
7 ROLE_DISABLED Disabled (none) 700 ROLE_VIEWER Viewer (none)
800 ROLE_DISABLED Disabled (none)
"Can Admin Roles" means a user with that role can create, edit, IDs are 100-based for future expansion. Lower ID = higher privilege.
or change the role of users with the listed role IDs. This "Can Admin Roles" prevents privilege escalation (Site Admin can't
prevents privilege escalation (admin can't create root admin). create Site Owner). Developer and Root Admin are system-assigned only.
PERMISSIONS PERMISSIONS
@@ -92,7 +95,7 @@ PERMISSIONS
ID Constant Granted By Default To ID Constant Granted By Default To
-- -------- --------------------- -- -------- ---------------------
1 PERM_MANAGE_SITES_ROOT Root Admin only 1 PERM_MANAGE_SITES_ROOT Developer, Root Admin only
2 PERM_MANAGE_SITE_BILLING Site Owner+ 2 PERM_MANAGE_SITE_BILLING Site Owner+
3 PERM_MANAGE_SITE_SETTINGS Site Admin+ 3 PERM_MANAGE_SITE_SETTINGS Site Admin+
4 PERM_MANAGE_SITE_USERS Site Admin+ 4 PERM_MANAGE_SITE_USERS Site Admin+
@@ -109,17 +112,17 @@ PERMISSIONS
Role-Permission Matrix Role-Permission Matrix
Permission Root Owner Admin Mgr User View Dis Permission Dev Root Owner Admin Mgr User View Dis
---------- ---- ----- ----- --- ---- ---- --- ---------- --- ---- ----- ----- --- ---- ---- ---
MANAGE_SITES_ROOT X MANAGE_SITES_ROOT X X
MANAGE_SITE_BILLING X X MANAGE_SITE_BILLING X X X
MANAGE_SITE_SETTINGS X X X MANAGE_SITE_SETTINGS X X X X
MANAGE_SITE_USERS X X X MANAGE_SITE_USERS X X X X
VIEW_USER_ACTIVITY X X X X VIEW_USER_ACTIVITY X X X X X
EDIT_DATA X X X X X EDIT_DATA X X X X X X
VIEW_DATA X X X X X X VIEW_DATA X X X X X X X
API_ACCESS - - - - - - API_ACCESS - - - - - - -
DATA_EXPORT - - - - - - DATA_EXPORT - - - - - - -
Legend: X = granted by role, - = must be granted individually Legend: X = granted by role, - = must be granted individually
@@ -129,14 +132,15 @@ MODEL IMPLEMENTATION
class User_Model extends Rsx_Model_Abstract class User_Model extends Rsx_Model_Abstract
{ {
// Role constants // Role constants (100-based, lower = higher privilege)
const ROLE_ROOT_ADMIN = 1; const ROLE_DEVELOPER = 100;
const ROLE_SITE_OWNER = 2; const ROLE_ROOT_ADMIN = 200;
const ROLE_SITE_ADMIN = 3; const ROLE_SITE_OWNER = 300;
const ROLE_MANAGER = 4; const ROLE_SITE_ADMIN = 400;
const ROLE_USER = 5; const ROLE_MANAGER = 500;
const ROLE_VIEWER = 6; const ROLE_USER = 600;
const ROLE_DISABLED = 7; const ROLE_VIEWER = 700;
const ROLE_DISABLED = 800;
// Permission constants // Permission constants
const PERM_MANAGE_SITES_ROOT = 1; const PERM_MANAGE_SITES_ROOT = 1;
@@ -151,55 +155,44 @@ MODEL IMPLEMENTATION
public static $enums = [ public static $enums = [
'role_id' => [ 'role_id' => [
self::ROLE_ROOT_ADMIN => [ 300 => [
'constant' => 'ROLE_ROOT_ADMIN', 'constant' => 'ROLE_SITE_OWNER',
'label' => 'Root Admin', 'label' => 'Site Owner',
'permissions' => [ 'permissions' => [2, 3, 4, 5, 6, 7],
self::PERM_MANAGE_SITES_ROOT, 'can_admin_roles' => [400, 500, 600, 700, 800],
self::PERM_MANAGE_SITE_BILLING,
self::PERM_MANAGE_SITE_SETTINGS,
self::PERM_MANAGE_SITE_USERS,
self::PERM_VIEW_USER_ACTIVITY,
self::PERM_EDIT_DATA,
self::PERM_VIEW_DATA,
],
'can_admin_roles' => [2,3,4,5,6,7],
], ],
// ... additional roles // ... additional roles
], ],
]; ];
public function has_permission(int $permission): bool // Get all resolved permissions (role + supplementary applied)
public function get_resolved_permissions(): array
{ {
if ($this->role_id === self::ROLE_DISABLED) { if ($this->role_id === self::ROLE_DISABLED) {
return false; return [];
} }
$permissions = $this->role_id__permissions ?? [];
// Add supplementary GRANTs, remove supplementary DENYs
// ... (see User_Model for full implementation)
return $permissions;
}
// Check supplementary DENY (overrides everything) public function has_permission(int $permission): bool
if ($this->has_supplementary_deny($permission)) { {
return false; return in_array($permission, $this->get_resolved_permissions(), true);
}
// Check supplementary GRANT
if ($this->has_supplementary_grant($permission)) {
return true;
}
// Check role default permissions
return in_array($permission, $this->role_permissions ?? []);
} }
public function can_admin_role(int $role_id): bool public function can_admin_role(int $role_id): bool
{ {
return in_array($role_id, $this->role_can_admin_roles ?? []); return in_array($role_id, $this->role_id__can_admin_roles ?? [], true);
} }
} }
Magic Properties (via enum system) Magic Properties (via enum system, BEM-style double underscore)
$user->role_label // "Site Admin" $user->role_id__label // "Site Admin"
$user->role_permissions // [3,4,5,6,7] $user->role_id__permissions // [3,4,5,6,7]
$user->role_can_admin_roles // [4,5,6,7] $user->role_id__can_admin_roles // [400,500,600,700,800]
PERMISSION CLASS API PERMISSION CLASS API
@@ -235,6 +228,10 @@ PERMISSION CLASS API
$user->has_permission(int $permission): bool $user->has_permission(int $permission): bool
Check if this specific user has permission. Check if this specific user has permission.
$user->get_resolved_permissions(): array
Get all resolved permission IDs for this user.
Applies role defaults + grants - denies.
$user->can_admin_role(int $role_id): bool $user->can_admin_role(int $role_id): bool
Check if user can create/edit users with given role. Check if user can create/edit users with given role.
@@ -244,6 +241,78 @@ PERMISSION CLASS API
$user->has_supplementary_deny(int $permission): bool $user->has_supplementary_deny(int $permission): bool
Check if user has explicit DENY for permission. Check if user has explicit DENY for permission.
JAVASCRIPT PERMISSION CLASS
The Permission class provides client-side permission checking using
pre-resolved permissions from window.rsxapp.resolved_permissions.
This array is computed server-side and includes role defaults with
supplementary grants added and denies removed, ensuring JS checks
match PHP exactly.
Static Methods
Permission.is_logged_in(): boolean
Check if user is authenticated.
if (Permission.is_logged_in()) {
// Show authenticated UI
}
Permission.get_user(): Object|null
Get current user object from rsxapp.
Permission.has_permission(permission): boolean
Check if user has specific permission.
if (Permission.has_permission(User_Model.PERM_EDIT_DATA)) {
// Show edit button
}
Permission.has_any_permission(permissions): boolean
Check if user has ANY of the listed permissions.
if (Permission.has_any_permission([
User_Model.PERM_EDIT_DATA,
User_Model.PERM_VIEW_DATA
])) {
// User can view or edit
}
Permission.has_all_permissions(permissions): boolean
Check if user has ALL of the listed permissions.
if (Permission.has_all_permissions([
User_Model.PERM_MANAGE_SITE_USERS,
User_Model.PERM_VIEW_USER_ACTIVITY
])) {
// User can manage users AND view activity
}
Permission.has_role(role_id): boolean
Check if user has at least the specified role level.
Lower role_id = higher privilege.
if (Permission.has_role(User_Model.ROLE_MANAGER)) {
// User is Manager or higher (Admin, Owner, etc.)
}
Permission.can_admin_role(role_id): boolean
Check if user can administer users with given role.
if (Permission.can_admin_role(User_Model.ROLE_USER)) {
// Show role assignment dropdown including User role
}
Permission.get_resolved_permissions(): number[]
Get array of all permission IDs the user has.
Data Source
The Permission class reads from window.rsxapp.resolved_permissions,
which is populated by the bundle renderer from the session user's
get_resolved_permissions() result. Empty array if not authenticated.
ROUTE PROTECTION ROUTE PROTECTION
Using #[Auth] Attribute Using #[Auth] Attribute
@@ -443,7 +512,7 @@ ADDING NEW PERMISSIONS
2. Add to role definitions in $enums if role should grant it: 2. Add to role definitions in $enums if role should grant it:
self::ROLE_SITE_ADMIN => [ 400 => [ // ROLE_SITE_ADMIN
'permissions' => [ 'permissions' => [
// ... existing // ... existing
self::PERM_NEW_FEATURE, self::PERM_NEW_FEATURE,
@@ -460,15 +529,13 @@ ADDING NEW PERMISSIONS
ADDING NEW ROLES ADDING NEW ROLES
1. Add constant (maintain hierarchy order): 1. Add constant (maintain hierarchy order, 100-based):
const ROLE_SUPERVISOR = 4; // Between Admin and Manager const ROLE_SUPERVISOR = 450; // Between Admin (400) and Manager (500)
const ROLE_MANAGER = 5; // Renumber if needed
// ...
2. Add to $enums with permissions and can_admin_roles: 2. Add to $enums with permissions and can_admin_roles:
self::ROLE_SUPERVISOR => [ 450 => [
'constant' => 'ROLE_SUPERVISOR', 'constant' => 'ROLE_SUPERVISOR',
'label' => 'Supervisor', 'label' => 'Supervisor',
'permissions' => [ 'permissions' => [
@@ -476,13 +543,13 @@ ADDING NEW ROLES
self::PERM_EDIT_DATA, self::PERM_EDIT_DATA,
self::PERM_VIEW_DATA, self::PERM_VIEW_DATA,
], ],
'can_admin_roles' => [5,6,7], 'can_admin_roles' => [500, 600, 700, 800],
], ],
3. Update can_admin_roles for roles above: 3. Update can_admin_roles for roles above:
self::ROLE_SITE_ADMIN => [ 400 => [ // ROLE_SITE_ADMIN
'can_admin_roles' => [4,5,6,7], // Add new role ID 'can_admin_roles' => [450, 500, 600, 700, 800], // Add new role ID
], ],
4. Run migration if role_id column needs updating 4. Run migration if role_id column needs updating
@@ -594,5 +661,6 @@ SEE ALSO
enums - Enum system for role/permission metadata enums - Enum system for role/permission metadata
routing - Route protection with #[Auth] attribute routing - Route protection with #[Auth] attribute
session - Session management and user context session - Session management and user context
rsxapp - Global JS object containing resolved_permissions
RSpade 1.0 November 2024 ACLS(7) RSpade 1.0 January 2026 ACLS(7)

108
app/RSpade/man/pagedata.txt Executable file
View File

@@ -0,0 +1,108 @@
PAGEDATA(3) RSX Framework Manual PAGEDATA(3)
NAME
PageData - Pass server-side data to JavaScript via window.rsxapp.page_data
SYNOPSIS
PHP Controller:
PageData::add(['key' => $value, 'another' => $data]);
Blade Directive:
@rsx_page_data(['key' => $value])
JavaScript Access:
const value = window.rsxapp.page_data.key;
DESCRIPTION
PageData provides a simple mechanism for passing server-side data to
JavaScript. Data added via PageData::add() or @rsx_page_data is
accumulated during request processing and automatically included in
window.rsxapp.page_data when the bundle renders.
This is useful for:
- Passing IDs needed by JavaScript components
- Pre-loading configuration for client-side logic
- Sharing computed values without additional Ajax calls
USAGE IN BLADE ROUTES
For traditional Blade views, use the @rsx_page_data directive:
{{-- In your Blade view --}}
@rsx_page_data(['user_id' => $user->id, 'can_edit' => $can_edit])
<div id="user-profile">
...
</div>
Or call PageData::add() in the controller before returning the view:
use App\RSpade\Core\View\PageData;
#[Route('/users/:id')]
public static function view(Request $request, array $params = [])
{
$user = User_Model::findOrFail($params['id']);
PageData::add([
'user_id' => $user->id,
'permissions' => $user->get_permissions(),
]);
return rsx_view('User_View', ['user' => $user]);
}
USAGE IN SPA CONTROLLERS
For SPA entry points, call PageData::add() before returning rsx_view(SPA):
use App\RSpade\Core\View\PageData;
#[SPA]
public static function index(Request $request, array $params = [])
{
// Load data needed by SPA actions
$internal_contact = Contact_Model::where('type_id', Contact_Model::TYPE_INTERNAL)
->first();
PageData::add([
'contact_internal_id' => $internal_contact?->id,
]);
return rsx_view(SPA, ['bundle' => 'Frontend_Bundle']);
}
The data is then available in any SPA action or component:
class Sidebar_Component {
on_ready() {
const internal_id = window.rsxapp.page_data.contact_internal_id;
if (internal_id) {
this.$sid('internal_link').attr('href', Rsx.Route('Contact_View_Action', internal_id));
}
}
}
MULTIPLE CALLS
PageData::add() merges data, so you can call it multiple times:
PageData::add(['user_id' => $user->id]);
PageData::add(['site_config' => $config]); // Merged with previous
Later calls overwrite earlier keys with the same name.
API
PageData::add(array $data)
Add key-value pairs to page_data. Merged with existing data.
PageData::get()
Returns all accumulated page data (used internally by bundle renderer).
PageData::has_data()
Returns true if any page data has been set (used internally).
SEE ALSO
rsxapp(3), spa(3), bundle_api(3)
AUTHOR
RSpade Framework
RSpade January 2026 PAGEDATA(3)

207
app/RSpade/man/rsxapp.txt Executable file
View File

@@ -0,0 +1,207 @@
RSXAPP(3) RSX Framework Manual RSXAPP(3)
NAME
rsxapp - Global JavaScript object containing runtime configuration and data
SYNOPSIS
JavaScript:
window.rsxapp.build_key // Manifest build hash
window.rsxapp.user // Current user model data
window.rsxapp.site // Current site model data
window.rsxapp.resolved_permissions // Pre-computed user permissions
window.rsxapp.page_data // Custom page-specific data
window.rsxapp.is_spa // Whether current page is SPA
window.rsxapp.csrf // CSRF token for forms
DESCRIPTION
window.rsxapp is a global JavaScript object rendered with every page bundle.
It contains session data, configuration, and runtime state needed by
client-side JavaScript. The object is built during bundle rendering in
Rsx_Bundle_Abstract::__generate_html() and output as an inline <script> tag
before bundle assets load.
The rsxapp object provides:
- Session context (user, site, authentication state)
- Build information for cache management
- Server time for client-server synchronization
- Custom page data passed via PageData::add()
- Debug configuration in development mode
HOW IT WORKS
When a bundle renders (e.g., Frontend_Bundle::render()), the framework:
1. Collects runtime data from various sources
2. Merges custom page_data from PageData::add() calls
3. JSON-encodes the data
4. Outputs: <script>window.rsxapp = {...};</script>
5. Bundle script tags follow, using rsxapp for initialization
Framework core classes (Rsx_Time, Rsx_Storage, Ajax) read from rsxapp
during their initialization, before application code runs.
OBJECT STRUCTURE
Core Properties (always present):
build_key String. Manifest hash for cache-busting.
Changes when any source file changes.
session_hash String. Hashed session token for storage scoping.
Non-reversible hash of rsx cookie value.
debug Boolean. True in non-production environments.
current_controller String. PHP controller handling this request.
current_action String. Controller method name.
is_auth Boolean. True if user is logged in.
is_spa Boolean. True if page is SPA bootstrap.
params Object. URL parameters from route (e.g., {id: "4"}).
csrf String. CSRF token for form submissions.
Session Data (when authenticated):
user Object. Current user model with all fields.
Includes enum properties (role_id__label, etc.)
and __MODEL marker.
site Object. Current site model.
resolved_permissions Array. Pre-computed permission IDs for current user.
Includes role defaults plus supplementary grants,
minus supplementary denies. Empty array if not
authenticated. Use Permission.has_permission() to check.
Time Synchronization:
server_time String. ISO 8601 UTC timestamp from server.
Used by Rsx_Time to correct client clock skew.
user_timezone String. IANA timezone (e.g., "America/Chicago").
Resolved: user preference > site > config > default.
Custom Data:
page_data Object. Data added via PageData::add().
Only present if data was added.
Debug Mode Only:
console_debug Object. Console debug configuration.
Controls console_debug() output filtering.
ajax_disable_batching Boolean. When true, Ajax calls bypass batching.
Optional:
flash_alerts Array. Pending flash messages to display.
Consumed by Server_Side_Flash component.
log_browser_errors Boolean. When true, JS errors logged to server.
EXAMPLE OUTPUT
Typical rsxapp object for authenticated SPA page:
window.rsxapp = {
"build_key": "72d8554b3a6a4382d9130707caff4009",
"session_hash": "9b8cdc5ebf5a3db1e88d400bafe1af06...",
"debug": true,
"current_controller": "Frontend_Spa_Controller",
"current_action": "index",
"is_auth": true,
"is_spa": true,
"params": {"id": "4"},
"user": {
"id": 1,
"first_name": "Test",
"last_name": "User",
"email": "test@example.com",
"role_id": 300,
"role_id__label": "Site Owner",
"__MODEL": "User_Model"
},
"site": {
"id": 1,
"name": "Test Site",
"timezone": "America/Chicago",
"__MODEL": "Site_Model"
},
"resolved_permissions": [2, 3, 4, 5, 6, 7],
"csrf": "f290180b609f8f353c3226accdc798961...",
"page_data": {
"contact_internal_id": 17
},
"server_time": "2026-01-13T07:48:11.482Z",
"user_timezone": "America/Chicago"
};
COMMON USAGE PATTERNS
Check authentication:
if (window.rsxapp.is_auth) {
// User is logged in
}
Access current user:
const user_name = window.rsxapp.user.first_name;
const is_admin = window.rsxapp.user.role_id === User_Model.ROLE_SITE_OWNER;
Check permissions (use Permission class):
if (Permission.has_permission(User_Model.PERM_EDIT_DATA)) {
// User can edit data
}
if (Permission.has_any_permission([User_Model.PERM_EDIT_DATA, User_Model.PERM_VIEW_DATA])) {
// User can edit or view data
}
Read page data:
const contact_id = window.rsxapp.page_data?.contact_internal_id;
Get CSRF token for forms:
$('form').append(`<input type="hidden" name="_token" value="${window.rsxapp.csrf}">`);
Check if SPA mode:
if (window.rsxapp.is_spa) {
// Use client-side navigation
Spa.dispatch('/new-route');
}
ADDING CUSTOM DATA
Use PageData::add() in PHP to add custom data to page_data:
PageData::add([
'feature_flags' => ['new_ui', 'beta_feature'],
'config' => $site_config,
]);
Access in JavaScript:
if (window.rsxapp.page_data.feature_flags.includes('new_ui')) {
// Enable new UI
}
See pagedata(3) for detailed usage.
UNDERSCORE KEY FILTERING
Keys starting with single underscore (e.g., _internal) are automatically
filtered out before JSON encoding. Keys with double underscore (e.g., __MODEL)
are preserved. This allows models to have internal properties that don't
leak to JavaScript.
FRAMEWORK CONSUMERS
These framework classes read from rsxapp during initialization:
Rsx_Time Reads server_time and user_timezone for clock sync
Rsx_Storage Reads session_hash, user.id, site.id, build_key for scoping
Ajax Reads csrf for request headers
Spa Reads is_spa, params for routing
Permission Reads resolved_permissions for access control checks
Debugger Reads console_debug for output filtering
SEE ALSO
pagedata(3), bundle_api(3), time(3), storage(3), spa(3), acls(3)
AUTHOR
RSpade Framework
RSpade January 2026 RSXAPP(3)

View File

@@ -158,6 +158,32 @@ BOOTSTRAP CONTROLLER
- One per feature/bundle - One per feature/bundle
- Naming: {Feature}_Spa_Controller::index - Naming: {Feature}_Spa_Controller::index
Passing Page Data:
Use PageData::add() to pass server-side data to JavaScript actions:
use App\RSpade\Core\View\PageData;
#[SPA]
public static function index(Request $request, array $params = [])
{
// Load data needed by SPA actions/components
$internal_contact = Contact_Model::where('type_id', Contact_Model::TYPE_INTERNAL)
->first();
PageData::add([
'contact_internal_id' => $internal_contact?->id,
'feature_flags' => config('rsx.features'),
]);
return rsx_view(SPA, ['bundle' => 'Frontend_Bundle']);
}
Access in JavaScript via window.rsxapp.page_data:
const internal_id = window.rsxapp.page_data.contact_internal_id;
See pagedata(3) for detailed usage.
Multiple SPA Bootstraps: Multiple SPA Bootstraps:
Different features can have separate SPA bootstraps: Different features can have separate SPA bootstraps:
- /app/frontend/Frontend_Spa_Controller::index (regular users) - /app/frontend/Frontend_Spa_Controller::index (regular users)
@@ -899,4 +925,6 @@ SEE ALSO
routing(3) - URL generation and route patterns routing(3) - URL generation and route patterns
modals(3) - Modal dialogs in SPA context modals(3) - Modal dialogs in SPA context
ajax_error_handling(3) - Error handling patterns ajax_error_handling(3) - Error handling patterns
pagedata(3) - Passing server-side data to JavaScript
rsxapp(3) - Global JavaScript runtime object
scss(3) - SCSS scoping conventions and component-first philosophy scss(3) - SCSS scoping conventions and component-first philosophy