Framework updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-02-01 05:16:45 +00:00
parent f48cda006a
commit 0efdcd4cde
27 changed files with 2970 additions and 153 deletions

View File

@@ -260,7 +260,9 @@ class Maint_Migrate extends Command
} }
// Run normalize_schema BEFORE migrations to fix existing tables // 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"); $this->info("\n Pre-migration normalization (fixing existing tables)...\n");
$normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs); $normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs);

View File

@@ -11,6 +11,7 @@ use Illuminate\Console\Command;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
use App\RSpade\Core\Debug\Debugger; use App\RSpade\Core\Debug\Debugger;
use App\RSpade\Core\Models\Login_User_Model; use App\RSpade\Core\Models\Login_User_Model;
use App\RSpade\Core\Portal\Portal_User_Model;
/** /**
* RSX Route Debug Command * 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} {--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-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)} {--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. * The console command description.
@@ -213,15 +216,46 @@ class Route_Debug_Command extends Command
$url = '/' . $url; $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) // Get user ID from options (accepts ID or email)
$user_id = $this->option('user'); $user_id = $this->option('user');
if ($user_id !== null) { 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); $user_id = $this->resolve_user($user_id);
if ($user_id === null) { if ($user_id === null) {
return 1; // Error already displayed 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 // Get log flag
$show_log = $this->option('log'); $show_log = $this->option('log');
@@ -384,16 +418,26 @@ class Route_Debug_Command extends Command
// This prevents unauthorized requests from hijacking sessions via headers // This prevents unauthorized requests from hijacking sessions via headers
$dev_auth_token = null; $dev_auth_token = null;
if ($user_id) { 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 // Build command arguments
$command_args = ['node', $playwright_script, $url]; $command_args = ['node', $playwright_script, $url];
if ($portal_mode) {
$command_args[] = '--portal';
}
if ($user_id) { if ($user_id) {
$command_args[] = "--user={$user_id}"; $command_args[] = "--user={$user_id}";
} }
if ($portal_user_id) {
$command_args[] = "--portal-user={$portal_user_id}";
}
if ($dev_auth_token) { if ($dev_auth_token) {
$command_args[] = "--dev-auth-token={$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(' php artisan rsx:debug /admin --user=admin@example.com # Test as user by email');
$this->line(''); $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->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 typeof Rsx_Time" # Check if class exists');
$this->line(' php artisan rsx:debug / --eval="return Rsx_Time.now_iso()" # Get current time'); $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; 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 * 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 string $url The URL being tested
* @param int $user_id The user ID to authenticate as * @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 * @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'); $app_key = config('app.key');
if (!$app_key) { if (!$app_key) {
@@ -700,6 +791,7 @@ class Route_Debug_Command extends Command
$payload = json_encode([ $payload = json_encode([
'url' => $url, 'url' => $url,
'user_id' => $user_id, 'user_id' => $user_id,
'portal' => $is_portal,
]); ]);
// Sign with HMAC-SHA256 // Sign with HMAC-SHA256

View File

@@ -6,6 +6,8 @@ use RuntimeException;
use App\RSpade\CodeQuality\RuntimeChecks\BundleErrors; use App\RSpade\CodeQuality\RuntimeChecks\BundleErrors;
use App\RSpade\Core\Bundle\BundleCompiler; use App\RSpade\Core\Bundle\BundleCompiler;
use App\RSpade\Core\Manifest\Manifest; 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\Rsx;
use App\RSpade\Core\Session\Session; use App\RSpade\Core\Session\Session;
@@ -261,10 +263,12 @@ abstract class Rsx_Bundle_Abstract
// Add runtime data // Add runtime data
$rsxapp_data['debug'] = Rsx::is_development(); $rsxapp_data['debug'] = Rsx::is_development();
$rsxapp_data['current_controller'] = Rsx::get_current_controller(); // Use portal-specific methods when in portal context
$rsxapp_data['current_action'] = Rsx::get_current_action(); $is_portal = Rsx_Portal::is_portal_request();
$rsxapp_data['is_auth'] = Session::is_logged_in(); $rsxapp_data['is_portal'] = $is_portal;
$rsxapp_data['is_spa'] = Rsx::is_spa(); $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 // Enable ajax batching in debug/production modes, disable in development for easier debugging
$rsxapp_data['ajax_batching'] = !Rsx::is_development(); $rsxapp_data['ajax_batching'] = !Rsx::is_development();
@@ -274,9 +278,18 @@ abstract class Rsx_Bundle_Abstract
$rsxapp_data['params'] = $current_params ?? []; $rsxapp_data['params'] = $current_params ?? [];
// Add user, site, and csrf data from session // Add user, site, and csrf data from session
$rsxapp_data['user'] = Session::get_user(); // Use Portal_Session for portal requests, Session for regular requests
$rsxapp_data['site'] = Session::get_site(); if ($is_portal) {
$rsxapp_data['csrf'] = Session::get_csrf_token(); $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) // Add browser error logging flag (enabled in both dev and production)
if (config('rsx.log_browser_errors', false)) { if (config('rsx.log_browser_errors', false)) {

View File

@@ -19,6 +19,8 @@ use App\RSpade\Core\Debug\Debugger;
use App\RSpade\Core\Dispatch\AssetHandler; use App\RSpade\Core\Dispatch\AssetHandler;
use App\RSpade\Core\Dispatch\RouteResolver; use App\RSpade\Core\Dispatch\RouteResolver;
use App\RSpade\Core\Manifest\Manifest; 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; use App\RSpade\Core\Rsx;
/** /**
@@ -83,6 +85,12 @@ class Dispatcher
$request = $request ?? request(); $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 // Custom session is handled by Session::init() in RsxAuth
// Check if this is an asset request // Check if this is an asset request

View File

@@ -30,50 +30,42 @@ use App\RSpade\Core\Files\File_Storage_Model;
* provides the basic structure for categorizing uploaded files. * provides the basic structure for categorizing uploaded files.
*/ */
/** /**
* _AUTO_GENERATED_ Database type hints - do not edit manually * _AUTO_GENERATED_
* Generated on: 2026-01-29 08:25:50 * @property integer $id
* Table: _file_attachments * @property string $key
* * @property integer $file_storage_id
* @property int $id * @property string $file_name
* @property mixed $key * @property string $file_extension
* @property int $file_storage_id * @property integer $file_type_id
* @property mixed $file_name * @property integer $width
* @property mixed $file_extension * @property integer $height
* @property int $file_type_id * @property integer $duration
* @property int $width * @property boolean $is_animated
* @property int $height * @property integer $frame_count
* @property int $duration * @property integer $fileable_type
* @property bool $is_animated * @property integer $fileable_id
* @property int $frame_count * @property string $fileable_category
* @property int $fileable_type * @property string $fileable_type_meta
* @property int $fileable_id * @property integer $fileable_order
* @property mixed $fileable_category
* @property mixed $fileable_type_meta
* @property int $fileable_order
* @property string $fileable_meta * @property string $fileable_meta
* @property int $site_id * @property integer $site_id
* @property mixed $session_id * @property string $session_id
* @property string $created_at * @property \Carbon\Carbon $created_at
* @property string $updated_at * @property \Carbon\Carbon $updated_at
* @property int $created_by * @property integer $created_by
* @property int $updated_by * @property integer $updated_by
* * @method static mixed file_type_id_enum()
* @property-read string $file_type_id__label * @method static mixed file_type_id_enum_select()
* @property-read string $file_type_id__constant * @method static mixed file_type_id_enum_ids()
* * @property-read mixed $file_type_id_constant
* @method static array file_type_id__enum() Get all enum definitions with full metadata * @property-read mixed $file_type_id_label
* @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
*
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class File_Attachment_Model extends Rsx_Site_Model_Abstract class File_Attachment_Model extends Rsx_Site_Model_Abstract
{ {
/** /** __AUTO_GENERATED: */
* _AUTO_GENERATED_ Enum constants
*/
const FILE_TYPE_IMAGE = 1; const FILE_TYPE_IMAGE = 1;
const FILE_TYPE_ANIMATED_IMAGE = 2; const FILE_TYPE_ANIMATED_IMAGE = 2;
const FILE_TYPE_VIDEO = 3; 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_TEXT = 5;
const FILE_TYPE_DOCUMENT = 6; const FILE_TYPE_DOCUMENT = 6;
const FILE_TYPE_OTHER = 7; const FILE_TYPE_OTHER = 7;
/** __AUTO_GENERATED: */
/** __/AUTO_GENERATED */ /** __/AUTO_GENERATED */
/** /**

405
app/RSpade/Core/Js/Rsx_Portal.js Executable file
View File

@@ -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<string>} 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 = {};
}
}

View File

@@ -23,46 +23,41 @@ use App\RSpade\Core\Models\User_Profile_Model;
* See: php artisan rsx:man acls * See: php artisan rsx:man acls
*/ */
/** /**
* _AUTO_GENERATED_ Database type hints - do not edit manually * _AUTO_GENERATED_
* Generated on: 2026-01-29 08:25:50 * @property integer $id
* Table: users * @property integer $login_user_id
* * @property integer $site_id
* @property int $id * @property string $first_name
* @property int $login_user_id * @property string $last_name
* @property int $site_id * @property string $phone
* @property mixed $first_name * @property integer $role_id
* @property mixed $last_name * @property boolean $is_enabled
* @property mixed $phone * @property integer $user_role_id
* @property int $role_id * @property string $email
* @property bool $is_enabled * @property \Carbon\Carbon $deleted_at
* @property int $user_role_id * @property \Carbon\Carbon $created_at
* @property mixed $email * @property \Carbon\Carbon $updated_at
* @property string $deleted_at * @property integer $created_by
* @property string $created_at * @property integer $updated_by
* @property string $updated_at * @property integer $deleted_by
* @property int $created_by * @property string $invite_code
* @property int $updated_by * @property \Carbon\Carbon $invite_accepted_at
* @property int $deleted_by * @property \Carbon\Carbon $invite_expires_at
* @property mixed $invite_code * @method static mixed role_id_enum()
* @property string $invite_accepted_at * @method static mixed role_id_enum_select()
* @property string $invite_expires_at * @method static mixed role_id_enum_ids()
* * @property-read mixed $role_id_constant
* @property-read string $role_id__label * @property-read mixed $role_id_label
* @property-read string $role_id__constant * @property-read mixed $role_id_permissions
* * @property-read mixed $role_id_can_admin_roles
* @method static array role_id__enum() Get all enum definitions with full metadata * @property-read mixed $role_id_selectable
* @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
*
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class User_Model extends Rsx_Site_Model_Abstract class User_Model extends Rsx_Site_Model_Abstract
{ {
/** /** __AUTO_GENERATED: */
* _AUTO_GENERATED_ Enum constants
*/
const ROLE_DEVELOPER = 100; const ROLE_DEVELOPER = 100;
const ROLE_ROOT_ADMIN = 200; const ROLE_ROOT_ADMIN = 200;
const ROLE_SITE_OWNER = 300; const ROLE_SITE_OWNER = 300;
@@ -71,9 +66,6 @@ class User_Model extends Rsx_Site_Model_Abstract
const ROLE_USER = 600; const ROLE_USER = 600;
const ROLE_VIEWER = 700; const ROLE_VIEWER = 700;
const ROLE_DISABLED = 800; const ROLE_DISABLED = 800;
/** __AUTO_GENERATED: */
/** __/AUTO_GENERATED */ /** __/AUTO_GENERATED */
// ========================================================================= // =========================================================================

