From 8ef798c30f2940df7015282e0878ec7c4506873a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 13 Jan 2026 08:35:06 +0000 Subject: [PATCH] Add client-side Permission class and resolved_permissions to rsxapp Refactor date/time classes to reduce code redundancy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Core/Bundle/Rsx_Bundle_Abstract.php | 4 + app/RSpade/Core/Js/Permission.js | 129 ++++++++++ app/RSpade/Core/Js/Rsx_Date.js | 103 ++------ app/RSpade/Core/Js/Rsx_Time.js | 134 +++------- app/RSpade/Core/Models/User_Model.php | 71 +++--- app/RSpade/Core/Time/Rsx_Date.php | 99 ++------ app/RSpade/Core/Time/Rsx_Time.php | 130 +++------- app/RSpade/man/acls.txt | 236 +++++++++++------- app/RSpade/man/pagedata.txt | 108 ++++++++ app/RSpade/man/rsxapp.txt | 207 +++++++++++++++ app/RSpade/man/spa.txt | 28 +++ 11 files changed, 783 insertions(+), 466 deletions(-) create mode 100755 app/RSpade/Core/Js/Permission.js create mode 100755 app/RSpade/man/pagedata.txt create mode 100755 app/RSpade/man/rsxapp.txt diff --git a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php index 09a288412..e1806d74b 100644 --- a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php +++ b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php @@ -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; diff --git a/app/RSpade/Core/Js/Permission.js b/app/RSpade/Core/Js/Permission.js new file mode 100755 index 000000000..861bed308 --- /dev/null +++ b/app/RSpade/Core/Js/Permission.js @@ -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 ?? []; + } +} diff --git a/app/RSpade/Core/Js/Rsx_Date.js b/app/RSpade/Core/Js/Rsx_Date.js index e9e7a8606..4d9875e73 100755 --- a/app/RSpade/Core/Js/Rsx_Date.js +++ b/app/RSpade/Core/Js/Rsx_Date.js @@ -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; } } diff --git a/app/RSpade/Core/Js/Rsx_Time.js b/app/RSpade/Core/Js/Rsx_Time.js index cf81cb1c0..2f02aa45b 100755 --- a/app/RSpade/Core/Js/Rsx_Time.js +++ b/app/RSpade/Core/Js/Rsx_Time.js @@ -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; } } diff --git a/app/RSpade/Core/Models/User_Model.php b/app/RSpade/Core/Models/User_Model.php index 6a16a2c69..9465daebd 100644 --- a/app/RSpade/Core/Models/User_Model.php +++ b/app/RSpade/Core/Models/User_Model.php @@ -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; } diff --git a/app/RSpade/Core/Time/Rsx_Date.php b/app/RSpade/Core/Time/Rsx_Date.php index 1b0483425..147c7c8bd 100644 --- a/app/RSpade/Core/Time/Rsx_Date.php +++ b/app/RSpade/Core/Time/Rsx_Date.php @@ -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; } // ========================================================================= diff --git a/app/RSpade/Core/Time/Rsx_Time.php b/app/RSpade/Core/Time/Rsx_Time.php index 5007c11ae..1ade84161 100644 --- a/app/RSpade/Core/Time/Rsx_Time.php +++ b/app/RSpade/Core/Time/Rsx_Time.php @@ -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; } // ========================================================================= diff --git a/app/RSpade/man/acls.txt b/app/RSpade/man/acls.txt index 42cda5b29..1631c1d80 100755 --- a/app/RSpade/man/acls.txt +++ b/app/RSpade/man/acls.txt @@ -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) diff --git a/app/RSpade/man/pagedata.txt b/app/RSpade/man/pagedata.txt new file mode 100755 index 000000000..871ba10f4 --- /dev/null +++ b/app/RSpade/man/pagedata.txt @@ -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]) + +
+ ... +
+ + 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) diff --git a/app/RSpade/man/rsxapp.txt b/app/RSpade/man/rsxapp.txt new file mode 100755 index 000000000..9026c3de4 --- /dev/null +++ b/app/RSpade/man/rsxapp.txt @@ -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 + 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(``); + + 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) diff --git a/app/RSpade/man/spa.txt b/app/RSpade/man/spa.txt index 3782a2979..aaca0d5e9 100755 --- a/app/RSpade/man/spa.txt +++ b/app/RSpade/man/spa.txt @@ -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