diff --git a/app/RSpade/Commands/Migrate/Maint_Migrate.php b/app/RSpade/Commands/Migrate/Maint_Migrate.php index 701b294ef..98dd27e1d 100644 --- a/app/RSpade/Commands/Migrate/Maint_Migrate.php +++ b/app/RSpade/Commands/Migrate/Maint_Migrate.php @@ -260,7 +260,9 @@ class Maint_Migrate extends Command } // Run normalize_schema BEFORE migrations to fix existing tables - $requiredColumnsArgs = $is_development ? [] : ['--production' => true]; + // Use --production flag if not using snapshots (framework-only or non-development mode) + $use_snapshot = $is_development && !$is_framework_only; + $requiredColumnsArgs = $use_snapshot ? [] : ['--production' => true]; $this->info("\n Pre-migration normalization (fixing existing tables)...\n"); $normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs); diff --git a/app/RSpade/Commands/Rsx/Route_Debug_Command.php b/app/RSpade/Commands/Rsx/Route_Debug_Command.php index bc3fe88ae..ea0a2eff6 100644 --- a/app/RSpade/Commands/Rsx/Route_Debug_Command.php +++ b/app/RSpade/Commands/Rsx/Route_Debug_Command.php @@ -11,6 +11,7 @@ use Illuminate\Console\Command; use Symfony\Component\Process\Process; use App\RSpade\Core\Debug\Debugger; use App\RSpade\Core\Models\Login_User_Model; +use App\RSpade\Core\Portal\Portal_User_Model; /** * RSX Route Debug Command @@ -156,7 +157,9 @@ class Route_Debug_Command extends Command {--console-list : Alias for --console-log to display all console output} {--screenshot-width= : Screenshot width (px or preset: mobile, iphone-mobile, tablet, desktop-small, desktop-medium, desktop-large). Defaults to 1920} {--screenshot-path= : Path to save screenshot file (triggers screenshot capture, max height 5000px)} - {--dump-dimensions= : Add data-dimensions attribute to elements matching selector (for layout debugging)}'; + {--dump-dimensions= : Add data-dimensions attribute to elements matching selector (for layout debugging)} + {--portal : Test portal routes (uses /_portal/ prefix and portal authentication)} + {--portal-user= : Test as specific portal user ID or email (requires --portal)}'; /** * The console command description. @@ -213,15 +216,46 @@ class Route_Debug_Command extends Command $url = '/' . $url; } + // Get portal mode options + $portal_mode = $this->option('portal'); + $portal_user_input = $this->option('portal-user'); + + // Validate portal options + if ($portal_user_input && !$portal_mode) { + $this->error('--portal-user requires --portal flag'); + return 1; + } + + // Normalize URL for portal mode (strip /_portal/ prefix if present) + if ($portal_mode && str_starts_with($url, '/_portal')) { + $url = substr($url, 8); // Remove '/_portal' + if ($url === '' || $url === false) { + $url = '/'; + } + } + // Get user ID from options (accepts ID or email) $user_id = $this->option('user'); if ($user_id !== null) { + if ($portal_mode) { + $this->error('Use --portal-user instead of --user when --portal flag is set'); + return 1; + } $user_id = $this->resolve_user($user_id); if ($user_id === null) { return 1; // Error already displayed } } + // Get portal user ID from options (accepts ID or email) + $portal_user_id = null; + if ($portal_user_input !== null) { + $portal_user_id = $this->resolve_portal_user($portal_user_input); + if ($portal_user_id === null) { + return 1; // Error already displayed + } + } + // Get log flag $show_log = $this->option('log'); @@ -384,16 +418,26 @@ class Route_Debug_Command extends Command // This prevents unauthorized requests from hijacking sessions via headers $dev_auth_token = null; if ($user_id) { - $dev_auth_token = $this->generate_dev_auth_token($url, $user_id); + $dev_auth_token = $this->generate_dev_auth_token($url, $user_id, false); + } elseif ($portal_user_id) { + $dev_auth_token = $this->generate_dev_auth_token($url, $portal_user_id, true); } // Build command arguments $command_args = ['node', $playwright_script, $url]; + if ($portal_mode) { + $command_args[] = '--portal'; + } + if ($user_id) { $command_args[] = "--user={$user_id}"; } + if ($portal_user_id) { + $command_args[] = "--portal-user={$portal_user_id}"; + } + if ($dev_auth_token) { $command_args[] = "--dev-auth-token={$dev_auth_token}"; } @@ -560,6 +604,15 @@ class Route_Debug_Command extends Command $this->line(' php artisan rsx:debug /admin --user=admin@example.com # Test as user by email'); $this->line(''); + $this->comment('PORTAL ROUTES:'); + $this->line(' php artisan rsx:debug /dashboard --portal --portal-user=1'); + $this->line(' # Test portal as user ID 1'); + $this->line(' php artisan rsx:debug /_portal/dashboard --portal --portal-user=1'); + $this->line(' # Same (/_portal/ prefix stripped)'); + $this->line(' php artisan rsx:debug /mail --portal --portal-user=client@example.com'); + $this->line(' # Test portal as user by email'); + $this->line(''); + $this->comment('TESTING RSX JAVASCRIPT (use return or console.log for output):'); $this->line(' php artisan rsx:debug / --eval="return typeof Rsx_Time" # Check if class exists'); $this->line(' php artisan rsx:debug / --eval="return Rsx_Time.now_iso()" # Get current time'); @@ -677,6 +730,43 @@ class Route_Debug_Command extends Command return $user_id; } + /** + * Resolve portal user identifier to user ID + * + * Accepts either a numeric user ID or an email address. + * Validates that the portal user exists in the database. + * + * @param string $user_input Portal user ID or email address + * @return int|null User ID or null if not found (error already displayed) + */ + protected function resolve_portal_user(string $user_input): ?int + { + // Check if input is an email address + if (str_contains($user_input, '@')) { + $portal_user = Portal_User_Model::where('email', $user_input)->first(); + if (!$portal_user) { + $this->error("Portal user not found: {$user_input}"); + return null; + } + return $portal_user->id; + } + + // Input is a user ID - validate it exists + if (!ctype_digit($user_input)) { + $this->error("Invalid portal user identifier: {$user_input} (must be numeric ID or email address)"); + return null; + } + + $user_id = (int) $user_input; + $portal_user = Portal_User_Model::find($user_id); + if (!$portal_user) { + $this->error("Portal user ID not found: {$user_id}"); + return null; + } + + return $user_id; + } + /** * Generate a signed dev auth token for Playwright requests * @@ -686,9 +776,10 @@ class Route_Debug_Command extends Command * * @param string $url The URL being tested * @param int $user_id The user ID to authenticate as + * @param bool $is_portal Whether this is a portal user (vs main site user) * @return string The signed token */ - protected function generate_dev_auth_token(string $url, int $user_id): string + protected function generate_dev_auth_token(string $url, int $user_id, bool $is_portal = false): string { $app_key = config('app.key'); if (!$app_key) { @@ -700,6 +791,7 @@ class Route_Debug_Command extends Command $payload = json_encode([ 'url' => $url, 'user_id' => $user_id, + 'portal' => $is_portal, ]); // Sign with HMAC-SHA256 diff --git a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php index a7a38d94e..af5b18714 100644 --- a/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php +++ b/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php @@ -6,6 +6,8 @@ use RuntimeException; use App\RSpade\CodeQuality\RuntimeChecks\BundleErrors; use App\RSpade\Core\Bundle\BundleCompiler; use App\RSpade\Core\Manifest\Manifest; +use App\RSpade\Core\Portal\Portal_Session; +use App\RSpade\Core\Portal\Rsx_Portal; use App\RSpade\Core\Rsx; use App\RSpade\Core\Session\Session; @@ -261,10 +263,12 @@ abstract class Rsx_Bundle_Abstract // Add runtime data $rsxapp_data['debug'] = Rsx::is_development(); - $rsxapp_data['current_controller'] = Rsx::get_current_controller(); - $rsxapp_data['current_action'] = Rsx::get_current_action(); - $rsxapp_data['is_auth'] = Session::is_logged_in(); - $rsxapp_data['is_spa'] = Rsx::is_spa(); + // Use portal-specific methods when in portal context + $is_portal = Rsx_Portal::is_portal_request(); + $rsxapp_data['is_portal'] = $is_portal; + $rsxapp_data['current_controller'] = $is_portal ? Rsx_Portal::get_current_controller() : Rsx::get_current_controller(); + $rsxapp_data['current_action'] = $is_portal ? Rsx_Portal::get_current_action() : Rsx::get_current_action(); + $rsxapp_data['is_spa'] = $is_portal ? Rsx_Portal::is_spa() : Rsx::is_spa(); // Enable ajax batching in debug/production modes, disable in development for easier debugging $rsxapp_data['ajax_batching'] = !Rsx::is_development(); @@ -274,9 +278,18 @@ abstract class Rsx_Bundle_Abstract $rsxapp_data['params'] = $current_params ?? []; // Add user, site, and csrf data from session - $rsxapp_data['user'] = Session::get_user(); - $rsxapp_data['site'] = Session::get_site(); - $rsxapp_data['csrf'] = Session::get_csrf_token(); + // Use Portal_Session for portal requests, Session for regular requests + if ($is_portal) { + $rsxapp_data['is_auth'] = Portal_Session::is_logged_in(); + $rsxapp_data['user'] = Portal_Session::get_portal_user(); + $rsxapp_data['site'] = Portal_Session::get_site(); + $rsxapp_data['csrf'] = Portal_Session::get_csrf_token(); + } else { + $rsxapp_data['is_auth'] = Session::is_logged_in(); + $rsxapp_data['user'] = Session::get_user(); + $rsxapp_data['site'] = Session::get_site(); + $rsxapp_data['csrf'] = Session::get_csrf_token(); + } // Add browser error logging flag (enabled in both dev and production) if (config('rsx.log_browser_errors', false)) { diff --git a/app/RSpade/Core/Dispatch/Dispatcher.php b/app/RSpade/Core/Dispatch/Dispatcher.php index 6b418bad5..a7ad0ed2c 100644 --- a/app/RSpade/Core/Dispatch/Dispatcher.php +++ b/app/RSpade/Core/Dispatch/Dispatcher.php @@ -19,6 +19,8 @@ use App\RSpade\Core\Debug\Debugger; use App\RSpade\Core\Dispatch\AssetHandler; use App\RSpade\Core\Dispatch\RouteResolver; use App\RSpade\Core\Manifest\Manifest; +use App\RSpade\Core\Portal\Portal_Dispatcher; +use App\RSpade\Core\Portal\Rsx_Portal; use App\RSpade\Core\Rsx; /** @@ -83,6 +85,12 @@ class Dispatcher $request = $request ?? request(); + // Check if this is a portal request - delegate to Portal_Dispatcher + if (Rsx_Portal::is_portal_request()) { + console_debug('DISPATCH', 'Portal request detected, delegating to Portal_Dispatcher'); + return Portal_Dispatcher::dispatch($url, $method, $extra_params, $request); + } + // Custom session is handled by Session::init() in RsxAuth // Check if this is an asset request diff --git a/app/RSpade/Core/Files/File_Attachment_Model.php b/app/RSpade/Core/Files/File_Attachment_Model.php index 0227f7e26..cf9badb12 100644 --- a/app/RSpade/Core/Files/File_Attachment_Model.php +++ b/app/RSpade/Core/Files/File_Attachment_Model.php @@ -30,50 +30,42 @@ use App\RSpade\Core\Files\File_Storage_Model; * provides the basic structure for categorizing uploaded files. */ + /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2026-01-29 08:25:50 - * Table: _file_attachments - * - * @property int $id - * @property mixed $key - * @property int $file_storage_id - * @property mixed $file_name - * @property mixed $file_extension - * @property int $file_type_id - * @property int $width - * @property int $height - * @property int $duration - * @property bool $is_animated - * @property int $frame_count - * @property int $fileable_type - * @property int $fileable_id - * @property mixed $fileable_category - * @property mixed $fileable_type_meta - * @property int $fileable_order + * _AUTO_GENERATED_ + * @property integer $id + * @property string $key + * @property integer $file_storage_id + * @property string $file_name + * @property string $file_extension + * @property integer $file_type_id + * @property integer $width + * @property integer $height + * @property integer $duration + * @property boolean $is_animated + * @property integer $frame_count + * @property integer $fileable_type + * @property integer $fileable_id + * @property string $fileable_category + * @property string $fileable_type_meta + * @property integer $fileable_order * @property string $fileable_meta - * @property int $site_id - * @property mixed $session_id - * @property string $created_at - * @property string $updated_at - * @property int $created_by - * @property int $updated_by - * - * @property-read string $file_type_id__label - * @property-read string $file_type_id__constant - * - * @method static array file_type_id__enum() Get all enum definitions with full metadata - * @method static array file_type_id__enum_select() Get selectable items for dropdowns - * @method static array file_type_id__enum_labels() Get simple id => label map - * @method static array file_type_id__enum_ids() Get array of all valid enum IDs - * + * @property integer $site_id + * @property string $session_id + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property integer $created_by + * @property integer $updated_by + * @method static mixed file_type_id_enum() + * @method static mixed file_type_id_enum_select() + * @method static mixed file_type_id_enum_ids() + * @property-read mixed $file_type_id_constant + * @property-read mixed $file_type_id_label * @mixin \Eloquent */ class File_Attachment_Model extends Rsx_Site_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const FILE_TYPE_IMAGE = 1; const FILE_TYPE_ANIMATED_IMAGE = 2; const FILE_TYPE_VIDEO = 3; @@ -81,9 +73,6 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract const FILE_TYPE_TEXT = 5; const FILE_TYPE_DOCUMENT = 6; const FILE_TYPE_OTHER = 7; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ /** diff --git a/app/RSpade/Core/Js/Rsx_Portal.js b/app/RSpade/Core/Js/Rsx_Portal.js new file mode 100755 index 000000000..5a7fb2655 --- /dev/null +++ b/app/RSpade/Core/Js/Rsx_Portal.js @@ -0,0 +1,405 @@ +// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names + +/** + * Rsx_Portal - Client Portal JavaScript Runtime Utilities + * + * Provides static utility methods for client portal JavaScript code including + * route generation and portal context detection. + * + * Key differences from Rsx: + * - Route() prepends portal domain/prefix + * - Portal-specific context detection + * - Simpler (no event system, fewer utilities) + * + * Usage Examples: + * ```javascript + * // Check if in portal context + * if (Rsx_Portal.is_portal()) { ... } + * + * // Route generation (applies portal prefix/domain) + * const url = Rsx_Portal.Route('Portal_Dashboard_Action'); + * // Development: /_portal/dashboard + * // Production: /dashboard (on portal domain) + * + * // Route with parameters + * const url = Rsx_Portal.Route('Portal_Project_View_Action', 123); + * // Development: /_portal/projects/123 + * ``` + * + * @static + * @global + */ +class Rsx_Portal { + /** + * URL prefix for portal when no dedicated domain configured + * Must match PHP Rsx_Portal::URL_PREFIX + */ + static URL_PREFIX = '/_portal'; + + /** + * Storage for portal route definitions loaded from bundles + */ + static _routes = {}; + + /** + * Cached portal context detection result + */ + static _is_portal = null; + + // ========================================================================= + // Configuration Methods + // ========================================================================= + + /** + * Get the configured portal domain + * + * @returns {string|null} Domain if configured, null otherwise + */ + static get_domain() { + return window.rsxapp?.portal?.domain || null; + } + + /** + * Get the portal URL prefix (used when no domain configured) + * + * @returns {string} The prefix, defaults to '/_portal' + */ + static get_prefix() { + return window.rsxapp?.portal?.prefix || Rsx_Portal.URL_PREFIX; + } + + /** + * Check if portal is using a dedicated domain (vs URL prefix) + * + * @returns {boolean} True if dedicated domain is configured + */ + static has_dedicated_domain() { + return !!Rsx_Portal.get_domain(); + } + + // ========================================================================= + // Portal Context Detection + // ========================================================================= + + /** + * Check if currently in portal context + * + * @returns {boolean} True if this is a portal page + */ + static is_portal() { + if (Rsx_Portal._is_portal !== null) { + return Rsx_Portal._is_portal; + } + + // Check rsxapp flag (set by portal bootstrap) + Rsx_Portal._is_portal = !!window.rsxapp?.is_portal; + + return Rsx_Portal._is_portal; + } + + /** + * Get the current portal user + * + * @returns {Object|null} Portal user data or null if not logged in + */ + static user() { + if (!Rsx_Portal.is_portal()) { + return null; + } + return window.rsxapp?.user || null; + } + + // ========================================================================= + // Route Generation + // ========================================================================= + + /** + * Generate URL for a portal route + * + * Similar to Rsx.Route() but: + * - Returns URLs with portal domain or prefix + * - Only works with portal routes + * + * Usage examples: + * ```javascript + * // Portal action route + * const url = Rsx_Portal.Route('Portal_Dashboard_Action'); + * // Development: /_portal/dashboard + * + * // Route with integer parameter (sets 'id') + * const url = Rsx_Portal.Route('Portal_Project_View_Action', 123); + * // Development: /_portal/projects/123 + * + * // Route with named parameters + * const url = Rsx_Portal.Route('Portal_Project_View_Action', {id: 123, tab: 'files'}); + * // Development: /_portal/projects/123?tab=files + * + * // Placeholder route + * const url = Rsx_Portal.Route('Future_Portal_Feature::#index'); + * // Returns: # + * ``` + * + * @param {string} action Controller class, SPA action, or "Class::method" + * @param {number|Object} [params=null] Route parameters + * @returns {string} The generated URL (includes portal prefix in dev mode) + */ + static Route(action, params = null) { + // Parse action into class_name and action_name + let class_name, action_name; + if (action.includes('::')) { + [class_name, action_name] = action.split('::', 2); + } else { + class_name = action; + action_name = 'index'; + } + + // Normalize params to object + let params_obj = {}; + if (typeof params === 'number') { + params_obj = { id: params }; + } else if (typeof params === 'string' && /^\d+$/.test(params)) { + params_obj = { id: parseInt(params, 10) }; + } else if (params && typeof params === 'object') { + params_obj = params; + } else if (params !== null && params !== undefined) { + throw new Error('Params must be number, object, or null'); + } + + // Placeholder route: action starts with # means unimplemented/scaffolding + if (action_name.startsWith('#')) { + return '#'; + } + + // Check if route exists in portal route definitions + let pattern = null; + + if (Rsx_Portal._routes[class_name] && Rsx_Portal._routes[class_name][action_name]) { + const route_patterns = Rsx_Portal._routes[class_name][action_name]; + pattern = Rsx_Portal._select_best_route_pattern(route_patterns, params_obj); + + if (!pattern) { + const route_list = route_patterns.join(', '); + throw new Error( + `No suitable portal route found for ${class_name}::${action_name} with provided parameters. ` + + `Available routes: ${route_list}` + ); + } + } else { + // Not found in portal routes - try SPA action route + pattern = Rsx_Portal._try_spa_action_route(class_name, params_obj); + + if (!pattern) { + throw new Error( + `Portal route not found for ${action}. ` + + `Ensure the class has a @portal_route decorator.` + ); + } + } + + // Generate base URL from pattern + const path = Rsx_Portal._generate_url_from_pattern(pattern, params_obj); + + // Apply portal prefix (in dev mode) or return as-is (domain mode) + return Rsx_Portal._apply_portal_base(path); + } + + /** + * Apply portal domain or prefix to a path + * + * @param {string} path The route path (e.g., '/dashboard') + * @returns {string} Path with portal prefix (dev) or plain path (domain mode) + * @private + */ + static _apply_portal_base(path) { + // If using dedicated domain, path is relative to that domain + if (Rsx_Portal.has_dedicated_domain()) { + return path; + } + + // Development mode: prepend prefix + const prefix = Rsx_Portal.get_prefix(); + return prefix + path; + } + + /** + * Select the best matching route pattern from available patterns + * + * @param {Array} patterns Array of route patterns + * @param {Object} params_obj Provided parameters + * @returns {string|null} Selected pattern or null if none match + * @private + */ + static _select_best_route_pattern(patterns, params_obj) { + const satisfiable = []; + + for (const pattern of patterns) { + // Extract required parameters from pattern + const required_params = []; + const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g); + if (matches) { + for (const match of matches) { + required_params.push(match.substring(1)); + } + } + + // Check if all required parameters are provided + let can_satisfy = true; + for (const required of required_params) { + if (!(required in params_obj)) { + can_satisfy = false; + break; + } + } + + if (can_satisfy) { + satisfiable.push({ + pattern: pattern, + param_count: required_params.length + }); + } + } + + if (satisfiable.length === 0) { + return null; + } + + // Sort by parameter count descending (most parameters first) + satisfiable.sort((a, b) => b.param_count - a.param_count); + + return satisfiable[0].pattern; + } + + /** + * Generate URL from route pattern by replacing parameters + * + * @param {string} pattern The route pattern (e.g., '/projects/:id') + * @param {Object} params Parameters to fill into the route + * @returns {string} The generated URL + * @private + */ + static _generate_url_from_pattern(pattern, params) { + // Extract required parameters from the pattern + const required_params = []; + const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g); + if (matches) { + for (const match of matches) { + required_params.push(match.substring(1)); + } + } + + // Check for required parameters + const missing = []; + for (const required of required_params) { + if (!(required in params)) { + missing.push(required); + } + } + + if (missing.length > 0) { + throw new Error(`Required parameters [${missing.join(', ')}] are missing for portal route ${pattern}`); + } + + // Build the URL by replacing parameters + let url = pattern; + const used_params = {}; + + for (const param_name of required_params) { + const value = params[param_name]; + const encoded_value = encodeURIComponent(value); + url = url.replace(':' + param_name, encoded_value); + used_params[param_name] = true; + } + + // Collect extra parameters for query string + const internal_params = ['_loader_title_hint']; + const query_params = {}; + for (const key in params) { + if (!used_params[key] && !internal_params.includes(key)) { + query_params[key] = params[key]; + } + } + + // Append query string if there are extra parameters + if (Object.keys(query_params).length > 0) { + const query_string = Object.entries(query_params) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + url += '?' + query_string; + } + + return url; + } + + /** + * Try to find a route pattern for a portal SPA action class + * + * @param {string} class_name The action class name + * @param {Object} params_obj The parameters for route selection + * @returns {string|null} The route pattern or null + * @private + */ + static _try_spa_action_route(class_name, params_obj) { + // Get all classes from manifest + const all_classes = Manifest.get_all_classes(); + + // Find the class by name + for (const class_info of all_classes) { + if (class_info.class_name === class_name) { + const class_object = class_info.class_object; + + // Check if it's a SPA action with portal routes + if (typeof Spa_Action !== 'undefined' && + class_object.prototype instanceof Spa_Action) { + + // Get route patterns from decorator metadata + // Portal SPA actions use @route() decorator (stores in _spa_routes) + // and @portal_spa() decorator (sets _is_portal_spa = true) + const routes = class_object._spa_routes || []; + + // Only match if this is a portal SPA action + if (routes.length > 0 && class_object._is_portal_spa) { + const selected = Rsx_Portal._select_best_route_pattern(routes, params_obj); + + if (!selected) { + throw new Error( + `No suitable portal route found for SPA action ${class_name} with provided parameters. ` + + `Available routes: ${routes.join(', ')}` + ); + } + + return selected; + } + } + + return null; + } + } + + return null; + } + + /** + * Define portal routes from bundled data + * Called by generated JavaScript in portal bundles + * + * @param {Object} routes Route definitions object + */ + static _define_routes(routes) { + for (const class_name in routes) { + if (!Rsx_Portal._routes[class_name]) { + Rsx_Portal._routes[class_name] = {}; + } + for (const method_name in routes[class_name]) { + Rsx_Portal._routes[class_name][method_name] = routes[class_name][method_name]; + } + } + } + + /** + * Clear cached state (for testing) + */ + static _clear_cache() { + Rsx_Portal._is_portal = null; + Rsx_Portal._routes = {}; + } +} diff --git a/app/RSpade/Core/Models/User_Model.php b/app/RSpade/Core/Models/User_Model.php index f1522431b..87936c8fe 100644 --- a/app/RSpade/Core/Models/User_Model.php +++ b/app/RSpade/Core/Models/User_Model.php @@ -23,46 +23,41 @@ use App\RSpade\Core\Models\User_Profile_Model; * See: php artisan rsx:man acls */ + /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2026-01-29 08:25:50 - * Table: users - * - * @property int $id - * @property int $login_user_id - * @property int $site_id - * @property mixed $first_name - * @property mixed $last_name - * @property mixed $phone - * @property int $role_id - * @property bool $is_enabled - * @property int $user_role_id - * @property mixed $email - * @property string $deleted_at - * @property string $created_at - * @property string $updated_at - * @property int $created_by - * @property int $updated_by - * @property int $deleted_by - * @property mixed $invite_code - * @property string $invite_accepted_at - * @property string $invite_expires_at - * - * @property-read string $role_id__label - * @property-read string $role_id__constant - * - * @method static array role_id__enum() Get all enum definitions with full metadata - * @method static array role_id__enum_select() Get selectable items for dropdowns - * @method static array role_id__enum_labels() Get simple id => label map - * @method static array role_id__enum_ids() Get array of all valid enum IDs - * + * _AUTO_GENERATED_ + * @property integer $id + * @property integer $login_user_id + * @property integer $site_id + * @property string $first_name + * @property string $last_name + * @property string $phone + * @property integer $role_id + * @property boolean $is_enabled + * @property integer $user_role_id + * @property string $email + * @property \Carbon\Carbon $deleted_at + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property integer $created_by + * @property integer $updated_by + * @property integer $deleted_by + * @property string $invite_code + * @property \Carbon\Carbon $invite_accepted_at + * @property \Carbon\Carbon $invite_expires_at + * @method static mixed role_id_enum() + * @method static mixed role_id_enum_select() + * @method static mixed role_id_enum_ids() + * @property-read mixed $role_id_constant + * @property-read mixed $role_id_label + * @property-read mixed $role_id_permissions + * @property-read mixed $role_id_can_admin_roles + * @property-read mixed $role_id_selectable * @mixin \Eloquent */ class User_Model extends Rsx_Site_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const ROLE_DEVELOPER = 100; const ROLE_ROOT_ADMIN = 200; const ROLE_SITE_OWNER = 300; @@ -71,9 +66,6 @@ class User_Model extends Rsx_Site_Model_Abstract const ROLE_USER = 600; const ROLE_VIEWER = 700; const ROLE_DISABLED = 800; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ // ========================================================================= diff --git a/app/RSpade/Core/Models/User_Verification_Model.php b/app/RSpade/Core/Models/User_Verification_Model.php index fd56473a0..bdad29a8e 100644 --- a/app/RSpade/Core/Models/User_Verification_Model.php +++ b/app/RSpade/Core/Models/User_Verification_Model.php @@ -11,44 +11,33 @@ use App\RSpade\Core\Database\Models\Rsx_Model_Abstract; * and two-factor authentication via email or SMS. */ + /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2026-01-29 08:25:50 - * Table: user_verifications - * - * @property int $id - * @property mixed $email - * @property mixed $verification_code - * @property int $verification_type_id - * @property string $verified_at - * @property string $expires_at - * @property string $created_at - * @property string $updated_at - * @property int $created_by - * @property int $updated_by - * - * @property-read string $verification_type_id__label - * @property-read string $verification_type_id__constant - * - * @method static array verification_type_id__enum() Get all enum definitions with full metadata - * @method static array verification_type_id__enum_select() Get selectable items for dropdowns - * @method static array verification_type_id__enum_labels() Get simple id => label map - * @method static array verification_type_id__enum_ids() Get array of all valid enum IDs - * + * _AUTO_GENERATED_ + * @property integer $id + * @property string $email + * @property string $verification_code + * @property integer $verification_type_id + * @property \Carbon\Carbon $verified_at + * @property \Carbon\Carbon $expires_at + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property integer $created_by + * @property integer $updated_by + * @method static mixed verification_type_id_enum() + * @method static mixed verification_type_id_enum_select() + * @method static mixed verification_type_id_enum_ids() + * @property-read mixed $verification_type_id_constant + * @property-read mixed $verification_type_id_label * @mixin \Eloquent */ class User_Verification_Model extends Rsx_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const VERIFICATION_TYPE_EMAIL = 1; const VERIFICATION_TYPE_SMS = 2; const VERIFICATION_TYPE_EMAIL_RECOVERY = 3; const VERIFICATION_TYPE_SMS_RECOVERY = 4; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ /** diff --git a/app/RSpade/Core/Portal/Portal_Dispatcher.php b/app/RSpade/Core/Portal/Portal_Dispatcher.php new file mode 100755 index 000000000..687e5dcdf --- /dev/null +++ b/app/RSpade/Core/Portal/Portal_Dispatcher.php @@ -0,0 +1,412 @@ +setMethod('GET'); + } + + // Find matching portal route + console_debug('PORTAL', "Looking for portal route: {$normalized_url}, method: {$route_method}"); + $route_match = static::__find_portal_route($normalized_url, $route_method); + + if (!$route_match) { + console_debug('PORTAL', "No portal route found for: {$normalized_url}"); + + // Call unhandled_route hook from Portal_Main + $unhandled_response = static::__call_portal_unhandled_route($request, $extra_params); + if ($unhandled_response !== null) { + return $unhandled_response; + } + + return null; + } + + console_debug('PORTAL', "Found portal route: {$route_match['class']}::{$route_match['method']}"); + + // Call pre_dispatch hooks with handler info + $pre_dispatch_params = array_merge($route_match['params'], [ + '_handler' => $route_match['class'] . '::' . $route_match['method'], + '_class' => $route_match['class'], + '_method' => $route_match['method'], + ]); + $pre_dispatch_response = static::__call_portal_pre_dispatch($request, $pre_dispatch_params); + if ($pre_dispatch_response !== null) { + return $pre_dispatch_response; + } + + // Track current controller/action for portal context + Rsx_Portal::_set_current_controller_action( + $route_match['class'], + $route_match['method'], + $route_match['type'] ?? 'standard' + ); + + // Load controller class (autoloaded by framework) + $controller_class = $route_match['class']; + + // Call controller pre_dispatch if it exists + if (method_exists($controller_class, 'pre_dispatch')) { + $controller_response = $controller_class::pre_dispatch($request, $route_match['params']); + if ($controller_response !== null) { + return $controller_response; + } + } + + // Execute the action + $action_method = $route_match['method']; + if (!method_exists($controller_class, $action_method)) { + throw new \RuntimeException("Portal action method not found: {$controller_class}::{$action_method}"); + } + + console_debug('PORTAL', "Executing: {$controller_class}::{$action_method}"); + $result = $controller_class::$action_method($request, $route_match['params']); + + // Build response + return static::__build_response($result, $original_method, $request); + } + + /** + * Find matching portal route + * + * @param string $url URL to match + * @param string $method HTTP method + * @return array|null Route match data or null + */ + protected static function __find_portal_route(string $url, string $method): ?array + { + $manifest = Manifest::get_full_manifest(); + $portal_routes = $manifest['data']['portal_routes'] ?? []; + + if (empty($portal_routes)) { + return null; + } + + // Sort routes for deterministic matching + $sorted_routes = []; + foreach ($portal_routes as $pattern => $route_data) { + $sorted_routes[] = array_merge($route_data, ['pattern' => $pattern]); + } + + // Sort by specificity (longer patterns first, static before dynamic) + // Catch-all routes (/*) should always be matched last + usort($sorted_routes, function ($a, $b) { + // Catch-all routes should be last + $a_catchall = str_contains($a['pattern'], '*'); + $b_catchall = str_contains($b['pattern'], '*'); + + if ($a_catchall !== $b_catchall) { + return $a_catchall ? 1 : -1; // Catch-all routes last + } + + // Count path segments + $a_segments = count(explode('/', trim($a['pattern'], '/'))); + $b_segments = count(explode('/', trim($b['pattern'], '/'))); + + if ($a_segments !== $b_segments) { + return $b_segments <=> $a_segments; // More segments first + } + + // Count dynamic segments (: params and {} params) + $a_dynamic = substr_count($a['pattern'], ':') + substr_count($a['pattern'], '{'); + $b_dynamic = substr_count($b['pattern'], ':') + substr_count($b['pattern'], '{'); + + return $a_dynamic <=> $b_dynamic; // Fewer dynamic first + }); + + // Try to match each route + foreach ($sorted_routes as $route_data) { + $pattern = $route_data['pattern']; + $route_methods = $route_data['methods'] ?? ['GET']; + + // Check HTTP method + if (!in_array($method, $route_methods)) { + continue; + } + + // Try to match the pattern + $params = RouteResolver::match($url, $pattern); + + if ($params !== false) { + return [ + 'class' => $route_data['class'], + 'method' => $route_data['method'], + 'params' => $params, + 'pattern' => $pattern, + 'type' => $route_data['type'] ?? 'portal', + 'require' => $route_data['require'] ?? [], + ]; + } + } + + return null; + } + + /** + * Call Portal_Main::pre_dispatch if it exists + * + * @param Request $request + * @param array $params + * @return mixed|null Response or null to continue + */ + protected static function __call_portal_pre_dispatch(Request $request, array $params) + { + // Find classes extending Portal_Main_Abstract via manifest + $portal_main_classes = Manifest::php_get_extending('Portal_Main_Abstract'); + + foreach ($portal_main_classes as $portal_main_class) { + if (isset($portal_main_class['fqcn']) && $portal_main_class['fqcn']) { + $class_name = $portal_main_class['fqcn']; + if (method_exists($class_name, 'pre_dispatch')) { + console_debug('PORTAL', "Calling {$class_name}::pre_dispatch"); + $result = $class_name::pre_dispatch($request, $params); + if ($result !== null) { + return $result; + } + } + } + } + + return null; + } + + /** + * Call Portal_Main::unhandled_route if it exists + * + * @param Request $request + * @param array $params + * @return mixed|null Response or null for default 404 + */ + protected static function __call_portal_unhandled_route(Request $request, array $params) + { + // Find classes extending Portal_Main_Abstract via manifest + $portal_main_classes = Manifest::php_get_extending('Portal_Main_Abstract'); + + foreach ($portal_main_classes as $portal_main_class) { + if (isset($portal_main_class['fqcn']) && $portal_main_class['fqcn']) { + $class_name = $portal_main_class['fqcn']; + if (method_exists($class_name, 'unhandled_route')) { + console_debug('PORTAL', "Calling {$class_name}::unhandled_route"); + $result = $class_name::unhandled_route($request, $params); + if ($result !== null) { + return $result; + } + } + } + } + + return null; + } + + /** + * Build appropriate response from handler result + * + * @param mixed $result Handler result + * @param string $method Original HTTP method + * @param Request $request + * @return mixed + */ + protected static function __build_response($result, string $method, Request $request) + { + // If already a Response, return as-is + if ($result instanceof \Illuminate\Http\Response || + $result instanceof \Symfony\Component\HttpFoundation\Response) { + // For HEAD requests, strip the body + if ($method === 'HEAD') { + $result->setContent(''); + } + + return $result; + } + + // If a View object, render it to a response + if ($result instanceof \Illuminate\Contracts\View\View) { + $response = response($result->render()); + + if ($method === 'HEAD') { + $response->setContent(''); + } + + return $response; + } + + // If array, return as JSON + if (is_array($result)) { + $response = response()->json($result); + + if ($method === 'HEAD') { + $response->setContent(''); + } + + return $response; + } + + // If string, return as HTML + if (is_string($result)) { + $response = response($result); + + if ($method === 'HEAD') { + $response->setContent(''); + } + + return $response; + } + + // If null, let caller handle (likely 404) + return $result; + } + + /** + * Handle dev auth headers for rsx:debug testing + * + * This allows rsx:debug to authenticate as any portal user in development. + * The token is validated using APP_KEY to ensure only authorized requests. + * + * @param Request $request + * @param string $url + * @return void + */ + protected static function __handle_dev_auth(Request $request, string $url): void + { + // Only in non-production environments + if (app()->environment('production')) { + return; + } + + // Check for portal dev auth header + $portal_user_id = $request->header('X-Dev-Auth-Portal-User-Id'); + if (!$portal_user_id) { + return; + } + + $token = $request->header('X-Dev-Auth-Token'); + if (!$token) { + console_debug('PORTAL', 'Dev auth: Missing token header'); + return; + } + + // Validate the token + $app_key = config('app.key'); + if (!$app_key) { + console_debug('PORTAL', 'Dev auth: APP_KEY not configured'); + return; + } + + // Normalize URL for token validation (strip /_portal prefix) + $normalized_url = static::normalize_portal_url($url); + + // Recreate the expected token payload + $expected_payload = json_encode([ + 'url' => $normalized_url, + 'user_id' => (int) $portal_user_id, + 'portal' => true, + ]); + + $expected_token = hash_hmac('sha256', $expected_payload, $app_key); + + if (!hash_equals($expected_token, $token)) { + console_debug('PORTAL', 'Dev auth: Token validation failed'); + return; + } + + // Token is valid - authenticate as the portal user + $portal_user = Portal_User_Model::find((int) $portal_user_id); + if (!$portal_user) { + console_debug('PORTAL', "Dev auth: Portal user not found: {$portal_user_id}"); + return; + } + + // Log the user in using the portal user's site_id + Portal_Session::set_portal_user_id((int) $portal_user_id, $portal_user->site_id); + console_debug('PORTAL', "Dev auth: Logged in as portal user {$portal_user_id}"); + } +} diff --git a/app/RSpade/Core/Portal/Portal_Main_Abstract.php b/app/RSpade/Core/Portal/Portal_Main_Abstract.php new file mode 100755 index 000000000..49663f93a --- /dev/null +++ b/app/RSpade/Core/Portal/Portal_Main_Abstract.php @@ -0,0 +1,61 @@ + $metadata) { + // Check public static method attributes for Portal_Route + if (isset($metadata['public_static_methods'])) { + foreach ($metadata['public_static_methods'] as $method_name => $method_data) { + if (isset($method_data['attributes'])) { + foreach ($method_data['attributes'] as $attr_name => $attr_instances) { + // Check if this is a Portal_Route attribute + if (str_ends_with($attr_name, '\\Portal_Route') || $attr_name === 'Portal_Route') { + $portal_route_classes[] = [ + 'file' => $file, + 'class' => $metadata['class'] ?? null, + 'fqcn' => $metadata['fqcn'] ?? null, + 'method' => $method_name, + 'type' => 'method', + 'instances' => $attr_instances, + ]; + } + } + } + } + } + } + + foreach ($portal_route_classes as $item) { + if ($item['type'] === 'method') { + foreach ($item['instances'] as $route_args) { + $pattern = $route_args[0] ?? ($route_args['pattern'] ?? null); + $methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']); + $name = $route_args[2] ?? ($route_args['name'] ?? null); + + if ($pattern) { + // Ensure pattern starts with / + if ($pattern[0] !== '/') { + $pattern = '/' . $pattern; + } + + // Type is always 'portal' for routes with #[Portal_Route] attribute + $type = 'portal'; + + // Extract Auth attributes for this method (portal-specific auth would use Portal_Auth or similar) + $require_attrs = []; + $file_metadata = $files[$item['file']] ?? null; + if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Portal_Auth'])) { + $require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Portal_Auth']; + } + + // Check for duplicate portal route definition + if (isset($manifest_data['data']['portal_routes'][$pattern])) { + $existing = $manifest_data['data']['portal_routes'][$pattern]; + $existing_location = "{$existing['class']}::{$existing['method']} in {$existing['file']}"; + + throw new \RuntimeException( + "Duplicate portal route definition: {$pattern}\n" . + " Already defined: {$existing_location}\n" . + " Conflicting: {$item['fqcn']}::{$item['method']} in {$item['file']}" + ); + } + + // Store route with flat structure (for portal dispatcher) + $route_data = [ + 'methods' => array_map('strtoupper', (array) $methods), + 'type' => $type, + 'class' => $item['fqcn'] ?? $item['class'], + 'method' => $item['method'], + 'name' => $name, + 'file' => $item['file'], + 'require' => $require_attrs, + 'pattern' => $pattern, + ]; + + $manifest_data['data']['portal_routes'][$pattern] = $route_data; + + // Also store by target for URL generation + $target = $item['class'] . '::' . $item['method']; + if (!isset($manifest_data['data']['portal_routes_by_target'][$target])) { + $manifest_data['data']['portal_routes_by_target'][$target] = []; + } + $manifest_data['data']['portal_routes_by_target'][$target][] = $route_data; + } + } + } + } + + // Sort routes alphabetically by path + ksort($manifest_data['data']['portal_routes']); + ksort($manifest_data['data']['portal_routes_by_target']); + } +} diff --git a/app/RSpade/Core/Portal/Portal_Session.php b/app/RSpade/Core/Portal/Portal_Session.php new file mode 100755 index 000000000..01653c7aa --- /dev/null +++ b/app/RSpade/Core/Portal/Portal_Session.php @@ -0,0 +1,729 @@ + 'integer', + 'portal_user_id' => 'integer', + 'last_active' => 'datetime', + ]; + + /** + * Columns that should never be exported to JavaScript + * @var array + */ + protected $neverExport = [ + 'session_token', + 'csrf_token', + 'ip_address', + ]; + + /** + * Check if running in CLI mode + * @return bool + */ + private static function __is_cli(): bool + { + return php_sapi_name() === 'cli'; + } + + /** + * Initialize session from cookie + * Loads existing session but does not create new one + * In CLI mode: does nothing + * @return void + */ + public static function init(): void + { + if (self::$_has_init) { + return; + } + self::$_has_init = true; + + // CLI mode: do nothing + if (self::__is_cli()) { + return; + } + + Manifest::init(); + + // Try to get session token from cookie + $session_token = $_COOKIE[self::COOKIE_NAME] ?? null; + + if (empty($session_token)) { + self::$_session = null; + + return; + } + + // Load session + $session = static::where('session_token', $session_token)->first(); + + if (!$session) { + self::$_session = null; + + return; + } + + // Update last activity + static::where('id', $session->id)->update(['last_active' => now()]); + + // Reload the session to ensure we have the latest version + $session = static::find($session->id); + + self::$_session_token = $session_token; + self::$_session = $session; + + self::_set_cookie(); + } + + /** + * Activate session - creates new one if needed + * In CLI mode: does nothing + * @param int $site_id Required site ID for new sessions + * @return void + */ + private static function __activate(int $site_id = 0): void + { + if (self::$_has_activate) { + return; + } + self::$_has_activate = true; + + // CLI mode: do nothing + if (self::__is_cli()) { + return; + } + + self::init(); + + // If no session exists, create one + if (empty(self::$_session)) { + if ($site_id === 0) { + throw new \RuntimeException('Portal session requires site_id for activation'); + } + + // Generate cryptographically secure token + self::$_session_token = bin2hex(random_bytes(32)); + + // Generate CSRF token + $csrf_token = bin2hex(random_bytes(32)); + + $session = new static(); + $session->session_token = self::$_session_token; + $session->csrf_token = $csrf_token; + $session->ip_address = self::__get_client_ip(); + $session->user_agent = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255); + $session->last_active = now(); + $session->site_id = $site_id; + $session->portal_user_id = null; + + $session->save(); + + self::$_session = $session; + self::_set_cookie(); + } + } + + /** + * Set the session cookie with security flags + * In CLI mode: does nothing + * @return void + */ + private static function _set_cookie(): void + { + if (self::$_has_set_cookie) { + return; + } + self::$_has_set_cookie = true; + + // CLI mode: do nothing + if (self::__is_cli()) { + return; + } + + $lifetime_days = config('rsx.portal.session_lifetime_days', 30); + + // Set cookie with security flags + setcookie(self::COOKIE_NAME, self::$_session_token, [ + 'expires' => time() + ($lifetime_days * 86400), + 'path' => '/', + 'domain' => '', // Current domain only + 'secure' => true, // HTTPS only + 'httponly' => true, // No JavaScript access + 'samesite' => 'Lax', // CSRF protection + ]); + } + + /** + * Get client IP address, handling proxies + * In CLI mode: returns "CLI" + * @return string + */ + private static function __get_client_ip(): string + { + // CLI mode: return "CLI" + if (self::__is_cli()) { + return 'CLI'; + } + + // Check for forwarded IP (when behind proxy/CDN) + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); + + return trim($ips[0]); + } + + if (!empty($_SERVER['HTTP_X_REAL_IP'])) { + return $_SERVER['HTTP_X_REAL_IP']; + } + + return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + } + + /** + * Reset/logout the current session + * @return void + */ + public static function reset(): void + { + self::init(); + + if (!empty(self::$_session)) { + self::$_session->delete(); + } + + self::$_session = null; + self::$_site = null; + self::$_portal_user = null; + self::$_has_init = false; + self::$_has_activate = false; + self::$_has_set_cookie = false; + + // Clear cookie + setcookie(self::COOKIE_NAME, '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'domain' => '', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } + + /** + * Get site ID for current session + * In CLI mode: returns static CLI property + * If no session exists, detects site from domain/config + * @return int + */ + public static function get_site_id(): int + { + // CLI mode: return static property + if (self::__is_cli()) { + return self::$_cli_site_id ?? 0; + } + + self::init(); + + // If session exists, use its site_id + if (!empty(self::$_session)) { + return self::$_session->site_id ?? 0; + } + + // No session - detect site from context + return self::detect_site_id(); + } + + /** + * Detect site ID from request context (domain or config default) + * Used when no session exists yet (e.g., login page) + * @return int + */ + public static function detect_site_id(): int + { + // Check for portal domain in config that matches current host + $portal_domain = Rsx_Portal::get_domain(); + if (!empty($portal_domain)) { + $request_host = $_SERVER['HTTP_HOST'] ?? ''; + if (strtolower($request_host) === strtolower($portal_domain)) { + // In production, could look up site by portal_domain + // For now, use default site + return config('rsx.portal.default_site_id', 1); + } + } + + // Development mode or no specific domain - use default site + return config('rsx.portal.default_site_id', 1); + } + + /** + * Get site model for current session + * @return Site_Model|null + */ + public static function get_site() + { + $site_id = self::get_site_id(); + + if ($site_id === 0) { + return null; + } + + if (empty(self::$_site)) { + self::$_site = Site_Model::find($site_id); + } + + return self::$_site; + } + + /** + * Get portal user ID for current session + * In CLI mode: returns static CLI property + * @return int|null + */ + public static function get_portal_user_id() + { + // CLI mode: return static property + if (self::__is_cli()) { + return self::$_cli_portal_user_id; + } + + self::init(); + + if (empty(self::$_session)) { + return null; + } + + return self::$_session->portal_user_id; + } + + /** + * Check if portal user is logged in + * @return bool + */ + public static function is_logged_in(): bool + { + return !empty(self::get_portal_user_id()); + } + + /** + * Get portal user model for current session + * @return \Portal_User_Model|null + */ + public static function get_user() + { + $portal_user_id = self::get_portal_user_id(); + + if (empty($portal_user_id)) { + return null; + } + + if (empty(self::$_portal_user)) { + self::$_portal_user = \Portal_User_Model::find($portal_user_id); + } + + return self::$_portal_user; + } + + /** + * Alias for get_user() to match naming convention + * @return \Portal_User_Model|null + */ + public static function get_portal_user() + { + return self::get_user(); + } + + /** + * Get current session model (creates if needed) + * @param int $site_id Required for new sessions + * @return Portal_Session + */ + public static function get_session(int $site_id = 0): Portal_Session + { + self::__activate($site_id); + + return self::$_session; + } + + /** + * Get current session ID (creates session if needed) + * @param int $site_id Required for new sessions + * @return int + */ + public static function get_session_id(int $site_id = 0): int + { + self::__activate($site_id); + + return self::$_session->id; + } + + /** + * Get CSRF token for current session + * @return string|null + */ + public static function get_csrf_token(): ?string + { + self::init(); + + if (empty(self::$_session)) { + return null; + } + + return self::$_session->csrf_token; + } + + /** + * Verify CSRF token + * @param string $token + * @return bool + */ + public static function verify_csrf_token(string $token): bool + { + self::init(); + + if (empty(self::$_session)) { + return false; + } + + // Use constant-time comparison + return hash_equals(self::$_session->csrf_token, $token); + } + + /** + * Logout current portal user + * @return void + */ + public static function logout(): void + { + self::set_portal_user_id(null); + } + + /** + * Set portal user ID for current session (login/logout) + * In CLI mode: sets static CLI property only, no database + * @param int|null $portal_user_id Portal user ID, or null to logout + * @param int $site_id Required for new sessions when logging in + * @return void + */ + public static function set_portal_user_id(?int $portal_user_id, int $site_id = 0): void + { + // Logout if null/0 + if (empty($portal_user_id)) { + // CLI mode: clear static property only + if (self::__is_cli()) { + self::$_cli_portal_user_id = null; + self::$_portal_user = null; + self::$_site = null; + + return; + } + + self::init(); + if (!empty(self::$_session)) { + self::$_session->portal_user_id = null; + self::$_session->save(); + } + + self::$_portal_user = null; + self::$_site = null; + + return; + } + + // CLI mode: set static property only + if (self::__is_cli()) { + self::$_cli_portal_user_id = $portal_user_id; + self::$_portal_user = null; + self::$_site = null; + + return; + } + + self::__activate($site_id); + + // Regenerate session token and CSRF token on login (prevent session fixation) + $new_token = bin2hex(random_bytes(32)); + $new_csrf = bin2hex(random_bytes(32)); + self::$_session->session_token = $new_token; + self::$_session->csrf_token = $new_csrf; + self::$_session->portal_user_id = $portal_user_id; + self::$_session->save(); + + self::$_session_token = $new_token; + self::$_has_set_cookie = false; // Force new cookie + self::_set_cookie(); + + // Clear cached portal_user/site + self::$_portal_user = null; + self::$_site = null; + + // Update portal user's last login timestamp + $portal_user = self::get_user(); + if ($portal_user) { + $portal_user->last_login = now(); + $portal_user->save(); + } + } + + /** + * Check if a session exists + * In CLI mode: returns true if site_id or user_id is set + * In web mode: returns true if session record exists + * @return bool + */ + public static function has_session(): bool + { + // CLI mode: check if site_id or user_id is set + if (self::__is_cli()) { + return self::$_cli_site_id !== null || self::$_cli_portal_user_id !== null; + } + + // Web mode: init and check if session exists + self::init(); + + return !empty(self::$_session); + } + + /** + * Get session by token (for API/external access) + * @param string $token + * @return Portal_Session|null + */ + public static function find_by_token(string $token) + { + return static::where('session_token', $token)->first(); + } + + /** + * Clean up expired sessions (garbage collection) + * @param int $days_until_expiry + * @return int Number of sessions deleted + */ + public static function cleanup_expired(int $days_until_expiry = 30): int + { + return static::where('last_active', '<', now()->subDays($days_until_expiry)) + ->delete(); + } + + /** + * Relationship: Portal User + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function portal_user() + { + return $this->belongsTo(\Portal_User_Model::class, 'portal_user_id'); + } + + /** + * Relationship: Site + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function site() + { + return $this->belongsTo(Site_Model::class, 'site_id'); + } + + // ========================================================================= + // CLI MODE SETTERS + // ========================================================================= + + /** + * Set site ID in CLI mode + * @param int $site_id + * @return void + */ + public static function cli_set_site_id(int $site_id): void + { + self::$_cli_site_id = $site_id; + self::$_site = null; + } + + /** + * Set portal user ID in CLI mode + * @param int $portal_user_id + * @return void + */ + public static function cli_set_portal_user_id(int $portal_user_id): void + { + self::$_cli_portal_user_id = $portal_user_id; + self::$_portal_user = null; + } + + // ========================================================================= + // SESSION MANAGEMENT METHODS + // ========================================================================= + + /** + * Get all active sessions for a portal user + * Returns formatted session info including device parsing + * + * @param int|null $portal_user_id If null, uses current logged-in user + * @return array Array of session info + */ + public static function get_sessions_for_user(?int $portal_user_id = null): array + { + if ($portal_user_id === null) { + $portal_user_id = self::get_portal_user_id(); + } + + if (empty($portal_user_id)) { + return []; + } + + $current_session_id = self::has_session() ? self::$_session?->id : null; + + return static::where('portal_user_id', $portal_user_id) + ->orderBy('last_active', 'desc') + ->get() + ->map(function ($session) use ($current_session_id) { + $parsed_ua = User_Agent::parse($session->user_agent); + + return [ + 'id' => $session->id, + 'ip_address' => $session->ip_address, + 'user_agent' => $session->user_agent, + 'user_agent_parsed' => $parsed_ua, + 'device_summary' => $parsed_ua['summary'], + 'last_active' => $session->last_active, + 'created_at' => $session->created_at, + 'is_current' => $session->id === $current_session_id, + ]; + }) + ->toArray(); + } + + /** + * Terminate a specific session by ID + * Cannot terminate the current session (use logout() instead) + * + * @param int $session_id + * @return bool True if session was terminated, false if not found or is current + */ + public static function terminate_session(int $session_id): bool + { + self::init(); + + // Don't allow terminating current session + if (self::$_session && self::$_session->id === $session_id) { + return false; + } + + $affected = static::where('id', $session_id)->delete(); + + return $affected > 0; + } + + /** + * Terminate all sessions for the current user except the current one + * + * @return int Number of sessions terminated + */ + public static function terminate_all_other_sessions(): int + { + self::init(); + + $portal_user_id = self::get_portal_user_id(); + if (empty($portal_user_id)) { + return 0; + } + + $query = static::where('portal_user_id', $portal_user_id); + + // Exclude current session if we have one + if (self::$_session) { + $query->where('id', '!=', self::$_session->id); + } + + return $query->delete(); + } + + /** + * Terminate all sessions for a specific portal user + * Useful for admin actions or password changes + * + * @param int $portal_user_id + * @param int|null $except_session_id Optional session ID to exclude + * @return int Number of sessions terminated + */ + public static function terminate_all_sessions_for_user(int $portal_user_id, ?int $except_session_id = null): int + { + $query = static::where('portal_user_id', $portal_user_id); + + if ($except_session_id !== null) { + $query->where('id', '!=', $except_session_id); + } + + return $query->delete(); + } +} diff --git a/app/RSpade/Core/Portal/Portal_Spa_ManifestSupport.php b/app/RSpade/Core/Portal/Portal_Spa_ManifestSupport.php new file mode 100755 index 000000000..407575597 --- /dev/null +++ b/app/RSpade/Core/Portal/Portal_Spa_ManifestSupport.php @@ -0,0 +1,195 @@ + $action_metadata) { + // Extract decorator metadata + $decorators = $action_metadata['decorators'] ?? []; + + // Parse decorators into route configuration + $route_info = static::_parse_decorators($decorators); + + // Skip if no @portal_spa decorator (this is a regular SPA action) + if (empty($route_info['portal_spa_controller'])) { + continue; + } + + // Skip if no route decorator found + if (empty($route_info['routes'])) { + continue; + } + + // Find the PHP controller file and metadata + $php_controller_class = $route_info['portal_spa_controller']; + $php_controller_method = $route_info['portal_spa_method']; + $php_controller_file = null; + $php_controller_fqcn = null; + + // Search for the controller in the manifest + foreach ($files as $file => $metadata) { + if (($metadata['class'] ?? null) === $php_controller_class || ($metadata['fqcn'] ?? null) === $php_controller_class) { + $php_controller_file = $file; + $php_controller_fqcn = $metadata['fqcn'] ?? $metadata['class']; + break; + } + } + + if (!$php_controller_file) { + throw new RuntimeException( + "Portal Spa action '{$class_name}' references unknown controller '{$php_controller_class}'.\n" . + "The @portal_spa decorator must reference a valid PHP controller class.\n" . + "File: {$action_metadata['file']}" + ); + } + + // Build complete route metadata for each route pattern + foreach ($route_info['routes'] as $route_pattern) { + // Ensure pattern starts with / + if ($route_pattern[0] !== '/') { + $route_pattern = '/' . $route_pattern; + } + + // Check for duplicate portal route definition + if (isset($manifest_data['data']['portal_routes'][$route_pattern])) { + $existing = $manifest_data['data']['portal_routes'][$route_pattern]; + $existing_type = $existing['type'] ?? 'portal'; + $existing_location = $existing_type === 'portal_spa' + ? "Portal Spa action {$existing['js_action_class']} in {$existing['file']}" + : "{$existing['class']}::{$existing['method']} in {$existing['file']}"; + + throw new RuntimeException( + "Duplicate portal route definition: {$route_pattern}\n" . + " Already defined: {$existing_location}\n" . + " Conflicting: Portal Spa action {$class_name} in {$action_metadata['file']}" + ); + } + + // Store route with unified structure (for portal dispatcher) + $route_data = [ + 'methods' => ['GET'], // Spa routes are always GET + 'type' => 'portal_spa', + 'class' => $php_controller_fqcn, + 'method' => $php_controller_method, + 'name' => null, + 'file' => $php_controller_file, + 'require' => [], + 'js_action_class' => $class_name, + 'pattern' => $route_pattern, + ]; + + $manifest_data['data']['portal_routes'][$route_pattern] = $route_data; + + // Also store by target for URL generation (group multiple routes per action class) + $target = $class_name; // For SPA, target is the JS action class name + if (!isset($manifest_data['data']['portal_routes_by_target'][$target])) { + $manifest_data['data']['portal_routes_by_target'][$target] = []; + } + $manifest_data['data']['portal_routes_by_target'][$target][] = $route_data; + } + } + } + + /** + * Parse decorator metadata into route configuration + * + * @param array $decorators Array of decorator data from manifest + * @return array Parsed route configuration + */ + private static function _parse_decorators(array $decorators): array + { + $config = [ + 'routes' => [], + 'layout' => null, + 'portal_spa_controller' => null, + 'portal_spa_method' => null, + ]; + + foreach ($decorators as $decorator) { + [$name, $args] = $decorator; + + switch ($name) { + case 'route': + // @route('/path') - args is array with single string + if (!empty($args[0])) { + $config['routes'][] = $args[0]; + } + break; + + case 'layout': + // @layout('Layout_Name') - args is array with single string + if (!empty($args[0])) { + $config['layout'] = $args[0]; + } + break; + + case 'portal_spa': + // @portal_spa('Controller::method') - args is array with single string + if (!empty($args[0])) { + $parts = explode('::', $args[0]); + if (count($parts) === 2) { + $config['portal_spa_controller'] = $parts[0]; + $config['portal_spa_method'] = $parts[1]; + } + } + break; + } + } + + return $config; + } +} diff --git a/app/RSpade/Core/Portal/Rsx_Portal.php b/app/RSpade/Core/Portal/Rsx_Portal.php new file mode 100755 index 000000000..db4be0f33 --- /dev/null +++ b/app/RSpade/Core/Portal/Rsx_Portal.php @@ -0,0 +1,481 @@ + $params]; + } elseif (is_array($params)) { + $params_array = $params; + } elseif ($params instanceof \stdClass) { + $params_array = (array) $params; + } elseif ($params !== null) { + throw new RuntimeException("Params must be integer, array, stdClass, or null"); + } + + // Placeholder route + if (str_starts_with($action_name, '#')) { + return '#'; + } + + // Look up routes in manifest using portal_routes_by_target + $manifest = Manifest::get_full_manifest(); + + // Build target - always include ::method for consistency with manifest keys + $target = $class_name . '::' . $action_name; + + // First try direct target lookup (Class::method) + if (!isset($manifest['data']['portal_routes_by_target'][$target])) { + // Allow shorthand: Route('MyController') implies Route('MyController::index') + if ($action_name === 'index' && isset($manifest['data']['portal_routes_by_target'][$class_name])) { + $target = $class_name; + } else { + throw new Rsx_Caller_Exception( + "Portal route not found for {$action}. " . + "Ensure the class has a #[Portal_Route] attribute." + ); + } + } + + $routes = $manifest['data']['portal_routes_by_target'][$target]; + + // Select best matching route + $selected_route = self::_select_best_route($routes, $params_array); + + if (!$selected_route) { + throw new Rsx_Caller_Exception( + "No suitable portal route found for {$action} with provided parameters. " . + "Available routes: " . implode(', ', array_column($routes, 'pattern')) + ); + } + + // Generate base URL from pattern + $path = self::_generate_url_from_pattern($selected_route['pattern'], $params_array); + + // Apply portal prefix/domain + return self::_apply_portal_base($path); + } + + /** + * Generate absolute URL for a portal route + * + * @param string $action Controller class, SPA action, or "Class::method" + * @param int|array|\stdClass|null $params Route parameters + * @return string Full URL including protocol and domain + */ + public static function url($action, $params = null): string + { + $path = self::Route($action, $params); + + // If already has domain, return as-is + if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { + return $path; + } + + // Build absolute URL + $portal_domain = self::get_domain(); + + if (!empty($portal_domain)) { + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + + return $protocol . '://' . $portal_domain . $path; + } + + // No portal domain, use current host with path + return url($path); + } + + /** + * Apply portal domain or prefix to a path + * + * @param string $path The route path (e.g., '/dashboard') + * @return string Path with portal prefix, or full URL if domain configured + */ + protected static function _apply_portal_base(string $path): string + { + // If using dedicated domain in production, keep path as-is + // The domain will be handled at routing level + if (self::has_dedicated_domain()) { + // In production, the path is relative to portal domain + // The caller should use url() if they need full URL + return $path; + } + + // Development mode: prepend prefix + $prefix = self::get_prefix(); + + return $prefix . $path; + } + + /** + * Select the best matching route from available routes + * + * @param array $routes Array of route data from manifest + * @param array $params_array Provided parameters + * @return array|null Selected route data or null if none match + */ + protected static function _select_best_route(array $routes, array $params_array): ?array + { + $satisfiable = []; + + foreach ($routes as $route) { + $pattern = $route['pattern']; + + // Extract required parameters from pattern + $required_params = []; + if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) { + $required_params = $matches[1]; + } + + // Check if all required parameters are provided + $can_satisfy = true; + foreach ($required_params as $required) { + if (!array_key_exists($required, $params_array)) { + $can_satisfy = false; + break; + } + } + + if ($can_satisfy) { + $satisfiable[] = [ + 'route' => $route, + 'param_count' => count($required_params), + ]; + } + } + + if (empty($satisfiable)) { + return null; + } + + // Sort by parameter count descending (most parameters first) + usort($satisfiable, function ($a, $b) { + return $b['param_count'] <=> $a['param_count']; + }); + + return $satisfiable[0]['route']; + } + + /** + * Generate URL from route pattern by replacing parameters + * + * @param string $pattern The route pattern + * @param array $params Parameters to fill in + * @return string The generated URL + */ + protected static function _generate_url_from_pattern(string $pattern, array $params): string + { + // Extract required parameters from the pattern + $required_params = []; + if (preg_match_all('/:([a-zA-Z_][a-zA-Z0-9_]*)/', $pattern, $matches)) { + $required_params = $matches[1]; + } + + // Build the URL by replacing parameters + $url = $pattern; + $used_params = []; + + foreach ($required_params as $param_name) { + if (!array_key_exists($param_name, $params)) { + throw new RuntimeException("Required parameter '{$param_name}' missing for route {$pattern}"); + } + $value = $params[$param_name]; + $encoded_value = urlencode((string) $value); + $url = str_replace(':' . $param_name, $encoded_value, $url); + $used_params[$param_name] = true; + } + + // Collect extra parameters for query string + $query_params = []; + foreach ($params as $key => $value) { + if (!isset($used_params[$key])) { + $query_params[$key] = $value; + } + } + + // Append query string if there are extra parameters + if (!empty($query_params)) { + $url .= '?' . http_build_query($query_params); + } + + return $url; + } + + // ========================================================================= + // Controller/Action Tracking + // ========================================================================= + + /** + * Set the current portal controller and action being executed + * + * @param string $controller_class The controller class name + * @param string $action_method The action method name + * @param string|null $route_type Route type ('spa' or 'standard') + */ + public static function _set_current_controller_action( + string $controller_class, + string $action_method, + ?string $route_type = null + ): void { + // Extract just the class name without namespace + $parts = explode('\\', $controller_class); + $class_name = end($parts); + + static::$current_controller = $class_name; + static::$current_action = $action_method; + static::$current_route_type = $route_type; + } + + /** + * Get the current portal controller class name + * + * @return string|null + */ + public static function get_current_controller(): ?string + { + return static::$current_controller; + } + + /** + * Get the current portal action method name + * + * @return string|null + */ + public static function get_current_action(): ?string + { + return static::$current_action; + } + + /** + * Check if current portal route is a SPA route + * + * @return bool + */ + public static function is_spa(): bool + { + return static::$current_route_type === 'spa' || static::$current_route_type === 'portal_spa'; + } + + /** + * Clear the current controller and action tracking + */ + public static function _clear_current_controller_action(): void + { + static::$current_controller = null; + static::$current_action = null; + static::$current_route_type = null; + } + + /** + * Clear all cached state (for testing) + */ + public static function _clear_cache(): void + { + static::$_is_portal_request = null; + static::$current_controller = null; + static::$current_action = null; + static::$current_route_type = null; + } +} diff --git a/app/RSpade/Core/SPA/Spa.js b/app/RSpade/Core/SPA/Spa.js index 87e1be9b4..a86b13c4b 100755 --- a/app/RSpade/Core/SPA/Spa.js +++ b/app/RSpade/Core/SPA/Spa.js @@ -250,6 +250,14 @@ class Spa { const parsed = Spa.parse_url(url); let path = parsed.path; + // Strip portal prefix if in portal context + if (Rsx_Portal.is_portal() && !Rsx_Portal.has_dedicated_domain()) { + const prefix = Rsx_Portal.get_prefix(); + if (path.startsWith(prefix)) { + path = path.substring(prefix.length) || '/'; + } + } + // Normalize path - remove leading/trailing slashes for matching path = path.substring(1); // Remove leading / diff --git a/app/RSpade/Core/SPA/Spa_Decorators.js b/app/RSpade/Core/SPA/Spa_Decorators.js index 8c04a88c0..dfe031801 100755 --- a/app/RSpade/Core/SPA/Spa_Decorators.js +++ b/app/RSpade/Core/SPA/Spa_Decorators.js @@ -86,3 +86,22 @@ function title(page_title) { return target; }; } + +/** + * @decorator + * Link a Portal Spa action to its PHP controller method + * Used for portal routes which are handled by Portal_Spa_ManifestSupport + * + * Usage: + * @portal_spa('Portal_Spa_Controller::index') + * class Portal_Dashboard_Action extends Spa_Action { } + */ +function portal_spa(controller_method) { + return function (target) { + // Store controller::method reference on the class + target._spa_controller_method = controller_method; + // Mark as portal SPA action + target._is_portal_spa = true; + return target; + }; +} diff --git a/app/RSpade/Core/SPA/Spa_ManifestSupport.php b/app/RSpade/Core/SPA/Spa_ManifestSupport.php index c72cbc519..90b441be9 100644 --- a/app/RSpade/Core/SPA/Spa_ManifestSupport.php +++ b/app/RSpade/Core/SPA/Spa_ManifestSupport.php @@ -51,6 +51,11 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract // Parse decorators into route configuration $route_info = static::_parse_decorators($decorators); + // Skip if this is a portal SPA action (handled by Portal_Spa_ManifestSupport) + if (!empty($route_info['is_portal_spa'])) { + continue; + } + // Skip if no route decorator found if (empty($route_info['routes'])) { continue; @@ -155,6 +160,7 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract 'layout' => null, 'spa_controller' => null, 'spa_method' => null, + 'is_portal_spa' => false, ]; foreach ($decorators as $decorator) { @@ -175,6 +181,11 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract } break; + case 'portal_spa': + // @portal_spa decorator - handled by Portal_Spa_ManifestSupport, skip here + $config['is_portal_spa'] = true; + break; + case 'spa': // @spa('Controller::method') - args is array with single string if (!empty($args[0])) { diff --git a/app/RSpade/Lib/Flash/Flash_Alert_Model.php b/app/RSpade/Lib/Flash/Flash_Alert_Model.php index 725df11e9..e6361dada 100644 --- a/app/RSpade/Lib/Flash/Flash_Alert_Model.php +++ b/app/RSpade/Lib/Flash/Flash_Alert_Model.php @@ -5,41 +5,29 @@ namespace App\RSpade\Lib\Flash; use App\RSpade\Core\Database\Models\Rsx_Model_Abstract; /** - * _AUTO_GENERATED_ Database type hints - do not edit manually - * Generated on: 2026-01-29 08:25:50 - * Table: _flash_alerts - * - * @property int $id - * @property int $session_id - * @property int $type_id + * _AUTO_GENERATED_ + * @property integer $id + * @property integer $session_id + * @property integer $type_id * @property string $message - * @property string $created_at - * @property int $created_by - * @property int $updated_by - * @property string $updated_at - * - * @property-read string $type_id__label - * @property-read string $type_id__constant - * - * @method static array type_id__enum() Get all enum definitions with full metadata - * @method static array type_id__enum_select() Get selectable items for dropdowns - * @method static array type_id__enum_labels() Get simple id => label map - * @method static array type_id__enum_ids() Get array of all valid enum IDs - * + * @property \Carbon\Carbon $created_at + * @property integer $created_by + * @property integer $updated_by + * @property \Carbon\Carbon $updated_at + * @method static mixed type_id_enum() + * @method static mixed type_id_enum_select() + * @method static mixed type_id_enum_ids() + * @property-read mixed $type_id_constant + * @property-read mixed $type_id_label * @mixin \Eloquent */ class Flash_Alert_Model extends Rsx_Model_Abstract - { - /** - * _AUTO_GENERATED_ Enum constants - */ +{ + /** __AUTO_GENERATED: */ const TYPE_SUCCESS = 1; const TYPE_ERROR = 2; const TYPE_INFO = 3; const TYPE_WARNING = 4; - - /** __AUTO_GENERATED: */ - /** __/AUTO_GENERATED */ // Enum constants (auto-generated by rsx:migrate:document_models) diff --git a/app/RSpade/man/rsx_debug.txt b/app/RSpade/man/rsx_debug.txt index 38a53d9fd..6f36a1066 100755 --- a/app/RSpade/man/rsx_debug.txt +++ b/app/RSpade/man/rsx_debug.txt @@ -27,6 +27,16 @@ CORE OPTIONS running test. Uses backdoor authentication that only works in development environments. +--portal + Enable portal mode for testing client portal routes. When set, the URL + is automatically prefixed with /_portal/ (if not already present). + Use with --portal-user to authenticate as a portal user. + +--portal-user= + Test as a specific portal user, bypassing portal authentication. + Accepts either a numeric portal user ID or email address. Requires + --portal flag. Validates portal user exists before running test. + --no-body Suppress HTTP response body output. Useful when you only want to see headers, status code, console errors, or other diagnostic information. @@ -281,6 +291,17 @@ Test a protected route as user ID 1: Test a protected route by email: php artisan rsx:debug /admin/users --user=admin@example.com +Test a portal route as portal user ID 1: + php artisan rsx:debug /dashboard --portal --portal-user=1 + +Test a portal route by email: + php artisan rsx:debug /mail --portal --portal-user=client@example.com + +Test a portal route (URL with or without /_portal/ prefix): + php artisan rsx:debug /_portal/dashboard --portal --portal-user=1 + php artisan rsx:debug /dashboard --portal --portal-user=1 + # Both are equivalent - prefix is normalized + Check if JavaScript errors occur: php artisan rsx:debug /page # Console errors are always shown diff --git a/bin/route-debug.js b/bin/route-debug.js index dcbe7a3d5..14892995a 100755 --- a/bin/route-debug.js +++ b/bin/route-debug.js @@ -57,6 +57,8 @@ function parse_args() { console.log(' --console-debug-benchmark Include benchmark timing in console_debug'); console.log(' --console-debug-all Show all console_debug channels'); console.log(' --dump-dimensions= Add layout dimensions to matching elements'); + console.log(' --portal Test portal routes (uses /_portal/ prefix)'); + console.log(' --portal-user= Test as specific portal user ID'); console.log(' --help Show this help message'); process.exit(0); } @@ -99,7 +101,9 @@ function parse_args() { screenshot_width: null, screenshot_path: null, dump_dimensions: null, - dev_auth_token: null + dev_auth_token: null, + portal: false, + portal_user_id: null }; for (const arg of args) { @@ -176,6 +180,10 @@ function parse_args() { options.screenshot_path = arg.substring(18); } else if (arg.startsWith('--dump-dimensions=')) { options.dump_dimensions = arg.substring(18); + } else if (arg === '--portal') { + options.portal = true; + } else if (arg.startsWith('--portal-user=')) { + options.portal_user_id = arg.substring(14); } else if (!arg.startsWith('--')) { options.route = arg; } @@ -208,9 +216,14 @@ function parse_args() { // Main execution (async () => { const options = parse_args(); - + const baseUrl = 'http://localhost'; - const fullUrl = baseUrl + options.route; + // In portal mode, ensure route has /_portal prefix + let route = options.route; + if (options.portal && !route.startsWith('/_portal')) { + route = '/_portal' + route; + } + const fullUrl = baseUrl + route; const laravel_log_path = process.env.LARAVEL_LOG_PATH || '/var/www/html/storage/logs/laravel.log'; // Launch browser (always headless) @@ -380,6 +393,9 @@ function parse_args() { if (options.user_id) { extraHeaders['X-Dev-Auth-User-Id'] = options.user_id; } + if (options.portal_user_id) { + extraHeaders['X-Dev-Auth-Portal-User-Id'] = options.portal_user_id; + } if (options.dev_auth_token) { extraHeaders['X-Dev-Auth-Token'] = options.dev_auth_token; } @@ -699,6 +715,14 @@ function parse_args() { if (options.user_id) { output += ` user:${options.user_id}`; } + + if (options.portal) { + output += ` portal:true`; + } + + if (options.portal_user_id) { + output += ` portal-user:${options.portal_user_id}`; + } // Add key headers if (headers_response['content-type']) { diff --git a/config/rsx.php b/config/rsx.php index 36b6a89be..a1d7d28a5 100755 --- a/config/rsx.php +++ b/config/rsx.php @@ -142,6 +142,8 @@ return [ 'manifest_support' => [ \App\RSpade\Core\Dispatch\Route_ManifestSupport::class, + \App\RSpade\Core\Portal\Portal_Route_ManifestSupport::class, + \App\RSpade\Core\Portal\Portal_Spa_ManifestSupport::class, \App\RSpade\Core\Manifest\Modules\Model_ManifestSupport::class, \App\RSpade\Integrations\Jqhtml\Jqhtml_ManifestSupport::class, \App\RSpade\Core\SPA\Spa_ManifestSupport::class, diff --git a/database/migrations/.migration_whitelist b/database/migrations/.migration_whitelist index 00a49762a..649d7dd12 100755 --- a/database/migrations/.migration_whitelist +++ b/database/migrations/.migration_whitelist @@ -396,6 +396,26 @@ "created_at": "2026-01-29T08:19:02+00:00", "created_by": "root", "command": "php artisan make:migration:safe create_notifications_table" + }, + "2026_01_29_180540_create_portal_users_table.php": { + "created_at": "2026-01-29T18:05:40+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe create_portal_users_table" + }, + "2026_01_29_180545_create_portal_invitations_table.php": { + "created_at": "2026-01-29T18:05:45+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe create_portal_invitations_table" + }, + "2026_01_29_180545_create_portal_sessions_table.php": { + "created_at": "2026-01-29T18:05:45+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe create_portal_sessions_table" + }, + "2026_01_29_184331_create_portal_password_resets_table.php": { + "created_at": "2026-01-29T18:43:31+00:00", + "created_by": "root", + "command": "php artisan make:migration:safe create_portal_password_resets_table" } } } \ No newline at end of file diff --git a/database/migrations/2026_01_29_180540_create_portal_users_table.php b/database/migrations/2026_01_29_180540_create_portal_users_table.php new file mode 100755 index 000000000..90c7f8219 --- /dev/null +++ b/database/migrations/2026_01_29_180540_create_portal_users_table.php @@ -0,0 +1,57 @@ +