View File

@@ -11,44 +11,33 @@ use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
* and two-factor authentication via email or SMS. * and two-factor authentication via email or SMS.
*/ */
/** /**
* _AUTO_GENERATED_ Database type hints - do not edit manually * _AUTO_GENERATED_
* Generated on: 2026-01-29 08:25:50 * @property integer $id
* Table: user_verifications * @property string $email
* * @property string $verification_code
* @property int $id * @property integer $verification_type_id
* @property mixed $email * @property \Carbon\Carbon $verified_at
* @property mixed $verification_code * @property \Carbon\Carbon $expires_at
* @property int $verification_type_id * @property \Carbon\Carbon $created_at
* @property string $verified_at * @property \Carbon\Carbon $updated_at
* @property string $expires_at * @property integer $created_by
* @property string $created_at * @property integer $updated_by
* @property string $updated_at * @method static mixed verification_type_id_enum()
* @property int $created_by * @method static mixed verification_type_id_enum_select()
* @property int $updated_by * @method static mixed verification_type_id_enum_ids()
* * @property-read mixed $verification_type_id_constant
* @property-read string $verification_type_id__label * @property-read mixed $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
*
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class User_Verification_Model extends Rsx_Model_Abstract class User_Verification_Model extends Rsx_Model_Abstract
{ {
/** /** __AUTO_GENERATED: */
* _AUTO_GENERATED_ Enum constants
*/
const VERIFICATION_TYPE_EMAIL = 1; const VERIFICATION_TYPE_EMAIL = 1;
const VERIFICATION_TYPE_SMS = 2; const VERIFICATION_TYPE_SMS = 2;
const VERIFICATION_TYPE_EMAIL_RECOVERY = 3; const VERIFICATION_TYPE_EMAIL_RECOVERY = 3;
const VERIFICATION_TYPE_SMS_RECOVERY = 4; const VERIFICATION_TYPE_SMS_RECOVERY = 4;
/** __AUTO_GENERATED: */
/** __/AUTO_GENERATED */ /** __/AUTO_GENERATED */
/** /**

View File

@@ -0,0 +1,412 @@
<?php
namespace App\RSpade\Core\Portal;
use Illuminate\Http\Request;
use App\RSpade\Core\Dispatch\AssetHandler;
use App\RSpade\Core\Dispatch\RouteResolver;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Portal\Portal_Session;
use App\RSpade\Core\Portal\Rsx_Portal;
/**
* Portal_Dispatcher - Handles dispatch for client portal requests
*
* Similar to Dispatcher but for portal routes:
* - Uses portal_routes from manifest instead of routes
* - Uses Portal_Session instead of Session
* - Calls portal.php hooks instead of main.php
* - Strips portal prefix in development mode
*/
class Portal_Dispatcher
{
/**
* Check if URL is a portal request and should be handled by portal dispatcher
*
* Detection logic:
* 1. If portal domain configured: check if request host matches
* 2. If no domain: check if URL starts with portal prefix
*
* @param string $url Request URL
* @param Request|null $request Optional request object
* @return bool True if this is a portal request
*/
public static function is_portal_request(string $url, ?Request $request = null): bool
{
return Rsx_Portal::is_portal_request();
}
/**
* Get the normalized portal URL (with prefix stripped if applicable)
*
* @param string $url Original request URL
* @return string URL normalized for portal route matching
*/
public static function normalize_portal_url(string $url): string
{
// If not using dedicated domain, strip the prefix
if (!Rsx_Portal::has_dedicated_domain()) {
$prefix = Rsx_Portal::get_prefix();
if (str_starts_with($url, $prefix)) {
$url = substr($url, strlen($prefix)) ?: '/';
}
}
return $url;
}
/**
* Dispatch a portal request to the appropriate handler
*
* @param string $url The URL to dispatch (will be normalized)
* @param string $method HTTP method (GET, POST, etc.)
* @param array $extra_params Additional parameters to merge
* @param Request|null $request Optional request object
* @return mixed Response from handler, or null if no route found
*/
public static function dispatch(string $url, string $method = 'GET', array $extra_params = [], ?Request $request = null)
{
console_debug('PORTAL', "Portal dispatch started for: {$method} {$url}");
// Handle dev auth for rsx:debug testing (development only)
static::__handle_dev_auth($request ?? request(), $url);
// Normalize the URL (strip prefix if needed)
$normalized_url = static::normalize_portal_url($url);
console_debug('PORTAL', "Normalized URL: {$normalized_url}");
// Initialize manifest
Manifest::init();
$request = $request ?? request();
// Check if this is an asset request
if (AssetHandler::is_asset_request($normalized_url)) {
console_debug('PORTAL', "Serving static asset: {$normalized_url}");
return AssetHandler::serve($normalized_url, $request);
}
// HEAD requests should be treated as GET
$original_method = $method;
$route_method = ($method === 'HEAD') ? 'GET' : $method;
if ($method === 'HEAD' && $request) {
$request->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}");
}
}

