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]) + +