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:
@@ -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
129
app/RSpade/Core/Js/Permission.js
Executable 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 ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -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
108
app/RSpade/man/pagedata.txt
Executable 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
207
app/RSpade/man/rsxapp.txt
Executable 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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user