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['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)
if (config('rsx.log_browser_errors', false)) {
$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
// =========================================================================
/**
* 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)
*
* @param {*} date
* @returns {number|null}
*/
static day(date) {
const parsed = this.parse(date);
if (!parsed) {
return null;
}
return parseInt(parsed.split('-')[2], 10);
return parsed ? parseInt(parsed.split('-')[2], 10) : null;
}
/**
* Get day of week (0=Sunday, 6=Saturday)
*
* @param {*} date
* @returns {number|null}
*/
static dow(date) {
const parsed = this.parse(date);
if (!parsed) {
return null;
}
const [year, month, day] = parsed.split('-').map(Number);
const d = new Date(year, month - 1, day);
return d.getDay();
return this._to_js_date(date)?.getDay() ?? null;
}
/**
* Get full day name ("Monday", "Tuesday", etc.)
*
* @param {*} date
* @returns {string}
*/
static dow_human(date) {
const parsed = this.parse(date);
if (!parsed) {
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);
const d = this._to_js_date(date);
return d ? new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(d) : '';
}
/**
* Get short day name ("Mon", "Tue", etc.)
*
* @param {*} date
* @returns {string}
*/
static dow_short(date) {
const parsed = this.parse(date);
if (!parsed) {
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);
const d = this._to_js_date(date);
return d ? new Intl.DateTimeFormat('en-US', { weekday: 'short' }).format(d) : '';
}
/**
* Get month (1-12)
*
* @param {*} date
* @returns {number|null}
*/
static month(date) {
const parsed = this.parse(date);
if (!parsed) {
return null;
}
return parseInt(parsed.split('-')[1], 10);
return parsed ? parseInt(parsed.split('-')[1], 10) : null;
}
/**
* Get full month name ("January", "February", etc.)
*
* @param {*} date
* @returns {string}
*/
static month_human(date) {
const parsed = this.parse(date);
if (!parsed) {
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);
const d = this._to_js_date(date);
return d ? new Intl.DateTimeFormat('en-US', { month: 'long' }).format(d) : '';
}
/**
* Get short month name ("Jan", "Feb", etc.)
*
* @param {*} date
* @returns {string}
*/
static month_human_short(date) {
const parsed = this.parse(date);
if (!parsed) {
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);
const d = this._to_js_date(date);
return d ? new Intl.DateTimeFormat('en-US', { month: 'short' }).format(d) : '';
}
/**
* Get year (e.g., 2025)
*
* @param {*} date
* @returns {number|null}
*/
static year(date) {
const parsed = this.parse(date);
if (!parsed) {
return null;
}
return parseInt(parsed.split('-')[0], 10);
return parsed ? parseInt(parsed.split('-')[0], 10) : null;
}
}

View File

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

View File

@@ -102,7 +102,7 @@ class User_Model extends Rsx_Site_Model_Abstract
use SoftDeletes;
/**
* Cached supplementary permissions for this user
* Cached supplementary permissions for this user (avoids repeated DB queries)
* @var array|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:
* 1. DISABLED role = deny all
* 2. Supplementary DENY = deny
* 3. Supplementary GRANT = grant
* 4. Role default permissions = grant if included
* 5. Deny
* Returns the final permission array after applying:
* 1. Role default permissions
* 2. Supplementary GRANTs (added)
* 3. Supplementary DENYs (removed)
*
* @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_*)
* @return bool
*/
public function has_permission(int $permission): bool
{
// Disabled users have no permissions
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);
return in_array($permission, $this->get_resolved_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;
}

View File

@@ -85,8 +85,21 @@ class Rsx_Date
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)
@@ -402,135 +415,69 @@ class Rsx_Date
/**
* Get day of month (1-31)
*
* @param mixed $date
* @return int|null
*/
public static function day($date): ?int
{
$parsed = static::parse($date);
if (!$parsed) {
return null;
}
return (int) explode('-', $parsed)[2];
return $parsed ? (int) explode('-', $parsed)[2] : null;
}
/**
* Get day of week (0=Sunday, 6=Saturday)
*
* @param mixed $date
* @return int|null
*/
public static function dow($date): ?int
{
$parsed = static::parse($date);
if (!$parsed) {
return null;
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->dayOfWeek;
return static::_to_carbon($date)?->dayOfWeek;
}
/**
* Get full day name ("Monday", "Tuesday", etc.)
*
* @param mixed $date
* @return string
*/
public static function dow_human($date): string
{
$parsed = static::parse($date);
if (!$parsed) {
return '';
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->format('l');
return static::_to_carbon($date)?->format('l') ?? '';
}
/**
* Get short day name ("Mon", "Tue", etc.)
*
* @param mixed $date
* @return string
*/
public static function dow_short($date): string
{
$parsed = static::parse($date);
if (!$parsed) {
return '';
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->format('D');
return static::_to_carbon($date)?->format('D') ?? '';
}
/**
* Get month (1-12)
*
* @param mixed $date
* @return int|null
*/
public static function month($date): ?int
{
$parsed = static::parse($date);
if (!$parsed) {
return null;
}
return (int) explode('-', $parsed)[1];
return $parsed ? (int) explode('-', $parsed)[1] : null;
}
/**
* Get full month name ("January", "February", etc.)
*
* @param mixed $date
* @return string
*/
public static function month_human($date): string
{
$parsed = static::parse($date);
if (!$parsed) {
return '';
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->format('F');
return static::_to_carbon($date)?->format('F') ?? '';
}
/**
* Get short month name ("Jan", "Feb", etc.)
*
* @param mixed $date
* @return string
*/
public static function month_human_short($date): string
{
$parsed = static::parse($date);
if (!$parsed) {
return '';
}
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
return $carbon->format('M');
return static::_to_carbon($date)?->format('M') ?? '';
}
/**
* Get year (e.g., 2025)
*
* @param mixed $date
* @return int|null
*/
public static function year($date): ?int
{
$parsed = static::parse($date);
if (!$parsed) {
return null;
}
return (int) explode('-', $parsed)[0];
return $parsed ? (int) explode('-', $parsed)[0] : null;
}
// =========================================================================

View File

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

View File

@@ -4,17 +4,19 @@ NAME
acls - Role-based access control with supplementary permissions
SYNOPSIS
// Check if current user has a permission
Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)
PHP (server-side):
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
Permission::has_role(User_Model::ROLE_SITE_ADMIN)
// Check on specific user instance
$user->has_permission(User_Model::PERM_EDIT_DATA)
// Check if user can administer another user's role
$user->can_admin_role($target_user->role_id)
JavaScript (client-side):
Permission.has_permission(User_Model.PERM_EDIT_DATA)
Permission.has_any_permission([User_Model.PERM_EDIT_DATA, User_Model.PERM_VIEW_DATA])
Permission.has_all_permissions([...])
Permission.has_role(User_Model.ROLE_MANAGER)
Permission.can_admin_role(User_Model.ROLE_USER)
DESCRIPTION
RSpade provides a role-based access control (RBAC) system where:
@@ -72,19 +74,20 @@ ARCHITECTURE
Role Hierarchy
ID Constant Label Can Admin Roles
-- -------- ----- ---------------
1 ROLE_ROOT_ADMIN Root Admin 2,3,4,5,6,7
2 ROLE_SITE_OWNER Site Owner 3,4,5,6,7
3 ROLE_SITE_ADMIN Site Admin 4,5,6,7
4 ROLE_MANAGER Manager 5,6,7
5 ROLE_USER User (none)
6 ROLE_VIEWER Viewer (none)
7 ROLE_DISABLED Disabled (none)
ID Constant Label Can Admin Roles
--- -------- ----- ---------------
100 ROLE_DEVELOPER Developer 200-800 (system only)
200 ROLE_ROOT_ADMIN Root Admin 300-800 (system only)
300 ROLE_SITE_OWNER Site Owner 400-800
400 ROLE_SITE_ADMIN Site Admin 500-800
500 ROLE_MANAGER Manager 600-800
600 ROLE_USER User (none)
700 ROLE_VIEWER Viewer (none)
800 ROLE_DISABLED Disabled (none)
"Can Admin Roles" means a user with that role can create, edit,
or change the role of users with the listed role IDs. This
prevents privilege escalation (admin can't create root admin).
IDs are 100-based for future expansion. Lower ID = higher privilege.
"Can Admin Roles" prevents privilege escalation (Site Admin can't
create Site Owner). Developer and Root Admin are system-assigned only.
PERMISSIONS
@@ -92,7 +95,7 @@ PERMISSIONS
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+
3 PERM_MANAGE_SITE_SETTINGS Site Admin+
4 PERM_MANAGE_SITE_USERS Site Admin+
@@ -109,17 +112,17 @@ PERMISSIONS
Role-Permission Matrix
Permission Root Owner Admin Mgr User View Dis
---------- ---- ----- ----- --- ---- ---- ---
MANAGE_SITES_ROOT X
MANAGE_SITE_BILLING X X
MANAGE_SITE_SETTINGS X X X
MANAGE_SITE_USERS X X X
VIEW_USER_ACTIVITY X X X X
EDIT_DATA X X X X X
VIEW_DATA X X X X X X
API_ACCESS - - - - - -
DATA_EXPORT - - - - - -
Permission Dev Root Owner Admin Mgr User View Dis
---------- --- ---- ----- ----- --- ---- ---- ---
MANAGE_SITES_ROOT X X
MANAGE_SITE_BILLING X X X
MANAGE_SITE_SETTINGS X X X X
MANAGE_SITE_USERS X X X X
VIEW_USER_ACTIVITY X X X X X
EDIT_DATA X X X X X X
VIEW_DATA X X X X X X X
API_ACCESS - - - - - - -
DATA_EXPORT - - - - - - -
Legend: X = granted by role, - = must be granted individually
@@ -129,14 +132,15 @@ MODEL IMPLEMENTATION
class User_Model extends Rsx_Model_Abstract
{
// Role constants
const ROLE_ROOT_ADMIN = 1;
const ROLE_SITE_OWNER = 2;
const ROLE_SITE_ADMIN = 3;
const ROLE_MANAGER = 4;
const ROLE_USER = 5;
const ROLE_VIEWER = 6;
const ROLE_DISABLED = 7;
// Role constants (100-based, lower = higher privilege)
const ROLE_DEVELOPER = 100;
const ROLE_ROOT_ADMIN = 200;
const ROLE_SITE_OWNER = 300;
const ROLE_SITE_ADMIN = 400;
const ROLE_MANAGER = 500;
const ROLE_USER = 600;
const ROLE_VIEWER = 700;
const ROLE_DISABLED = 800;
// Permission constants
const PERM_MANAGE_SITES_ROOT = 1;
@@ -151,55 +155,44 @@ MODEL IMPLEMENTATION
public static $enums = [
'role_id' => [
self::ROLE_ROOT_ADMIN => [
'constant' => 'ROLE_ROOT_ADMIN',
'label' => 'Root Admin',
'permissions' => [
self::PERM_MANAGE_SITES_ROOT,
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],
300 => [
'constant' => 'ROLE_SITE_OWNER',
'label' => 'Site Owner',
'permissions' => [2, 3, 4, 5, 6, 7],
'can_admin_roles' => [400, 500, 600, 700, 800],
],
// ... 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) {
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)
if ($this->has_supplementary_deny($permission)) {
return false;
}
// Check supplementary GRANT
if ($this->has_supplementary_grant($permission)) {
return true;
}
// Check role default permissions
return in_array($permission, $this->role_permissions ?? []);
public function has_permission(int $permission): bool
{
return in_array($permission, $this->get_resolved_permissions(), true);
}
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_permissions // [3,4,5,6,7]
$user->role_can_admin_roles // [4,5,6,7]
$user->role_id__label // "Site Admin"
$user->role_id__permissions // [3,4,5,6,7]
$user->role_id__can_admin_roles // [400,500,600,700,800]
PERMISSION CLASS API
@@ -235,6 +228,10 @@ PERMISSION CLASS API
$user->has_permission(int $permission): bool
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
Check if user can create/edit users with given role.
@@ -244,6 +241,78 @@ PERMISSION CLASS API
$user->has_supplementary_deny(int $permission): bool
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
Using #[Auth] Attribute
@@ -443,7 +512,7 @@ ADDING NEW PERMISSIONS
2. Add to role definitions in $enums if role should grant it:
self::ROLE_SITE_ADMIN => [
400 => [ // ROLE_SITE_ADMIN
'permissions' => [
// ... existing
self::PERM_NEW_FEATURE,
@@ -460,15 +529,13 @@ ADDING NEW PERMISSIONS
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_MANAGER = 5; // Renumber if needed
// ...
const ROLE_SUPERVISOR = 450; // Between Admin (400) and Manager (500)
2. Add to $enums with permissions and can_admin_roles:
self::ROLE_SUPERVISOR => [
450 => [
'constant' => 'ROLE_SUPERVISOR',
'label' => 'Supervisor',
'permissions' => [
@@ -476,13 +543,13 @@ ADDING NEW ROLES
self::PERM_EDIT_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:
self::ROLE_SITE_ADMIN => [
'can_admin_roles' => [4,5,6,7], // Add new role ID
400 => [ // ROLE_SITE_ADMIN
'can_admin_roles' => [450, 500, 600, 700, 800], // Add new role ID
],
4. Run migration if role_id column needs updating
@@ -594,5 +661,6 @@ SEE ALSO
enums - Enum system for role/permission metadata
routing - Route protection with #[Auth] attribute
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
- 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:
Different features can have separate SPA bootstraps:
- /app/frontend/Frontend_Spa_Controller::index (regular users)
@@ -899,4 +925,6 @@ SEE ALSO
routing(3) - URL generation and route patterns
modals(3) - Modal dialogs in SPA context
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