View File

@@ -0,0 +1,61 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Core\Portal;
use Illuminate\Http\Request;
/**
* Portal_Main_Abstract - Base class for portal-wide middleware-style hooks
*
* Similar to Main_Abstract but specifically for portal requests.
* Application extends this via /rsx/portal.php as Portal_Main.
*
* Portal_Main provides hooks that run BEFORE individual controller hooks:
* 1. Portal_Main::init() - Called once during portal bootstrap
* 2. Portal_Main::pre_dispatch() - Called before any portal route dispatch
* 3. Portal_Main::unhandled_route() - Called when no portal route matches
*/
abstract class Portal_Main_Abstract
{
/**
* Initialize the Portal_Main class
*
* Called once during portal bootstrap (when a portal request is detected)
*
* @return void
*/
abstract public static function init();
/**
* Pre-dispatch hook for portal requests
*
* Called before any portal route dispatch. If a non-null value is returned,
* dispatch is halted and that value is returned as the response.
*
* Typical uses:
* - Require portal authentication
* - Load portal user data
* - Set portal-specific headers
*
* @param Request $request The current request
* @param array $params Combined GET values and URL parameters
* @return mixed|null Return null to continue, or a response to halt dispatch
*/
abstract public static function pre_dispatch(Request $request, array $params);
/**
* Unhandled route hook for portal requests
*
* Called when no portal route matches the request
*
* @param Request $request The current request
* @param array $params Combined GET values and URL parameters
* @return mixed|null Return null for default 404, or a response to handle
*/
abstract public static function unhandled_route(Request $request, array $params);
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\RSpade\Core\Portal;
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
/**
* Support module for building portal routes index from #[Portal_Route] attributes
*
* Similar to Route_ManifestSupport but for portal-specific routes.
* Portal routes are stored separately in the manifest and handled by
* the portal dispatcher.
*
* Usage in controllers:
* ```php
* #[Portal_Route('/dashboard')]
* public static function index(Request $request, array $params = []) { ... }
*
* #[Portal_Route('/projects/:id', methods: ['GET'])]
* public static function view(Request $request, array $params = []) { ... }
* ```
*/
class Portal_Route_ManifestSupport extends ManifestSupport_Abstract
{
/**
* Get the name of this support module
*
* @return string
*/
public static function get_name(): string
{
return 'Portal Routes';
}
/**
* Process the manifest and build portal routes index
*
* @param array &$manifest_data Reference to the manifest data array
* @return void
*/
public static function process(array &$manifest_data): void
{
// Initialize portal routes structures
if (!isset($manifest_data['data']['portal_routes'])) {
$manifest_data['data']['portal_routes'] = [];
}
if (!isset($manifest_data['data']['portal_routes_by_target'])) {
$manifest_data['data']['portal_routes_by_target'] = [];
}
// Look for Portal_Route attributes
$files = $manifest_data['data']['files'];
$portal_route_classes = [];
foreach ($files as $file => $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']);
}
}

View File

@@ -0,0 +1,729 @@
<?php
namespace App\RSpade\Core\Portal;
use App\RSpade\Core\Database\Models\Rsx_System_Model_Abstract;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Models\Site_Model;
use App\RSpade\Core\Portal\Rsx_Portal;
use App\RSpade\Core\Session\User_Agent;
/**
* Portal_Session - handles authentication for external portal users
*
* This class serves dual purposes:
* 1. As a Laravel Eloquent model for the portal_sessions table
* 2. As a static interface for portal session management
*
* Key differences from Session:
* - Site-scoped (no multi-site switching)
* - Simpler model (no experience_id)
* - Uses different cookie name ('rsx_portal')
* - References Portal_User_Model instead of Login_User_Model
*
* @FILE-SUBCLASS-01-EXCEPTION Class intentionally named Portal_Session instead of Portal_Session_Model
*
* @property int $id
* @property int $site_id
* @property int $portal_user_id
* @property string $session_token
* @property string $csrf_token
* @property string $ip_address
* @property string $user_agent
* @property \Carbon\Carbon $last_active
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class Portal_Session extends Rsx_System_Model_Abstract
{
// Enum definitions (required by abstract parent)
public static $enums = [];
// Cookie name for portal sessions (different from internal 'rsx')
const COOKIE_NAME = 'rsx_portal';
// Static session management properties
private static $_session = null;
private static $_site = null;
private static $_portal_user = null;
private static $_session_token = null;
private static $_has_init = false;
private static $_has_activate = false;
private static $_has_set_cookie = false;
// CLI mode properties (static-only, no database)
private static $_cli_site_id = null;
private static $_cli_portal_user_id = null;
/**
* The table associated with the model
* @var string
*/
protected $table = 'portal_sessions';
/**
* The attributes that should be cast
* @var array
*/
protected $casts = [
'site_id' => '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();
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\RSpade\Core\Portal;
use RuntimeException;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
/**
* Support module for extracting Portal Spa route metadata from Spa_Action classes
*
* Similar to Spa_ManifestSupport but for portal-specific SPA routes.
* Portal SPA actions use the @portal_spa() decorator instead of @spa()
* and their routes are registered in portal_routes instead of routes.
*
* Usage in JS action:
* ```javascript
* @route('/dashboard')
* @layout('Portal_Layout')
* @portal_spa('Portal_Spa_Controller::index')
* class Portal_Dashboard_Action extends Spa_Action {
* // ...
* }
* ```
*/
class Portal_Spa_ManifestSupport extends ManifestSupport_Abstract
{
/**
* Get the name of this support module
*
* @return string
*/
public static function get_name(): string
{
return 'Portal Spa Routes';
}
/**
* Process the manifest and build Portal Spa routes index
*
* @param array &$manifest_data Reference to the manifest data array
* @return void
*/
public static function process(array &$manifest_data): void
{
// Initialize portal routes structures if not already set
if (!isset($manifest_data['data']['portal_routes'])) {
$manifest_data['data']['portal_routes'] = [];
}
if (!isset($manifest_data['data']['portal_routes_by_target'])) {
$manifest_data['data']['portal_routes_by_target'] = [];
}
// Get all files to look up PHP controller metadata
$files = $manifest_data['data']['files'];
// Get all JavaScript classes extending Spa_Action
$action_classes = Manifest::js_get_extending('Spa_Action');
foreach ($action_classes as $class_name => $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;
}
}

View File

@@ -0,0 +1,481 @@
<?php
/**
* @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
*/
namespace App\RSpade\Core\Portal;
use RuntimeException;
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
use App\RSpade\Core\Manifest\Manifest;
/**
* Portal utility class
*
* Provides static utility methods for the client portal 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)
*/
class Rsx_Portal
{
/**
* URL prefix for portal when no dedicated domain configured
*/
public const URL_PREFIX = '/_portal';
/**
* Whether we're currently in a portal request context
* @var bool|null
*/
protected static ?bool $_is_portal_request = null;
/**
* Current portal controller being executed
* @var string|null
*/
protected static ?string $current_controller = null;
/**
* Current portal action being executed
* @var string|null
*/
protected static ?string $current_action = null;
/**
* Current portal route type ('spa' or 'standard')
* @var string|null
*/
protected static ?string $current_route_type = null;
// =========================================================================
// Configuration Methods
// =========================================================================
/**
* Get the configured portal domain
*
* @return string|null Domain if configured, null otherwise
*/
public static function get_domain(): ?string
{
return config('rsx.portal.domain');
}
/**
* Get the portal URL prefix (used when no domain configured)
*
* @return string The prefix, defaults to '/_portal'
*/
public static function get_prefix(): string
{
return config('rsx.portal.prefix', self::URL_PREFIX);
}
/**
* Check if portal is using a dedicated domain (vs URL prefix)
*
* @return bool True if dedicated domain is configured
*/
public static function has_dedicated_domain(): bool
{
return !empty(self::get_domain());
}
// =========================================================================
// Portal Request Context Detection
// =========================================================================
/**
* Check if current request is a portal request
*
* Detection logic:
* 1. If portal domain configured: check if request host matches
* 2. If no domain: check if URL starts with portal prefix
*
* @return bool True if this is a portal request
*/
public static function is_portal_request(): bool
{
if (self::$_is_portal_request !== null) {
return self::$_is_portal_request;
}
// CLI mode: default to false (can be overridden)
if (php_sapi_name() === 'cli') {
self::$_is_portal_request = false;
return false;
}
$portal_domain = self::get_domain();
if (!empty($portal_domain)) {
// Domain-based detection
$request_host = $_SERVER['HTTP_HOST'] ?? '';
self::$_is_portal_request = (strtolower($request_host) === strtolower($portal_domain));
} else {
// Prefix-based detection
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$prefix = self::get_prefix();
self::$_is_portal_request = str_starts_with($request_uri, $prefix . '/') ||
$request_uri === $prefix;
}
return self::$_is_portal_request;
}
/**
* Manually set whether this is a portal request (for testing/CLI)
*
* @param bool $is_portal
* @return void
*/
public static function set_portal_request(bool $is_portal): void
{
self::$_is_portal_request = $is_portal;
}
/**
* Get the current request URL with portal prefix stripped (if applicable)
*
* @return string The normalized path
*/
public static function get_normalized_path(): string
{
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
// Remove query string
$path = parse_url($request_uri, PHP_URL_PATH) ?? '/';
// If using prefix mode and path starts with prefix, strip it
if (!self::has_dedicated_domain()) {
$prefix = self::get_prefix();
if (str_starts_with($path, $prefix)) {
$path = substr($path, strlen($prefix)) ?: '/';
}
}
return $path;
}
// =========================================================================
// Route Generation
// =========================================================================
/**
* Generate URL for a portal route
*
* Similar to Rsx::Route() but:
* - Returns URLs with portal domain or prefix
* - Only works with routes that have #[Portal_Route] attribute
*
* Usage examples:
* ```php
* // Portal action route
* $url = Rsx_Portal::Route('Portal_Dashboard_Action');
* // Development: /_portal/dashboard
* // Production: https://portal.example.com/dashboard
*
* // Route with integer parameter
* $url = Rsx_Portal::Route('Portal_Project_View_Action', 123);
* // Development: /_portal/projects/123
*
* // Placeholder route
* $url = Rsx_Portal::Route('Future_Portal_Feature::#index');
* // Returns: #
* ```
*
* @param string $action Controller class, SPA action, or "Class::method"
* @param int|array|\stdClass|null $params Route parameters
* @return string The generated URL (may include portal domain/prefix)
*/
public static function Route($action, $params = null): string
{
// Parse action into class_name and action_name
if (str_contains($action, '::')) {
[$class_name, $action_name] = explode('::', $action, 2);
} else {
$class_name = $action;
$action_name = 'index';
}
// Normalize params to array
$params_array = [];
if (is_int($params)) {
$params_array = ['id' => $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;
}
}

View File

@@ -250,6 +250,14 @@ class Spa {
const parsed = Spa.parse_url(url); const parsed = Spa.parse_url(url);
let path = parsed.path; 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 // Normalize path - remove leading/trailing slashes for matching
path = path.substring(1); // Remove leading / path = path.substring(1); // Remove leading /

View File

@@ -86,3 +86,22 @@ function title(page_title) {
return target; 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;
};
}

View File

@@ -51,6 +51,11 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
// Parse decorators into route configuration // Parse decorators into route configuration
$route_info = static::_parse_decorators($decorators); $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 // Skip if no route decorator found
if (empty($route_info['routes'])) { if (empty($route_info['routes'])) {
continue; continue;
@@ -155,6 +160,7 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
'layout' => null, 'layout' => null,
'spa_controller' => null, 'spa_controller' => null,
'spa_method' => null, 'spa_method' => null,
'is_portal_spa' => false,
]; ];
foreach ($decorators as $decorator) { foreach ($decorators as $decorator) {
@@ -175,6 +181,11 @@ class Spa_ManifestSupport extends ManifestSupport_Abstract
} }
break; break;
case 'portal_spa':
// @portal_spa decorator - handled by Portal_Spa_ManifestSupport, skip here
$config['is_portal_spa'] = true;
break;
case 'spa': case 'spa':
// @spa('Controller::method') - args is array with single string // @spa('Controller::method') - args is array with single string
if (!empty($args[0])) { if (!empty($args[0])) {

View File

@@ -5,41 +5,29 @@ namespace App\RSpade\Lib\Flash;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract; use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
/** /**
* _AUTO_GENERATED_ Database type hints - do not edit manually * _AUTO_GENERATED_
* Generated on: 2026-01-29 08:25:50 * @property integer $id
* Table: _flash_alerts * @property integer $session_id
* * @property integer $type_id
* @property int $id
* @property int $session_id
* @property int $type_id
* @property string $message * @property string $message
* @property string $created_at * @property \Carbon\Carbon $created_at
* @property int $created_by * @property integer $created_by
* @property int $updated_by * @property integer $updated_by
* @property string $updated_at * @property \Carbon\Carbon $updated_at
* * @method static mixed type_id_enum()
* @property-read string $type_id__label * @method static mixed type_id_enum_select()
* @property-read string $type_id__constant * @method static mixed type_id_enum_ids()
* * @property-read mixed $type_id_constant
* @method static array type_id__enum() Get all enum definitions with full metadata * @property-read mixed $type_id_label
* @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
*
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class Flash_Alert_Model extends Rsx_Model_Abstract class Flash_Alert_Model extends Rsx_Model_Abstract
{ {
/** /** __AUTO_GENERATED: */
* _AUTO_GENERATED_ Enum constants
*/
const TYPE_SUCCESS = 1; const TYPE_SUCCESS = 1;
const TYPE_ERROR = 2; const TYPE_ERROR = 2;
const TYPE_INFO = 3; const TYPE_INFO = 3;
const TYPE_WARNING = 4; const TYPE_WARNING = 4;
/** __AUTO_GENERATED: */
/** __/AUTO_GENERATED */ /** __/AUTO_GENERATED */
// Enum constants (auto-generated by rsx:migrate:document_models) // Enum constants (auto-generated by rsx:migrate:document_models)

View File

@@ -27,6 +27,16 @@ CORE OPTIONS
running test. Uses backdoor authentication that only works in running test. Uses backdoor authentication that only works in
development environments. 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=<id|email>
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 --no-body
Suppress HTTP response body output. Useful when you only want to see Suppress HTTP response body output. Useful when you only want to see
headers, status code, console errors, or other diagnostic information. 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: Test a protected route by email:
php artisan rsx:debug /admin/users --user=admin@example.com 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: Check if JavaScript errors occur:
php artisan rsx:debug /page php artisan rsx:debug /page
# Console errors are always shown # Console errors are always shown

View File

@@ -57,6 +57,8 @@ function parse_args() {
console.log(' --console-debug-benchmark Include benchmark timing in console_debug'); console.log(' --console-debug-benchmark Include benchmark timing in console_debug');
console.log(' --console-debug-all Show all console_debug channels'); console.log(' --console-debug-all Show all console_debug channels');
console.log(' --dump-dimensions=<sel> Add layout dimensions to matching elements'); console.log(' --dump-dimensions=<sel> Add layout dimensions to matching elements');
console.log(' --portal Test portal routes (uses /_portal/ prefix)');
console.log(' --portal-user=<id> Test as specific portal user ID');
console.log(' --help Show this help message'); console.log(' --help Show this help message');
process.exit(0); process.exit(0);
} }
@@ -99,7 +101,9 @@ function parse_args() {
screenshot_width: null, screenshot_width: null,
screenshot_path: null, screenshot_path: null,
dump_dimensions: null, dump_dimensions: null,
dev_auth_token: null dev_auth_token: null,
portal: false,
portal_user_id: null
}; };
for (const arg of args) { for (const arg of args) {
@@ -176,6 +180,10 @@ function parse_args() {
options.screenshot_path = arg.substring(18); options.screenshot_path = arg.substring(18);
} else if (arg.startsWith('--dump-dimensions=')) { } else if (arg.startsWith('--dump-dimensions=')) {
options.dump_dimensions = arg.substring(18); 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('--')) { } else if (!arg.startsWith('--')) {
options.route = arg; options.route = arg;
} }
@@ -208,9 +216,14 @@ function parse_args() {
// Main execution // Main execution
(async () => { (async () => {
const options = parse_args(); const options = parse_args();
const baseUrl = 'http://localhost'; 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'; const laravel_log_path = process.env.LARAVEL_LOG_PATH || '/var/www/html/storage/logs/laravel.log';
// Launch browser (always headless) // Launch browser (always headless)
@@ -380,6 +393,9 @@ function parse_args() {
if (options.user_id) { if (options.user_id) {
extraHeaders['X-Dev-Auth-User-Id'] = 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) { if (options.dev_auth_token) {
extraHeaders['X-Dev-Auth-Token'] = options.dev_auth_token; extraHeaders['X-Dev-Auth-Token'] = options.dev_auth_token;
} }
@@ -699,6 +715,14 @@ function parse_args() {
if (options.user_id) { if (options.user_id) {
output += ` user:${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 // Add key headers
if (headers_response['content-type']) { if (headers_response['content-type']) {

View File

@@ -142,6 +142,8 @@ return [
'manifest_support' => [ 'manifest_support' => [
\App\RSpade\Core\Dispatch\Route_ManifestSupport::class, \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\Core\Manifest\Modules\Model_ManifestSupport::class,
\App\RSpade\Integrations\Jqhtml\Jqhtml_ManifestSupport::class, \App\RSpade\Integrations\Jqhtml\Jqhtml_ManifestSupport::class,
\App\RSpade\Core\SPA\Spa_ManifestSupport::class, \App\RSpade\Core\SPA\Spa_ManifestSupport::class,

View File

@@ -396,6 +396,26 @@
"created_at": "2026-01-29T08:19:02+00:00", "created_at": "2026-01-29T08:19:02+00:00",
"created_by": "root", "created_by": "root",
"command": "php artisan make:migration:safe create_notifications_table" "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"
} }
} }
} }

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Portal users are external users (customers, clients, vendors) who access
* a site through the client portal. They are completely separate from
* internal system users (login_users).
*
* Key differences from login_users:
* - Site-scoped (no multi-tenant access)
* - Simpler auth model (no multi-site switching)
* - Invite-only registration
* - No remember_token (portal sessions are simpler)
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE portal_users (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
is_verified TINYINT(1) NOT NULL DEFAULT 0,
status_id BIGINT NOT NULL DEFAULT 1,
metadata JSON NULL,
last_login TIMESTAMP(3) NULL DEFAULT NULL,
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_by BIGINT DEFAULT NULL,
updated_by BIGINT DEFAULT NULL,
UNIQUE KEY uk_portal_users_site_email (site_id, email),
KEY idx_portal_users_site_id (site_id),
KEY idx_portal_users_email (email),
KEY idx_portal_users_status_id (status_id),
KEY idx_portal_users_is_verified (is_verified),
KEY idx_portal_users_created_at (created_at),
KEY idx_portal_users_last_login (last_login),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Portal invitations are single-use codes that allow external users
* to register for portal access. The invitation flow:
*
* 1. Admin creates invitation with email + optional metadata
* 2. System sends email with unique invitation code
* 3. User clicks link, lands on portal registration page
* 4. User sets password, account created with email verified
* 5. Invitation marked as used (used_at set)
*
* Metadata allows linking portal user to business entities
* (e.g., contact_id, client_id) - application-specific.
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE portal_invitations (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
email VARCHAR(255) NOT NULL,
invitation_code VARCHAR(64) NOT NULL,
metadata JSON NULL,
expires_at TIMESTAMP(3) NOT NULL,
used_at TIMESTAMP(3) NULL DEFAULT NULL,
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_by BIGINT DEFAULT NULL,
updated_by BIGINT DEFAULT NULL,
UNIQUE KEY uk_portal_invitations_code (invitation_code),
KEY idx_portal_invitations_site_id (site_id),
KEY idx_portal_invitations_email (email),
KEY idx_portal_invitations_site_email (site_id, email),
KEY idx_portal_invitations_expires_at (expires_at),
KEY idx_portal_invitations_used_at (used_at),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Portal sessions are completely separate from internal sessions.
* This ensures:
* - Different cookie names (no session bleeding)
* - Independent session management
* - Simpler structure (no experience_id, no multi-site)
*
* Portal sessions are site-scoped - a portal user can only
* be logged into one site at a time with a given session.
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE portal_sessions (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
portal_user_id BIGINT NULL,
session_token VARCHAR(64) NOT NULL,
csrf_token VARCHAR(64) NULL,
ip_address VARCHAR(45) NOT NULL,
user_agent VARCHAR(255) NULL,
last_active TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_portal_sessions_token (session_token),
KEY idx_portal_sessions_site_id (site_id),
KEY idx_portal_sessions_portal_user_id (portal_user_id),
KEY idx_portal_sessions_last_active (last_active),
KEY idx_portal_sessions_site_user (site_id, portal_user_id),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
FOREIGN KEY (portal_user_id) REFERENCES portal_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
/**
* down() method is prohibited in RSpade framework
* Migrations should only move forward, never backward
* You may remove this comment as soon as you see it and understand.
*/
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* Portal password reset tokens allow users to reset their password.
* Tokens are single-use and expire after a configurable time period.
*
* Flow:
* 1. User requests password reset with their email
* 2. System creates token and sends email with reset link
* 3. User clicks link, lands on password reset page
* 4. User sets new password
* 5. Token marked as used (used_at set)
*
* @return void
*/
public function up()
{
DB::statement("
CREATE TABLE portal_password_resets (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
site_id BIGINT NOT NULL,
portal_user_id BIGINT NOT NULL,
token VARCHAR(128) NOT NULL,
expires_at TIMESTAMP(3) NOT NULL,
used_at TIMESTAMP(3) NULL DEFAULT NULL,
created_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_portal_password_resets_token (token),
KEY idx_portal_password_resets_site_id (site_id),
KEY idx_portal_password_resets_portal_user_id (portal_user_id),
KEY idx_portal_password_resets_expires_at (expires_at),
KEY idx_portal_password_resets_used_at (used_at),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
FOREIGN KEY (portal_user_id) REFERENCES portal_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
}
};

View File

@@ -1048,9 +1048,10 @@ rsx:debug /page --screenshot-path=/tmp/page.png # Capture screenshot
rsx:debug /contacts --eval="$('.btn').click(); await sleep(1000)" # Simulate interaction rsx:debug /contacts --eval="$('.btn').click(); await sleep(1000)" # Simulate interaction
rsx:debug / --eval="return Rsx_Time.now_iso()" # Get eval result (use return) rsx:debug / --eval="return Rsx_Time.now_iso()" # Get eval result (use return)
rsx:debug / --console --eval="console.log(Rsx_Date.today())" # Or console.log with --console rsx:debug / --console --eval="console.log(Rsx_Date.today())" # Or console.log with --console
rsx:debug /dashboard --portal --portal-user=1 # Portal route as portal user
``` ```
Options: `--user=ID`, `--console`, `--screenshot-path`, `--screenshot-width=mobile|tablet|desktop-*`, `--dump-dimensions=".selector"`, `--eval="js"`, `--help` Options: `--user=ID`, `--portal`, `--portal-user=ID`, `--console`, `--screenshot-path`, `--screenshot-width=mobile|tablet|desktop-*`, `--dump-dimensions=".selector"`, `--eval="js"`, `--help`
**SPA routes ARE server routes.** If you get 404, the route doesn't exist - check route definitions. Never dismiss as "SPA can't be tested server-side". **SPA routes ARE server routes.** If you get 404, the route doesn't exist - check route definitions. Never dismiss as "SPA can't be tested server-side".