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['site'] = Session::get_site();
|
||||||
$rsxapp_data['csrf'] = Session::get_csrf_token();
|
$rsxapp_data['csrf'] = Session::get_csrf_token();
|
||||||
|
|
||||||
|
// Add resolved permissions for client-side permission checks
|
||||||
|
$user = Session::get_user();
|
||||||
|
$rsxapp_data['resolved_permissions'] = $user ? $user->get_resolved_permissions() : [];
|
||||||
|
|
||||||
// Add browser error logging flag (enabled in both dev and production)
|
// Add browser error logging flag (enabled in both dev and production)
|
||||||
if (config('rsx.log_browser_errors', false)) {
|
if (config('rsx.log_browser_errors', false)) {
|
||||||
$rsxapp_data['log_browser_errors'] = true;
|
$rsxapp_data['log_browser_errors'] = true;
|
||||||
|
|||||||
129
app/RSpade/Core/Js/Permission.js
Executable file
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
|
// COMPONENT EXTRACTORS
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and convert to JS Date object (internal helper)
|
||||||
|
* Returns null if invalid, throws on datetime input
|
||||||
|
*/
|
||||||
|
static _to_js_date(date) {
|
||||||
|
const parsed = this.parse(date);
|
||||||
|
if (!parsed) return null;
|
||||||
|
const [year, month, day] = parsed.split('-').map(Number);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get day of month (1-31)
|
* Get day of month (1-31)
|
||||||
*
|
|
||||||
* @param {*} date
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
*/
|
||||||
static day(date) {
|
static day(date) {
|
||||||
const parsed = this.parse(date);
|
const parsed = this.parse(date);
|
||||||
if (!parsed) {
|
return parsed ? parseInt(parsed.split('-')[2], 10) : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parseInt(parsed.split('-')[2], 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get day of week (0=Sunday, 6=Saturday)
|
* Get day of week (0=Sunday, 6=Saturday)
|
||||||
*
|
|
||||||
* @param {*} date
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
*/
|
||||||
static dow(date) {
|
static dow(date) {
|
||||||
const parsed = this.parse(date);
|
return this._to_js_date(date)?.getDay() ?? null;
|
||||||
if (!parsed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [year, month, day] = parsed.split('-').map(Number);
|
|
||||||
const d = new Date(year, month - 1, day);
|
|
||||||
return d.getDay();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full day name ("Monday", "Tuesday", etc.)
|
* Get full day name ("Monday", "Tuesday", etc.)
|
||||||
*
|
|
||||||
* @param {*} date
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
static dow_human(date) {
|
static dow_human(date) {
|
||||||
const parsed = this.parse(date);
|
const d = this._to_js_date(date);
|
||||||
if (!parsed) {
|
return d ? new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(d) : '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const [year, month, day] = parsed.split('-').map(Number);
|
|
||||||
const d = new Date(year, month - 1, day);
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get short day name ("Mon", "Tue", etc.)
|
* Get short day name ("Mon", "Tue", etc.)
|
||||||
*
|
|
||||||
* @param {*} date
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
static dow_short(date) {
|
static dow_short(date) {
|
||||||
const parsed = this.parse(date);
|
const d = this._to_js_date(date);
|
||||||
if (!parsed) {
|
return d ? new Intl.DateTimeFormat('en-US', { weekday: 'short' }).format(d) : '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const [year, month, day] = parsed.split('-').map(Number);
|
|
||||||
const d = new Date(year, month - 1, day);
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', { weekday: 'short' }).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get month (1-12)
|
* Get month (1-12)
|
||||||
*
|
|
||||||
* @param {*} date
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
*/
|
||||||
static month(date) {
|
static month(date) {
|
||||||
const parsed = this.parse(date);
|
const parsed = this.parse(date);
|
||||||
if (!parsed) {
|
return parsed ? parseInt(parsed.split('-')[1], 10) : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parseInt(parsed.split('-')[1], 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full month name ("January", "February", etc.)
|
* Get full month name ("January", "February", etc.)
|
||||||
*
|
|
||||||
* @param {*} date
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
static month_human(date) {
|
static month_human(date) {
|
||||||
const parsed = this.parse(date);
|
const d = this._to_js_date(date);
|
||||||
if (!parsed) {
|
return d ? new Intl.DateTimeFormat('en-US', { month: 'long' }).format(d) : '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const [year, month, day] = parsed.split('-').map(Number);
|
|
||||||
const d = new Date(year, month - 1, day);
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', { month: 'long' }).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get short month name ("Jan", "Feb", etc.)
|
* Get short month name ("Jan", "Feb", etc.)
|
||||||
*
|
|
||||||
* @param {*} date
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
static month_human_short(date) {
|
static month_human_short(date) {
|
||||||
const parsed = this.parse(date);
|
const d = this._to_js_date(date);
|
||||||
if (!parsed) {
|
return d ? new Intl.DateTimeFormat('en-US', { month: 'short' }).format(d) : '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const [year, month, day] = parsed.split('-').map(Number);
|
|
||||||
const d = new Date(year, month - 1, day);
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', { month: 'short' }).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get year (e.g., 2025)
|
* Get year (e.g., 2025)
|
||||||
*
|
|
||||||
* @param {*} date
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
*/
|
||||||
static year(date) {
|
static year(date) {
|
||||||
const parsed = this.parse(date);
|
const parsed = this.parse(date);
|
||||||
if (!parsed) {
|
return parsed ? parseInt(parsed.split('-')[0], 10) : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parseInt(parsed.split('-')[0], 10);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -615,151 +615,89 @@ class Rsx_Time {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get day of month (1-31)
|
* Format component with validation (internal helper). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param {*} time
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
*/
|
||||||
static day(time) {
|
static _format_component(time, options) {
|
||||||
const date = this.parse(time);
|
return this.parse(time) ? this.format_in_timezone(time, options) : null;
|
||||||
if (!date) return null;
|
|
||||||
|
|
||||||
return parseInt(this.format_in_timezone(time, { day: 'numeric' }), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get day of week (0=Sunday, 6=Saturday)
|
* Get day of month (1-31). Uses user's timezone.
|
||||||
* Uses user's timezone
|
*/
|
||||||
*
|
static day(time) {
|
||||||
* @param {*} time
|
const v = this._format_component(time, { day: 'numeric' });
|
||||||
* @returns {number|null}
|
return v ? parseInt(v, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get day of week (0=Sunday, 6=Saturday). Uses user's timezone.
|
||||||
*/
|
*/
|
||||||
static dow(time) {
|
static dow(time) {
|
||||||
const date = this.parse(time);
|
const dayName = this._format_component(time, { weekday: 'short' });
|
||||||
if (!date) return null;
|
if (!dayName) return null;
|
||||||
|
|
||||||
// Get the day name and map to number
|
|
||||||
const dayName = this.format_in_timezone(time, { weekday: 'short' });
|
|
||||||
const dayMap = { 'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6 };
|
const dayMap = { 'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6 };
|
||||||
return dayMap[dayName] ?? null;
|
return dayMap[dayName] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full day name ("Monday", "Tuesday", etc.)
|
* Get full day name ("Monday", "Tuesday", etc.). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param {*} time
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
static dow_human(time) {
|
static dow_human(time) {
|
||||||
const date = this.parse(time);
|
return this._format_component(time, { weekday: 'long' }) ?? '';
|
||||||
if (!date) return '';
|
|
||||||
|
|
||||||
return this.format_in_timezone(time, { weekday: 'long' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get short day name ("Mon", "Tue", etc.)
|
* Get short day name ("Mon", "Tue", etc.). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param {*} time
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
static dow_short(time) {
|
static dow_short(time) {
|
||||||
const date = this.parse(time);
|
return this._format_component(time, { weekday: 'short' }) ?? '';
|
||||||
if (!date) return '';
|
|
||||||
|
|
||||||
return this.format_in_timezone(time, { weekday: 'short' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get month (1-12)
|
* Get month (1-12). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param {*} time
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
*/
|
||||||
static month(time) {
|
static month(time) {
|
||||||
const date = this.parse(time);
|
const v = this._format_component(time, { month: '2-digit' });
|
||||||
if (!date) return null;
|
return v ? parseInt(v, 10) : null;
|
||||||
|
|
||||||
// Use 2-digit to get padded month, then parse
|
|
||||||
const monthStr = this.format_in_timezone(time, { month: '2-digit' });
|
|
||||||
return parseInt(monthStr, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full month name ("January", "February", etc.)
|
* Get full month name ("January", "February", etc.). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param {*} time
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
static month_human(time) {
|
static month_human(time) {
|
||||||
const date = this.parse(time);
|
return this._format_component(time, { month: 'long' }) ?? '';
|
||||||
if (!date) return '';
|
|
||||||
|
|
||||||
return this.format_in_timezone(time, { month: 'long' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get short month name ("Jan", "Feb", etc.)
|
* Get short month name ("Jan", "Feb", etc.). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param {*} time
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
static month_human_short(time) {
|
static month_human_short(time) {
|
||||||
const date = this.parse(time);
|
return this._format_component(time, { month: 'short' }) ?? '';
|
||||||
if (!date) return '';
|
|
||||||
|
|
||||||
return this.format_in_timezone(time, { month: 'short' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get year (e.g., 2025)
|
* Get year (e.g., 2025). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param {*} time
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
*/
|
||||||
static year(time) {
|
static year(time) {
|
||||||
const date = this.parse(time);
|
const v = this._format_component(time, { year: 'numeric' });
|
||||||
if (!date) return null;
|
return v ? parseInt(v, 10) : null;
|
||||||
|
|
||||||
return parseInt(this.format_in_timezone(time, { year: 'numeric' }), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get hour (0-23)
|
* Get hour (0-23). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param {*} time
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
*/
|
||||||
static hour(time) {
|
static hour(time) {
|
||||||
const date = this.parse(time);
|
const v = this._format_component(time, { hour: '2-digit', hour12: false });
|
||||||
if (!date) return null;
|
if (!v) return null;
|
||||||
|
const hour = parseInt(v, 10);
|
||||||
// Use hour12: false and 2-digit for consistent 24-hour format
|
return hour === 24 ? 0 : hour; // "24" returned for midnight in some locales
|
||||||
const hourStr = this.format_in_timezone(time, { hour: '2-digit', hour12: false });
|
|
||||||
// "24" is returned for midnight in some locales, treat as 0
|
|
||||||
const hour = parseInt(hourStr, 10);
|
|
||||||
return hour === 24 ? 0 : hour;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get minute (0-59)
|
* Get minute (0-59). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param {*} time
|
|
||||||
* @returns {number|null}
|
|
||||||
*/
|
*/
|
||||||
static minute(time) {
|
static minute(time) {
|
||||||
const date = this.parse(time);
|
const v = this._format_component(time, { minute: '2-digit' });
|
||||||
if (!date) return null;
|
return v ? parseInt(v, 10) : null;
|
||||||
|
|
||||||
return parseInt(this.format_in_timezone(time, { minute: '2-digit' }), 10);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class User_Model extends Rsx_Site_Model_Abstract
|
|||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached supplementary permissions for this user
|
* Cached supplementary permissions for this user (avoids repeated DB queries)
|
||||||
* @var array|null
|
* @var array|null
|
||||||
*/
|
*/
|
||||||
protected $_supplementary_permissions = null;
|
protected $_supplementary_permissions = null;
|
||||||
@@ -238,38 +238,53 @@ class User_Model extends Rsx_Site_Model_Abstract
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user has a specific permission
|
* Get all resolved permissions for this user
|
||||||
*
|
*
|
||||||
* Resolution order:
|
* Returns the final permission array after applying:
|
||||||
* 1. DISABLED role = deny all
|
* 1. Role default permissions
|
||||||
* 2. Supplementary DENY = deny
|
* 2. Supplementary GRANTs (added)
|
||||||
* 3. Supplementary GRANT = grant
|
* 3. Supplementary DENYs (removed)
|
||||||
* 4. Role default permissions = grant if included
|
*
|
||||||
* 5. Deny
|
* @return array Array of permission IDs the user has
|
||||||
|
*/
|
||||||
|
public function get_resolved_permissions(): array
|
||||||
|
{
|
||||||
|
// Disabled users have no permissions
|
||||||
|
if ($this->role_id === self::ROLE_DISABLED) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with role default permissions
|
||||||
|
$permissions = $this->role_id__permissions ?? [];
|
||||||
|
|
||||||
|
// Load supplementary overrides (DB query is cached)
|
||||||
|
$supplementary = $this->_load_supplementary_permissions();
|
||||||
|
|
||||||
|
// Add supplementary GRANTs
|
||||||
|
foreach ($supplementary['grants'] as $perm_id) {
|
||||||
|
if (!in_array($perm_id, $permissions, true)) {
|
||||||
|
$permissions[] = $perm_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove supplementary DENYs
|
||||||
|
$permissions = array_values(array_diff($permissions, $supplementary['denies']));
|
||||||
|
|
||||||
|
// Sort for consistent ordering
|
||||||
|
sort($permissions);
|
||||||
|
|
||||||
|
return $permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has a specific permission
|
||||||
*
|
*
|
||||||
* @param int $permission Permission constant (PERM_*)
|
* @param int $permission Permission constant (PERM_*)
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function has_permission(int $permission): bool
|
public function has_permission(int $permission): bool
|
||||||
{
|
{
|
||||||
// Disabled users have no permissions
|
return in_array($permission, $this->get_resolved_permissions(), true);
|
||||||
if ($this->role_id === self::ROLE_DISABLED) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check supplementary DENY (overrides everything)
|
|
||||||
if ($this->has_supplementary_deny($permission)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check supplementary GRANT
|
|
||||||
if ($this->has_supplementary_grant($permission)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check role default permissions
|
|
||||||
$role_permissions = $this->role_id_permissions ?? [];
|
|
||||||
return in_array($permission, $role_permissions, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -360,9 +375,9 @@ class User_Model extends Rsx_Site_Model_Abstract
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear cached supplementary permissions (call after modifying)
|
* Clear cached supplementary permissions (call after modifying user_permissions table)
|
||||||
*/
|
*/
|
||||||
public function clear_supplementary_cache(): void
|
public function clear_permission_cache(): void
|
||||||
{
|
{
|
||||||
$this->_supplementary_permissions = null;
|
$this->_supplementary_permissions = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,8 +85,21 @@ class Rsx_Date
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// .expect file will document expected behaviors
|
/**
|
||||||
// See Rsx_Date.php.expect for behavioral specifications
|
* Parse and convert to Carbon object (internal helper)
|
||||||
|
* Returns null if invalid, throws on datetime input
|
||||||
|
*
|
||||||
|
* @param mixed $date
|
||||||
|
* @return Carbon|null
|
||||||
|
*/
|
||||||
|
private static function _to_carbon($date): ?Carbon
|
||||||
|
{
|
||||||
|
$parsed = static::parse($date);
|
||||||
|
if (!$parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Carbon::createFromFormat('Y-m-d', $parsed);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if input is a valid date-only value (not datetime)
|
* Check if input is a valid date-only value (not datetime)
|
||||||
@@ -402,135 +415,69 @@ class Rsx_Date
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get day of month (1-31)
|
* Get day of month (1-31)
|
||||||
*
|
|
||||||
* @param mixed $date
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public static function day($date): ?int
|
public static function day($date): ?int
|
||||||
{
|
{
|
||||||
$parsed = static::parse($date);
|
$parsed = static::parse($date);
|
||||||
if (!$parsed) {
|
return $parsed ? (int) explode('-', $parsed)[2] : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) explode('-', $parsed)[2];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get day of week (0=Sunday, 6=Saturday)
|
* Get day of week (0=Sunday, 6=Saturday)
|
||||||
*
|
|
||||||
* @param mixed $date
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public static function dow($date): ?int
|
public static function dow($date): ?int
|
||||||
{
|
{
|
||||||
$parsed = static::parse($date);
|
return static::_to_carbon($date)?->dayOfWeek;
|
||||||
if (!$parsed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
|
|
||||||
return $carbon->dayOfWeek;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full day name ("Monday", "Tuesday", etc.)
|
* Get full day name ("Monday", "Tuesday", etc.)
|
||||||
*
|
|
||||||
* @param mixed $date
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public static function dow_human($date): string
|
public static function dow_human($date): string
|
||||||
{
|
{
|
||||||
$parsed = static::parse($date);
|
return static::_to_carbon($date)?->format('l') ?? '';
|
||||||
if (!$parsed) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
|
|
||||||
return $carbon->format('l');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get short day name ("Mon", "Tue", etc.)
|
* Get short day name ("Mon", "Tue", etc.)
|
||||||
*
|
|
||||||
* @param mixed $date
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public static function dow_short($date): string
|
public static function dow_short($date): string
|
||||||
{
|
{
|
||||||
$parsed = static::parse($date);
|
return static::_to_carbon($date)?->format('D') ?? '';
|
||||||
if (!$parsed) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
|
|
||||||
return $carbon->format('D');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get month (1-12)
|
* Get month (1-12)
|
||||||
*
|
|
||||||
* @param mixed $date
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public static function month($date): ?int
|
public static function month($date): ?int
|
||||||
{
|
{
|
||||||
$parsed = static::parse($date);
|
$parsed = static::parse($date);
|
||||||
if (!$parsed) {
|
return $parsed ? (int) explode('-', $parsed)[1] : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) explode('-', $parsed)[1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full month name ("January", "February", etc.)
|
* Get full month name ("January", "February", etc.)
|
||||||
*
|
|
||||||
* @param mixed $date
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public static function month_human($date): string
|
public static function month_human($date): string
|
||||||
{
|
{
|
||||||
$parsed = static::parse($date);
|
return static::_to_carbon($date)?->format('F') ?? '';
|
||||||
if (!$parsed) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
|
|
||||||
return $carbon->format('F');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get short month name ("Jan", "Feb", etc.)
|
* Get short month name ("Jan", "Feb", etc.)
|
||||||
*
|
|
||||||
* @param mixed $date
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public static function month_human_short($date): string
|
public static function month_human_short($date): string
|
||||||
{
|
{
|
||||||
$parsed = static::parse($date);
|
return static::_to_carbon($date)?->format('M') ?? '';
|
||||||
if (!$parsed) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$carbon = Carbon::createFromFormat('Y-m-d', $parsed);
|
|
||||||
return $carbon->format('M');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get year (e.g., 2025)
|
* Get year (e.g., 2025)
|
||||||
*
|
|
||||||
* @param mixed $date
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public static function year($date): ?int
|
public static function year($date): ?int
|
||||||
{
|
{
|
||||||
$parsed = static::parse($date);
|
$parsed = static::parse($date);
|
||||||
if (!$parsed) {
|
return $parsed ? (int) explode('-', $parsed)[0] : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) explode('-', $parsed)[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -619,163 +619,93 @@ class Rsx_Time
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get day of month (1-31)
|
* Parse and convert to user's timezone (internal helper)
|
||||||
* Uses user's timezone
|
* Returns null if invalid, throws on date-only input
|
||||||
*
|
*/
|
||||||
* @param mixed $time
|
private static function _to_user_carbon($time): ?Carbon
|
||||||
* @return int|null
|
{
|
||||||
|
$carbon = static::parse($time);
|
||||||
|
return $carbon ? static::to_user_timezone($carbon) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get day of month (1-31). Uses user's timezone.
|
||||||
*/
|
*/
|
||||||
public static function day($time): ?int
|
public static function day($time): ?int
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->day;
|
||||||
if (!$carbon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (int) static::to_user_timezone($carbon)->format('j');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get day of week (0=Sunday, 6=Saturday)
|
* Get day of week (0=Sunday, 6=Saturday). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param mixed $time
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public static function dow($time): ?int
|
public static function dow($time): ?int
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->dayOfWeek;
|
||||||
if (!$carbon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return static::to_user_timezone($carbon)->dayOfWeek;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full day name ("Monday", "Tuesday", etc.)
|
* Get full day name ("Monday", "Tuesday", etc.). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param mixed $time
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public static function dow_human($time): string
|
public static function dow_human($time): string
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->format('l') ?? '';
|
||||||
if (!$carbon) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return static::to_user_timezone($carbon)->format('l');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get short day name ("Mon", "Tue", etc.)
|
* Get short day name ("Mon", "Tue", etc.). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param mixed $time
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public static function dow_short($time): string
|
public static function dow_short($time): string
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->format('D') ?? '';
|
||||||
if (!$carbon) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return static::to_user_timezone($carbon)->format('D');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get month (1-12)
|
* Get month (1-12). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param mixed $time
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public static function month($time): ?int
|
public static function month($time): ?int
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->month;
|
||||||
if (!$carbon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (int) static::to_user_timezone($carbon)->format('n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full month name ("January", "February", etc.)
|
* Get full month name ("January", "February", etc.). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param mixed $time
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public static function month_human($time): string
|
public static function month_human($time): string
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->format('F') ?? '';
|
||||||
if (!$carbon) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return static::to_user_timezone($carbon)->format('F');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get short month name ("Jan", "Feb", etc.)
|
* Get short month name ("Jan", "Feb", etc.). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param mixed $time
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public static function month_human_short($time): string
|
public static function month_human_short($time): string
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->format('M') ?? '';
|
||||||
if (!$carbon) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return static::to_user_timezone($carbon)->format('M');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get year (e.g., 2025)
|
* Get year (e.g., 2025). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param mixed $time
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public static function year($time): ?int
|
public static function year($time): ?int
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->year;
|
||||||
if (!$carbon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (int) static::to_user_timezone($carbon)->format('Y');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get hour (0-23)
|
* Get hour (0-23). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param mixed $time
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public static function hour($time): ?int
|
public static function hour($time): ?int
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->hour;
|
||||||
if (!$carbon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (int) static::to_user_timezone($carbon)->format('G');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get minute (0-59)
|
* Get minute (0-59). Uses user's timezone.
|
||||||
* Uses user's timezone
|
|
||||||
*
|
|
||||||
* @param mixed $time
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public static function minute($time): ?int
|
public static function minute($time): ?int
|
||||||
{
|
{
|
||||||
$carbon = static::parse($time);
|
return static::_to_user_carbon($time)?->minute;
|
||||||
if (!$carbon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (int) static::to_user_timezone($carbon)->format('i');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ NAME
|
|||||||
acls - Role-based access control with supplementary permissions
|
acls - Role-based access control with supplementary permissions
|
||||||
|
|
||||||
SYNOPSIS
|
SYNOPSIS
|
||||||
// Check if current user has a permission
|
PHP (server-side):
|
||||||
Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)
|
Permission::has_permission(User_Model::PERM_MANAGE_SITE_USERS)
|
||||||
|
Permission::has_role(User_Model::ROLE_SITE_ADMIN)
|
||||||
|
$user->has_permission(User_Model::PERM_EDIT_DATA)
|
||||||
|
$user->get_resolved_permissions() // All permissions as array
|
||||||
|
$user->can_admin_role($target_user->role_id)
|
||||||
|
|
||||||
// Check if current user has at least a certain role
|
JavaScript (client-side):
|
||||||
Permission::has_role(User_Model::ROLE_SITE_ADMIN)
|
Permission.has_permission(User_Model.PERM_EDIT_DATA)
|
||||||
|
Permission.has_any_permission([User_Model.PERM_EDIT_DATA, User_Model.PERM_VIEW_DATA])
|
||||||
// Check on specific user instance
|
Permission.has_all_permissions([...])
|
||||||
$user->has_permission(User_Model::PERM_EDIT_DATA)
|
Permission.has_role(User_Model.ROLE_MANAGER)
|
||||||
|
Permission.can_admin_role(User_Model.ROLE_USER)
|
||||||
// Check if user can administer another user's role
|
|
||||||
$user->can_admin_role($target_user->role_id)
|
|
||||||
|
|
||||||
DESCRIPTION
|
DESCRIPTION
|
||||||
RSpade provides a role-based access control (RBAC) system where:
|
RSpade provides a role-based access control (RBAC) system where:
|
||||||
@@ -72,19 +74,20 @@ ARCHITECTURE
|
|||||||
|
|
||||||
Role Hierarchy
|
Role Hierarchy
|
||||||
|
|
||||||
ID Constant Label Can Admin Roles
|
ID Constant Label Can Admin Roles
|
||||||
-- -------- ----- ---------------
|
--- -------- ----- ---------------
|
||||||
1 ROLE_ROOT_ADMIN Root Admin 2,3,4,5,6,7
|
100 ROLE_DEVELOPER Developer 200-800 (system only)
|
||||||
2 ROLE_SITE_OWNER Site Owner 3,4,5,6,7
|
200 ROLE_ROOT_ADMIN Root Admin 300-800 (system only)
|
||||||
3 ROLE_SITE_ADMIN Site Admin 4,5,6,7
|
300 ROLE_SITE_OWNER Site Owner 400-800
|
||||||
4 ROLE_MANAGER Manager 5,6,7
|
400 ROLE_SITE_ADMIN Site Admin 500-800
|
||||||
5 ROLE_USER User (none)
|
500 ROLE_MANAGER Manager 600-800
|
||||||
6 ROLE_VIEWER Viewer (none)
|
600 ROLE_USER User (none)
|
||||||
7 ROLE_DISABLED Disabled (none)
|
700 ROLE_VIEWER Viewer (none)
|
||||||
|
800 ROLE_DISABLED Disabled (none)
|
||||||
|
|
||||||
"Can Admin Roles" means a user with that role can create, edit,
|
IDs are 100-based for future expansion. Lower ID = higher privilege.
|
||||||
or change the role of users with the listed role IDs. This
|
"Can Admin Roles" prevents privilege escalation (Site Admin can't
|
||||||
prevents privilege escalation (admin can't create root admin).
|
create Site Owner). Developer and Root Admin are system-assigned only.
|
||||||
|
|
||||||
PERMISSIONS
|
PERMISSIONS
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ PERMISSIONS
|
|||||||
|
|
||||||
ID Constant Granted By Default To
|
ID Constant Granted By Default To
|
||||||
-- -------- ---------------------
|
-- -------- ---------------------
|
||||||
1 PERM_MANAGE_SITES_ROOT Root Admin only
|
1 PERM_MANAGE_SITES_ROOT Developer, Root Admin only
|
||||||
2 PERM_MANAGE_SITE_BILLING Site Owner+
|
2 PERM_MANAGE_SITE_BILLING Site Owner+
|
||||||
3 PERM_MANAGE_SITE_SETTINGS Site Admin+
|
3 PERM_MANAGE_SITE_SETTINGS Site Admin+
|
||||||
4 PERM_MANAGE_SITE_USERS Site Admin+
|
4 PERM_MANAGE_SITE_USERS Site Admin+
|
||||||
@@ -109,17 +112,17 @@ PERMISSIONS
|
|||||||
|
|
||||||
Role-Permission Matrix
|
Role-Permission Matrix
|
||||||
|
|
||||||
Permission Root Owner Admin Mgr User View Dis
|
Permission Dev Root Owner Admin Mgr User View Dis
|
||||||
---------- ---- ----- ----- --- ---- ---- ---
|
---------- --- ---- ----- ----- --- ---- ---- ---
|
||||||
MANAGE_SITES_ROOT X
|
MANAGE_SITES_ROOT X X
|
||||||
MANAGE_SITE_BILLING X X
|
MANAGE_SITE_BILLING X X X
|
||||||
MANAGE_SITE_SETTINGS X X X
|
MANAGE_SITE_SETTINGS X X X X
|
||||||
MANAGE_SITE_USERS X X X
|
MANAGE_SITE_USERS X X X X
|
||||||
VIEW_USER_ACTIVITY X X X X
|
VIEW_USER_ACTIVITY X X X X X
|
||||||
EDIT_DATA X X X X X
|
EDIT_DATA X X X X X X
|
||||||
VIEW_DATA X X X X X X
|
VIEW_DATA X X X X X X X
|
||||||
API_ACCESS - - - - - -
|
API_ACCESS - - - - - - -
|
||||||
DATA_EXPORT - - - - - -
|
DATA_EXPORT - - - - - - -
|
||||||
|
|
||||||
Legend: X = granted by role, - = must be granted individually
|
Legend: X = granted by role, - = must be granted individually
|
||||||
|
|
||||||
@@ -129,14 +132,15 @@ MODEL IMPLEMENTATION
|
|||||||
|
|
||||||
class User_Model extends Rsx_Model_Abstract
|
class User_Model extends Rsx_Model_Abstract
|
||||||
{
|
{
|
||||||
// Role constants
|
// Role constants (100-based, lower = higher privilege)
|
||||||
const ROLE_ROOT_ADMIN = 1;
|
const ROLE_DEVELOPER = 100;
|
||||||
const ROLE_SITE_OWNER = 2;
|
const ROLE_ROOT_ADMIN = 200;
|
||||||
const ROLE_SITE_ADMIN = 3;
|
const ROLE_SITE_OWNER = 300;
|
||||||
const ROLE_MANAGER = 4;
|
const ROLE_SITE_ADMIN = 400;
|
||||||
const ROLE_USER = 5;
|
const ROLE_MANAGER = 500;
|
||||||
const ROLE_VIEWER = 6;
|
const ROLE_USER = 600;
|
||||||
const ROLE_DISABLED = 7;
|
const ROLE_VIEWER = 700;
|
||||||
|
const ROLE_DISABLED = 800;
|
||||||
|
|
||||||
// Permission constants
|
// Permission constants
|
||||||
const PERM_MANAGE_SITES_ROOT = 1;
|
const PERM_MANAGE_SITES_ROOT = 1;
|
||||||
@@ -151,55 +155,44 @@ MODEL IMPLEMENTATION
|
|||||||
|
|
||||||
public static $enums = [
|
public static $enums = [
|
||||||
'role_id' => [
|
'role_id' => [
|
||||||
self::ROLE_ROOT_ADMIN => [
|
300 => [
|
||||||
'constant' => 'ROLE_ROOT_ADMIN',
|
'constant' => 'ROLE_SITE_OWNER',
|
||||||
'label' => 'Root Admin',
|
'label' => 'Site Owner',
|
||||||
'permissions' => [
|
'permissions' => [2, 3, 4, 5, 6, 7],
|
||||||
self::PERM_MANAGE_SITES_ROOT,
|
'can_admin_roles' => [400, 500, 600, 700, 800],
|
||||||
self::PERM_MANAGE_SITE_BILLING,
|
|
||||||
self::PERM_MANAGE_SITE_SETTINGS,
|
|
||||||
self::PERM_MANAGE_SITE_USERS,
|
|
||||||
self::PERM_VIEW_USER_ACTIVITY,
|
|
||||||
self::PERM_EDIT_DATA,
|
|
||||||
self::PERM_VIEW_DATA,
|
|
||||||
],
|
|
||||||
'can_admin_roles' => [2,3,4,5,6,7],
|
|
||||||
],
|
],
|
||||||
// ... additional roles
|
// ... additional roles
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function has_permission(int $permission): bool
|
// Get all resolved permissions (role + supplementary applied)
|
||||||
|
public function get_resolved_permissions(): array
|
||||||
{
|
{
|
||||||
if ($this->role_id === self::ROLE_DISABLED) {
|
if ($this->role_id === self::ROLE_DISABLED) {
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
|
$permissions = $this->role_id__permissions ?? [];
|
||||||
|
// Add supplementary GRANTs, remove supplementary DENYs
|
||||||
|
// ... (see User_Model for full implementation)
|
||||||
|
return $permissions;
|
||||||
|
}
|
||||||
|
|
||||||
// Check supplementary DENY (overrides everything)
|
public function has_permission(int $permission): bool
|
||||||
if ($this->has_supplementary_deny($permission)) {
|
{
|
||||||
return false;
|
return in_array($permission, $this->get_resolved_permissions(), true);
|
||||||
}
|
|
||||||
|
|
||||||
// Check supplementary GRANT
|
|
||||||
if ($this->has_supplementary_grant($permission)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check role default permissions
|
|
||||||
return in_array($permission, $this->role_permissions ?? []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function can_admin_role(int $role_id): bool
|
public function can_admin_role(int $role_id): bool
|
||||||
{
|
{
|
||||||
return in_array($role_id, $this->role_can_admin_roles ?? []);
|
return in_array($role_id, $this->role_id__can_admin_roles ?? [], true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Magic Properties (via enum system)
|
Magic Properties (via enum system, BEM-style double underscore)
|
||||||
|
|
||||||
$user->role_label // "Site Admin"
|
$user->role_id__label // "Site Admin"
|
||||||
$user->role_permissions // [3,4,5,6,7]
|
$user->role_id__permissions // [3,4,5,6,7]
|
||||||
$user->role_can_admin_roles // [4,5,6,7]
|
$user->role_id__can_admin_roles // [400,500,600,700,800]
|
||||||
|
|
||||||
PERMISSION CLASS API
|
PERMISSION CLASS API
|
||||||
|
|
||||||
@@ -235,6 +228,10 @@ PERMISSION CLASS API
|
|||||||
$user->has_permission(int $permission): bool
|
$user->has_permission(int $permission): bool
|
||||||
Check if this specific user has permission.
|
Check if this specific user has permission.
|
||||||
|
|
||||||
|
$user->get_resolved_permissions(): array
|
||||||
|
Get all resolved permission IDs for this user.
|
||||||
|
Applies role defaults + grants - denies.
|
||||||
|
|
||||||
$user->can_admin_role(int $role_id): bool
|
$user->can_admin_role(int $role_id): bool
|
||||||
Check if user can create/edit users with given role.
|
Check if user can create/edit users with given role.
|
||||||
|
|
||||||
@@ -244,6 +241,78 @@ PERMISSION CLASS API
|
|||||||
$user->has_supplementary_deny(int $permission): bool
|
$user->has_supplementary_deny(int $permission): bool
|
||||||
Check if user has explicit DENY for permission.
|
Check if user has explicit DENY for permission.
|
||||||
|
|
||||||
|
JAVASCRIPT PERMISSION CLASS
|
||||||
|
|
||||||
|
The Permission class provides client-side permission checking using
|
||||||
|
pre-resolved permissions from window.rsxapp.resolved_permissions.
|
||||||
|
|
||||||
|
This array is computed server-side and includes role defaults with
|
||||||
|
supplementary grants added and denies removed, ensuring JS checks
|
||||||
|
match PHP exactly.
|
||||||
|
|
||||||
|
Static Methods
|
||||||
|
|
||||||
|
Permission.is_logged_in(): boolean
|
||||||
|
Check if user is authenticated.
|
||||||
|
|
||||||
|
if (Permission.is_logged_in()) {
|
||||||
|
// Show authenticated UI
|
||||||
|
}
|
||||||
|
|
||||||
|
Permission.get_user(): Object|null
|
||||||
|
Get current user object from rsxapp.
|
||||||
|
|
||||||
|
Permission.has_permission(permission): boolean
|
||||||
|
Check if user has specific permission.
|
||||||
|
|
||||||
|
if (Permission.has_permission(User_Model.PERM_EDIT_DATA)) {
|
||||||
|
// Show edit button
|
||||||
|
}
|
||||||
|
|
||||||
|
Permission.has_any_permission(permissions): boolean
|
||||||
|
Check if user has ANY of the listed permissions.
|
||||||
|
|
||||||
|
if (Permission.has_any_permission([
|
||||||
|
User_Model.PERM_EDIT_DATA,
|
||||||
|
User_Model.PERM_VIEW_DATA
|
||||||
|
])) {
|
||||||
|
// User can view or edit
|
||||||
|
}
|
||||||
|
|
||||||
|
Permission.has_all_permissions(permissions): boolean
|
||||||
|
Check if user has ALL of the listed permissions.
|
||||||
|
|
||||||
|
if (Permission.has_all_permissions([
|
||||||
|
User_Model.PERM_MANAGE_SITE_USERS,
|
||||||
|
User_Model.PERM_VIEW_USER_ACTIVITY
|
||||||
|
])) {
|
||||||
|
// User can manage users AND view activity
|
||||||
|
}
|
||||||
|
|
||||||
|
Permission.has_role(role_id): boolean
|
||||||
|
Check if user has at least the specified role level.
|
||||||
|
Lower role_id = higher privilege.
|
||||||
|
|
||||||
|
if (Permission.has_role(User_Model.ROLE_MANAGER)) {
|
||||||
|
// User is Manager or higher (Admin, Owner, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
Permission.can_admin_role(role_id): boolean
|
||||||
|
Check if user can administer users with given role.
|
||||||
|
|
||||||
|
if (Permission.can_admin_role(User_Model.ROLE_USER)) {
|
||||||
|
// Show role assignment dropdown including User role
|
||||||
|
}
|
||||||
|
|
||||||
|
Permission.get_resolved_permissions(): number[]
|
||||||
|
Get array of all permission IDs the user has.
|
||||||
|
|
||||||
|
Data Source
|
||||||
|
|
||||||
|
The Permission class reads from window.rsxapp.resolved_permissions,
|
||||||
|
which is populated by the bundle renderer from the session user's
|
||||||
|
get_resolved_permissions() result. Empty array if not authenticated.
|
||||||
|
|
||||||
ROUTE PROTECTION
|
ROUTE PROTECTION
|
||||||
|
|
||||||
Using #[Auth] Attribute
|
Using #[Auth] Attribute
|
||||||
@@ -443,7 +512,7 @@ ADDING NEW PERMISSIONS
|
|||||||
|
|
||||||
2. Add to role definitions in $enums if role should grant it:
|
2. Add to role definitions in $enums if role should grant it:
|
||||||
|
|
||||||
self::ROLE_SITE_ADMIN => [
|
400 => [ // ROLE_SITE_ADMIN
|
||||||
'permissions' => [
|
'permissions' => [
|
||||||
// ... existing
|
// ... existing
|
||||||
self::PERM_NEW_FEATURE,
|
self::PERM_NEW_FEATURE,
|
||||||
@@ -460,15 +529,13 @@ ADDING NEW PERMISSIONS
|
|||||||
|
|
||||||
ADDING NEW ROLES
|
ADDING NEW ROLES
|
||||||
|
|
||||||
1. Add constant (maintain hierarchy order):
|
1. Add constant (maintain hierarchy order, 100-based):
|
||||||
|
|
||||||
const ROLE_SUPERVISOR = 4; // Between Admin and Manager
|
const ROLE_SUPERVISOR = 450; // Between Admin (400) and Manager (500)
|
||||||
const ROLE_MANAGER = 5; // Renumber if needed
|
|
||||||
// ...
|
|
||||||
|
|
||||||
2. Add to $enums with permissions and can_admin_roles:
|
2. Add to $enums with permissions and can_admin_roles:
|
||||||
|
|
||||||
self::ROLE_SUPERVISOR => [
|
450 => [
|
||||||
'constant' => 'ROLE_SUPERVISOR',
|
'constant' => 'ROLE_SUPERVISOR',
|
||||||
'label' => 'Supervisor',
|
'label' => 'Supervisor',
|
||||||
'permissions' => [
|
'permissions' => [
|
||||||
@@ -476,13 +543,13 @@ ADDING NEW ROLES
|
|||||||
self::PERM_EDIT_DATA,
|
self::PERM_EDIT_DATA,
|
||||||
self::PERM_VIEW_DATA,
|
self::PERM_VIEW_DATA,
|
||||||
],
|
],
|
||||||
'can_admin_roles' => [5,6,7],
|
'can_admin_roles' => [500, 600, 700, 800],
|
||||||
],
|
],
|
||||||
|
|
||||||
3. Update can_admin_roles for roles above:
|
3. Update can_admin_roles for roles above:
|
||||||
|
|
||||||
self::ROLE_SITE_ADMIN => [
|
400 => [ // ROLE_SITE_ADMIN
|
||||||
'can_admin_roles' => [4,5,6,7], // Add new role ID
|
'can_admin_roles' => [450, 500, 600, 700, 800], // Add new role ID
|
||||||
],
|
],
|
||||||
|
|
||||||
4. Run migration if role_id column needs updating
|
4. Run migration if role_id column needs updating
|
||||||
@@ -594,5 +661,6 @@ SEE ALSO
|
|||||||
enums - Enum system for role/permission metadata
|
enums - Enum system for role/permission metadata
|
||||||
routing - Route protection with #[Auth] attribute
|
routing - Route protection with #[Auth] attribute
|
||||||
session - Session management and user context
|
session - Session management and user context
|
||||||
|
rsxapp - Global JS object containing resolved_permissions
|
||||||
|
|
||||||
RSpade 1.0 November 2024 ACLS(7)
|
RSpade 1.0 January 2026 ACLS(7)
|
||||||
|
|||||||
108
app/RSpade/man/pagedata.txt
Executable file
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
|
- One per feature/bundle
|
||||||
- Naming: {Feature}_Spa_Controller::index
|
- Naming: {Feature}_Spa_Controller::index
|
||||||
|
|
||||||
|
Passing Page Data:
|
||||||
|
Use PageData::add() to pass server-side data to JavaScript actions:
|
||||||
|
|
||||||
|
use App\RSpade\Core\View\PageData;
|
||||||
|
|
||||||
|
#[SPA]
|
||||||
|
public static function index(Request $request, array $params = [])
|
||||||
|
{
|
||||||
|
// Load data needed by SPA actions/components
|
||||||
|
$internal_contact = Contact_Model::where('type_id', Contact_Model::TYPE_INTERNAL)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
PageData::add([
|
||||||
|
'contact_internal_id' => $internal_contact?->id,
|
||||||
|
'feature_flags' => config('rsx.features'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return rsx_view(SPA, ['bundle' => 'Frontend_Bundle']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Access in JavaScript via window.rsxapp.page_data:
|
||||||
|
|
||||||
|
const internal_id = window.rsxapp.page_data.contact_internal_id;
|
||||||
|
|
||||||
|
See pagedata(3) for detailed usage.
|
||||||
|
|
||||||
Multiple SPA Bootstraps:
|
Multiple SPA Bootstraps:
|
||||||
Different features can have separate SPA bootstraps:
|
Different features can have separate SPA bootstraps:
|
||||||
- /app/frontend/Frontend_Spa_Controller::index (regular users)
|
- /app/frontend/Frontend_Spa_Controller::index (regular users)
|
||||||
@@ -899,4 +925,6 @@ SEE ALSO
|
|||||||
routing(3) - URL generation and route patterns
|
routing(3) - URL generation and route patterns
|
||||||
modals(3) - Modal dialogs in SPA context
|
modals(3) - Modal dialogs in SPA context
|
||||||
ajax_error_handling(3) - Error handling patterns
|
ajax_error_handling(3) - Error handling patterns
|
||||||
|
pagedata(3) - Passing server-side data to JavaScript
|
||||||
|
rsxapp(3) - Global JavaScript runtime object
|
||||||
scss(3) - SCSS scoping conventions and component-first philosophy
|
scss(3) - SCSS scoping conventions and component-first philosophy
|
||||||
|
|||||||
Reference in New Issue
Block a user