Standardize settings file naming and relocate documentation files Fix code quality violations from rsx:check Reorganize user_management directory into logical subdirectories Move Quill Bundle to core and align with Tom Select pattern Simplify Site Settings page to focus on core site information Complete Phase 5: Multi-tenant authentication with login flow and site selection Add route query parameter rule and synchronize filename validation logic Fix critical bug in UpdateNpmCommand causing missing JavaScript stubs Implement filename convention rule and resolve VS Code auto-rename conflict Implement js-sanitizer RPC server to eliminate 900+ Node.js process spawns Implement RPC server architecture for JavaScript parsing WIP: Add RPC server infrastructure for JS parsing (partial implementation) Update jqhtml terminology from destroy to stop, fix datagrid DOM preservation Add JQHTML-CLASS-01 rule and fix redundant class names Improve code quality rules and resolve violations Remove legacy fatal error format in favor of unified 'fatal' error type Filter internal keys from window.rsxapp output Update button styling and comprehensive form/modal documentation Add conditional fly-in animation for modals Fix non-deterministic bundle compilation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1373 lines
50 KiB
Plaintext
Executable File
1373 lines
50 KiB
Plaintext
Executable File
NAME
|
|
auth - RSX authentication, user invitations, and multi-tenant architecture
|
|
|
|
SYNOPSIS
|
|
Modern authentication system with Slack-style invitations, multi-tenant
|
|
support, and flexible session management for B2B SaaS applications
|
|
|
|
DESCRIPTION
|
|
The RSX authentication system provides a complete user management solution
|
|
built around two fundamental concepts:
|
|
|
|
1. Login Identity (login_users table)
|
|
- One email, one password, one identity
|
|
- Global authentication credentials
|
|
- Verified through invitation acceptance or signup
|
|
|
|
2. Site Membership (users table)
|
|
- Multiple memberships per login identity
|
|
- Each membership has its own role, name display
|
|
- Invitation workflow manages membership lifecycle
|
|
|
|
This separation enables:
|
|
- One person can be a member of multiple organizations/sites
|
|
- Each site membership has independent roles and profile data
|
|
- Invitation-based onboarding with email verification
|
|
- Works seamlessly for single-site or multi-tenant applications
|
|
|
|
CORE ARCHITECTURE
|
|
|
|
Database Tables:
|
|
|
|
login_users
|
|
id Login identity primary key
|
|
email Authentication email (unique)
|
|
password Hashed password
|
|
is_activated Account activation status
|
|
is_verified Email verification status
|
|
last_login Last successful login timestamp
|
|
created_at Account creation timestamp
|
|
updated_at Last update timestamp
|
|
|
|
users
|
|
id Site membership primary key
|
|
login_user_id FK to login_users (NULL until invite accepted)
|
|
site_id FK to sites
|
|
email Email for invitation (before login_user exists)
|
|
first_name Display name (part 1)
|
|
last_name Display name (part 2)
|
|
phone Contact number
|
|
role_id Site-specific role (1=OWNER, 2=ADMIN, 3=MEMBER)
|
|
is_enabled Active membership flag
|
|
invite_code Unique code for invitation URL
|
|
invite_accepted_at NULL until user accepts (marks as active member)
|
|
invite_expires_at Invitation expiration (default 7 days)
|
|
created_at Invitation sent timestamp
|
|
updated_at Last update timestamp
|
|
deleted_at Soft delete timestamp
|
|
|
|
sites
|
|
id Site/tenant primary key
|
|
name Organization name
|
|
slug URL-safe identifier
|
|
is_enabled Active site flag
|
|
|
|
sessions
|
|
session_token Cookie value (unique identifier)
|
|
login_user_id FK to login_users (NULL for anonymous)
|
|
site_id Current site context (NULL until selected)
|
|
experience_id Authentication realm (default 0)
|
|
active Session validity flag
|
|
expires_at Session expiration timestamp
|
|
updated_at Last activity timestamp
|
|
|
|
Relationships:
|
|
|
|
One login_user → Many users (site memberships)
|
|
One site → Many users (members)
|
|
Unique constraint: (login_user_id, site_id)
|
|
|
|
Sessions reference login_user_id, not users.id
|
|
Site selection sets session.site_id
|
|
|
|
Key Principles:
|
|
|
|
Invitation = Site Membership Record
|
|
No separate invites table. The users record IS the invitation
|
|
in a pending state (invite_accepted_at = NULL, login_user_id = NULL)
|
|
|
|
Lazy Session Creation
|
|
Sessions not created for every page view
|
|
Only created when: login, Session::id() called, CSRF needed
|
|
Anonymous browsing does not consume session resources
|
|
|
|
Email Verification via Invitation
|
|
Clicking invite link verifies email ownership
|
|
New accounts created via invite are auto-verified
|
|
No separate email verification flow needed
|
|
|
|
EMAIL BINDING AND SECURITY
|
|
|
|
Overview:
|
|
RSX invitations are bound to specific email addresses for enterprise
|
|
security. An invitation sent to john@company.com can ONLY be accepted
|
|
by creating or logging into an account with that exact email address.
|
|
|
|
Why Email Binding:
|
|
This design reflects how enterprise SaaS applications work in practice:
|
|
|
|
Security Model:
|
|
- When IT admin invites "john@company.com", they expect THAT
|
|
person to access the account
|
|
- Cannot forward invite link to someone else to hijack access
|
|
- Audit trail integrity: Actions tied to corporate identity
|
|
- Compliance: User actions must be attributable to verified identity
|
|
|
|
Deprovisioning:
|
|
- When employee leaves and email is disabled, they're locked out
|
|
- Works correctly with enterprise SSO and identity management
|
|
- No backdoor access via personal email accounts
|
|
|
|
Enterprise Control:
|
|
- Users cannot escape corporate control by changing email
|
|
- Admin controls which corporate identities have access
|
|
- Prevents scenarios like changing from corporate@company.com
|
|
to personal@gmail.com
|
|
|
|
Email Matching Rules:
|
|
1. Invitation created with email = "user@company.com"
|
|
2. Invitation can be accepted by:
|
|
- Creating NEW login account with email = "user@company.com"
|
|
- Logging into EXISTING account with email = "user@company.com"
|
|
3. Invitation CANNOT be accepted by:
|
|
- Different email address (even if same person)
|
|
- User must logout and login/signup with correct email
|
|
|
|
Email Mismatch Handling:
|
|
When logged in user tries to accept invite for different email:
|
|
|
|
System Shows:
|
|
"Email Mismatch
|
|
|
|
This invitation was sent to: user@company.com
|
|
You are currently logged in as: other@example.com
|
|
|
|
To accept this invitation, you must logout and login or create
|
|
an account using the invited email address.
|
|
|
|
[Logout and Continue]
|
|
|
|
If you believe this is an error, contact your site administrator
|
|
to request the invitation be resent to your current email address."
|
|
|
|
Email Changes (Admin-Controlled):
|
|
Users CANNOT change their login email address. Only administrators
|
|
can update email addresses to maintain corporate identity control.
|
|
|
|
Legitimate scenarios for admin email changes:
|
|
- Company domain migration (company.com → newcompany.com)
|
|
- Legal name changes (jsmith@co.com → jjones@co.com)
|
|
- Typo corrections by admin
|
|
- Organizational mergers and acquisitions
|
|
|
|
When admin changes user email:
|
|
1. Update login_users.email (authentication identity)
|
|
2. Update all users.email records for that login_user_id
|
|
3. Log change in audit trail (future feature)
|
|
4. Notify user of email change (future feature)
|
|
|
|
Email change process:
|
|
- Admin-only UI for email management (future feature)
|
|
- Bulk domain migration tool (future feature)
|
|
- Audit log for compliance (future feature)
|
|
|
|
Override for Non-Enterprise Apps:
|
|
RSpade provides this as the default for 90% of enterprise SaaS use
|
|
cases. Developers building consumer apps or flexible-identity systems
|
|
can override this behavior since all authentication code is exposed
|
|
in /rsx/app/login/ as template application code, not locked in
|
|
the framework.
|
|
|
|
INVITATION WORKFLOW
|
|
|
|
Overview:
|
|
RSX uses Slack-style invitations where site admins invite users by
|
|
email. The invitation creates a pending users record bound to that
|
|
email address. When accepted, the user either creates a login account
|
|
or links their existing one, but ONLY if the email addresses match.
|
|
|
|
Multi-Account Philosophy:
|
|
RSX does NOT implement Google/Reddit-style multi-identity account
|
|
switching (switching between different email addresses in same browser).
|
|
|
|
Why no multi-identity switcher:
|
|
- People naturally segment by device/browser (work computer vs home)
|
|
- Original driver was multi-site access (one email, many sites)
|
|
- That problem is solved by current multi-site architecture
|
|
- Multi-identity switcher adds cognitive overhead
|
|
- Rare real-world need (users use browser profiles instead)
|
|
|
|
What RSX DOES support:
|
|
- Multi-site: One email address can be a member of multiple sites
|
|
- Site picker: User selects which site to access after login
|
|
- Clean separation: Different logins use different browser sessions
|
|
|
|
If users need multiple identities: Use different browsers/profiles,
|
|
or logout/login. This is standard across most B2B SaaS products.
|
|
|
|
Smart Invitation Acceptance:
|
|
When user clicks an invitation link, RSX auto-detects their account
|
|
state and shows the appropriate next action.
|
|
|
|
Why auto-detect instead of "Create Account OR Login" choice:
|
|
- Modern SaaS products (Slack, Teams, GitHub) auto-detect
|
|
- System knows if account exists for invited email
|
|
- Showing one correct action is less confusing than both options
|
|
- Email enumeration is not a security concern in invite context
|
|
(invitation itself proves we know the email address)
|
|
|
|
Acceptance Flow States:
|
|
1. Not logged in + No account exists
|
|
→ Show "Create Account" button
|
|
→ Small text: "Already have account? Sign in instead"
|
|
|
|
2. Not logged in + Account exists
|
|
→ Show "Sign In to Accept Invitation" button
|
|
→ Small text: "Not you? Use different account"
|
|
|
|
3. Logged in + Email matches
|
|
→ Show "Accept Invitation" button
|
|
→ User continues immediately
|
|
|
|
4. Logged in + Email mismatch
|
|
→ Show email mismatch error
|
|
→ Logout and continue option
|
|
|
|
5. Already accepted
|
|
→ Show "Go to Dashboard" button
|
|
|
|
Smart detection provides obvious next steps that match user's actual
|
|
state, reducing confusion and support requests.
|
|
|
|
1. Admin Invites User
|
|
|
|
Admin Action:
|
|
- Opens "Add User" modal from user management page
|
|
- Enters: email (required), first/last name (optional), role, phone
|
|
- Submits form
|
|
|
|
System Processing:
|
|
1. Verify email not already a member of this site
|
|
2. Create users record:
|
|
- email (for invitation)
|
|
- first_name, last_name, phone, role_id (from form)
|
|
- login_user_id = NULL (not linked yet)
|
|
- invite_code = generate_unique_code()
|
|
- invite_accepted_at = NULL (pending)
|
|
- invite_expires_at = now + 7 days (configurable)
|
|
3. TODO: Send invitation email to email address
|
|
Subject: "You've been invited to join [Site Name]"
|
|
Link: /accept-invite/{invite_code}
|
|
|
|
Result:
|
|
Pending membership created, invitation email sent
|
|
|
|
2. New User Accepts Invite (No Account)
|
|
|
|
User Action:
|
|
- Clicks /accept-invite/{code} from email
|
|
- Not logged in, no existing account
|
|
|
|
System Shows:
|
|
"You've been invited to join [Site Name]!
|
|
|
|
Create an account to get started:
|
|
Email: [email from invite] (pre-filled, read-only)
|
|
First Name: [from invite or blank]
|
|
Last Name: [from invite or blank]
|
|
Phone: [from invite or blank]
|
|
Password: [________]
|
|
Confirm Password: [________]
|
|
|
|
[Create Account & Accept Invite]
|
|
|
|
Already have an account? [Login instead]"
|
|
|
|
Validation:
|
|
- Email must match invitation email EXACTLY
|
|
- Email field is pre-filled and read-only (cannot be changed)
|
|
- Password minimum 8 characters
|
|
- Confirm password must match
|
|
|
|
On Account Creation:
|
|
1. Verify email matches invitation email
|
|
2. Create login_users record:
|
|
- email (from invite, verified to match)
|
|
- password (hashed)
|
|
- is_activated = true (auto-activate on invite)
|
|
- is_verified = true (email verified by invite click)
|
|
3. Link membership to login identity:
|
|
- users.login_user_id = login_user.id
|
|
- users.invite_accepted_at = now()
|
|
4. Log user in:
|
|
- Session::set_user(login_user)
|
|
- Session::set_site_id(users.site_id)
|
|
5. Redirect to /dashboard
|
|
|
|
Result:
|
|
New login_users record created with matching email, membership
|
|
linked and accepted, user logged in and redirected to site
|
|
|
|
3. Existing User Accepts Invite (Has Account)
|
|
|
|
3A. User Logged In, Email Matches
|
|
|
|
User Action:
|
|
- Clicks /accept-invite/{code} from email
|
|
- Already logged in with same email
|
|
|
|
Validation:
|
|
- Check logged in user email matches invitation email
|
|
- If match → proceed with acceptance
|
|
- If no match → show email mismatch error (see 3B below)
|
|
|
|
System Shows:
|
|
"Welcome back!
|
|
|
|
You've been invited to join [Site Name].
|
|
Click to accept and get started.
|
|
|
|
[Accept Invitation]"
|
|
|
|
On Acceptance:
|
|
1. Verify email match one more time:
|
|
- login_user.email === invitation.email
|
|
2. Link membership:
|
|
- users.login_user_id = current login_user.id
|
|
- users.invite_accepted_at = now()
|
|
3. Set site context:
|
|
- Session::set_site_id(users.site_id)
|
|
4. Redirect to /dashboard
|
|
|
|
Result:
|
|
Membership linked and accepted, user continues to site
|
|
|
|
3B. User Logged In, Email Mismatch
|
|
|
|
User Action:
|
|
- Clicks /accept-invite/{code} from email
|
|
- Logged in with DIFFERENT email
|
|
|
|
Email Check:
|
|
- Compare login_user.email with invitation.email
|
|
- If mismatch → Block acceptance and show error
|
|
|
|
System Shows:
|
|
"Email Mismatch
|
|
|
|
This invitation was sent to: [invite email]
|
|
You are currently logged in as: [current email]
|
|
|
|
To accept this invitation, you must be logged in with the
|
|
invited email address. This security measure ensures
|
|
invitations are accepted by the intended recipient.
|
|
|
|
[Logout and Continue]
|
|
|
|
If you believe this is an error, contact your site
|
|
administrator to request the invitation be updated or
|
|
resent to your current email address."
|
|
|
|
On "Logout and Continue":
|
|
1. Session::logout()
|
|
2. Redirect back to /accept-invite/{code}
|
|
3. User sees "not logged in" flow (section 3C)
|
|
4. Can now create account or login with correct email
|
|
|
|
Important:
|
|
- No "accept anyway" option (security requirement)
|
|
- Cannot bypass email matching
|
|
- Admin must update invitation email if legitimately wrong
|
|
- Prevents invite hijacking and unauthorized access
|
|
|
|
Result:
|
|
User must logout and use correct email to accept invitation
|
|
|
|
3C. User Not Logged In
|
|
|
|
User Action:
|
|
- Clicks /accept-invite/{code} from email
|
|
- Not logged in
|
|
|
|
System Shows:
|
|
"You've been invited to join [Site Name]!
|
|
|
|
Email: [invite email] (pre-filled)
|
|
Password: [________]
|
|
|
|
[Login & Accept Invite]
|
|
|
|
Don't have an account? [Create one]"
|
|
|
|
On Login:
|
|
- Authenticate login_users with email/password
|
|
- Check if email matches invite email
|
|
- If match → Same as 3A flow
|
|
- If mismatch → Same as 3B flow
|
|
|
|
On "Create one":
|
|
- Same as "New User Accepts Invite" flow (section 2)
|
|
|
|
Result:
|
|
User logs in or creates account, then follows appropriate flow
|
|
|
|
4. User Logs In (Regular Login Flow)
|
|
|
|
User Action:
|
|
- Navigates to /login
|
|
- Enters email and password
|
|
|
|
Authentication:
|
|
1. Find login_user by email
|
|
2. Verify password with password_verify()
|
|
3. If invalid, show error
|
|
4. If valid, create session:
|
|
- Session::set_user(login_user)
|
|
|
|
Site Selection Logic:
|
|
1. Query accepted memberships:
|
|
- WHERE login_user_id = current user
|
|
- AND invite_accepted_at IS NOT NULL
|
|
- AND invite_expires_at > now()
|
|
- AND deleted_at IS NULL
|
|
|
|
2. Count memberships:
|
|
|
|
Zero sites:
|
|
- Session::logout()
|
|
- Error: "You do not have access to any sites.
|
|
Contact your administrator."
|
|
- Stays on login page
|
|
|
|
One site:
|
|
- Session::set_site_id(site_id)
|
|
- Redirect to /dashboard
|
|
|
|
Multiple sites:
|
|
- Show site picker modal
|
|
- User clicks site name
|
|
- Session::set_site_id(selected_site_id)
|
|
- Redirect to /dashboard
|
|
|
|
First-Login Auto-Accept (Special Case):
|
|
After authentication, before site selection, check for pending
|
|
invites matching user's email:
|
|
|
|
1. Query pending invites:
|
|
- WHERE email = login_user.email
|
|
- AND login_user_id IS NULL
|
|
- AND invite_accepted_at IS NULL
|
|
- AND invite_expires_at > now()
|
|
|
|
2. If found:
|
|
- Show acceptance modal:
|
|
"You have a pending invitation to [Site Name].
|
|
Click to accept and get started."
|
|
- On accept:
|
|
- Link: invite.login_user_id = login_user.id
|
|
- Mark: invite.invite_accepted_at = now()
|
|
- Continue to normal site selection logic
|
|
|
|
IMPORTANT: This only happens on first login after invite sent.
|
|
Subsequent invites MUST be accepted via email link for security.
|
|
|
|
Result:
|
|
User authenticated and directed to appropriate site dashboard
|
|
|
|
SIGNUP WORKFLOW
|
|
|
|
Overview:
|
|
RSpade supports three signup modes configured in rsx/resource/config/rsx.php:
|
|
- anonymous: Anyone can create an account
|
|
- invite_only: Only invited users can sign up
|
|
- disabled: No new signups allowed (closed system)
|
|
|
|
Configuration:
|
|
File: rsx/resource/config/rsx.php
|
|
|
|
return [
|
|
// Signup mode
|
|
'signup_mode' => 'invite_only', // 'anonymous', 'invite_only', 'disabled'
|
|
|
|
// Verification requirements
|
|
'verification_required' => 'email', // 'email', 'sms', 'either', 'both'
|
|
|
|
// Invitation expiration (days)
|
|
'invite_expiration_days' => 7,
|
|
];
|
|
|
|
Configuration Philosophy:
|
|
These settings are provided as developer convenience for rapid testing.
|
|
They allow quickly switching between behaviors during development.
|
|
|
|
For production applications, developers should:
|
|
1. Determine which mode their application needs
|
|
2. Find the code using these config settings
|
|
3. Remove the switches and hardcode desired behavior
|
|
4. Customize the implementation to their exact needs
|
|
|
|
This keeps code simple and prevents "configuration explosion" where
|
|
every feature has multiple modes to maintain.
|
|
|
|
Signup Mode: anonymous
|
|
|
|
Behavior:
|
|
- Public /signup page available
|
|
- Anyone can create account without invitation
|
|
- Account created immediately
|
|
- Verification required based on verification_required setting
|
|
- User automatically added to site_id = 1 with role MEMBER
|
|
|
|
Use Cases:
|
|
- Public SaaS applications
|
|
- Marketing sites with trial signups
|
|
- Community platforms
|
|
- Any application where anyone can join
|
|
|
|
Workflow:
|
|
1. User visits /signup
|
|
2. Fills form: email, password, first/last name (optional)
|
|
3. Submits form
|
|
4. System creates login_users record (is_verified = false)
|
|
5. System creates users record (site_id = 1, role_id = 3 MEMBER)
|
|
6. Users record immediately accepted (invite_accepted_at = now)
|
|
7. If verification required:
|
|
- Send verification code via email/SMS
|
|
- Show verification modal (PIN entry)
|
|
- User enters code, account activated
|
|
- Redirect to dashboard
|
|
8. If verification not required:
|
|
- Log user in immediately
|
|
- Redirect to dashboard
|
|
|
|
Security Considerations:
|
|
- Rate limit signup endpoint (prevent spam accounts)
|
|
- CAPTCHA for anonymous signups (prevent bots)
|
|
- Email verification strongly recommended
|
|
- Monitor for abuse patterns
|
|
|
|
Signup Mode: invite_only
|
|
|
|
Behavior:
|
|
- /signup page requires invite code in URL: /signup?code={invite_code}
|
|
- Without valid invite code, redirects to login with error message
|
|
- Users can only create accounts via invitation acceptance
|
|
- Email must match invitation email (enforced)
|
|
- Invitation validates email ownership
|
|
|
|
Use Cases:
|
|
- Private beta applications
|
|
- Internal company tools
|
|
- B2B applications with controlled onboarding
|
|
- Default mode for RSpade starter template
|
|
|
|
Workflow:
|
|
See INVITATION WORKFLOW section above for complete details.
|
|
|
|
Summary:
|
|
1. Admin invites user → users record created (pending state)
|
|
2. User receives email with /accept-invite/:code link
|
|
3. User clicks link, system validates:
|
|
- Invitation exists
|
|
- Not expired (invite_expires_at > now)
|
|
- Not already accepted (invite_accepted_at IS NULL)
|
|
4. If not logged in: Show account creation form
|
|
- Email pre-filled from invitation (read-only)
|
|
- Email field cannot be changed (security)
|
|
- User sets password
|
|
5. If logged in with matching email: Show accept button
|
|
6. If logged in with different email: Show email mismatch error
|
|
7. Account created/linked, invitation accepted, user logged in
|
|
|
|
Email Matching Enforcement:
|
|
- Signup form email field is read-only (uses invitation email)
|
|
- Cannot create account with different email
|
|
- If user tries to accept with wrong logged-in account:
|
|
→ Show error and require logout
|
|
- Admin must update invitation if email legitimately wrong
|
|
|
|
Benefits:
|
|
- Email verified via invitation click
|
|
- Controlled user growth
|
|
- Know who invited each user
|
|
- Prevent spam signups
|
|
- Prevent invite hijacking (email matching requirement)
|
|
|
|
Signup Mode: disabled
|
|
|
|
Behavior:
|
|
- No signup page available
|
|
- /signup redirects to /login with error message
|
|
- No invitation acceptance allowed
|
|
- Accounts must be created manually by administrators
|
|
|
|
Use Cases:
|
|
- Closed systems (all users pre-created)
|
|
- Applications in maintenance mode
|
|
- Internal tools with fixed user list
|
|
- Temporarily disable signups during issues
|
|
|
|
Workflow:
|
|
Admin must manually create accounts:
|
|
1. Admin creates login_users record directly
|
|
2. Admin creates users record for site membership
|
|
3. Admin provides credentials to user separately
|
|
4. User logs in with provided credentials
|
|
|
|
Implementation:
|
|
Routes /signup and /accept-invite return 403 or redirect with:
|
|
"New account signups are currently disabled. Contact your
|
|
administrator for assistance."
|
|
|
|
Verification Methods
|
|
|
|
Email Verification:
|
|
1. On signup, generate random 6-digit PIN
|
|
2. Store in verification table with expiration
|
|
3. Send email with PIN code
|
|
4. Show modal: "Enter verification code sent to your email"
|
|
5. User enters PIN
|
|
6. Verify PIN matches and not expired
|
|
7. Mark login_user.is_verified = true
|
|
8. Delete verification record
|
|
9. Complete login
|
|
|
|
SMS Verification:
|
|
Similar to email, but:
|
|
- Require phone number during signup
|
|
- Send PIN via SMS service (Twilio, etc.)
|
|
- User enters PIN from text message
|
|
|
|
Either Verification:
|
|
- User chooses email OR SMS during signup
|
|
- Provide both options in UI
|
|
- Complete either one to verify account
|
|
|
|
Both Verification:
|
|
- Require BOTH email AND SMS verification
|
|
- Must complete both before account activated
|
|
- Higher security for sensitive applications
|
|
|
|
Verification UI:
|
|
RSpade includes a PIN verification modal component.
|
|
See: /rsx/theme/components/verification_pin_modal.jqhtml
|
|
|
|
Anonymous Signup Implementation
|
|
|
|
Route: /signup (GET)
|
|
Show signup form:
|
|
- Email (required, validated)
|
|
- Password (required, min 8 characters)
|
|
- Confirm Password (must match)
|
|
- First Name (optional)
|
|
- Last Name (optional)
|
|
- Phone (if SMS verification required)
|
|
- Terms acceptance checkbox
|
|
- "Already have an account? Login" link
|
|
|
|
Route: /signup (POST)
|
|
Process signup:
|
|
1. Validate input (email unique, password strength)
|
|
2. Check signup_mode = 'anonymous' (else error)
|
|
3. Create login_users record:
|
|
- email
|
|
- password (hashed)
|
|
- is_activated = true
|
|
- is_verified = false (until verification)
|
|
4. Create users record:
|
|
- login_user_id
|
|
- site_id = 1 (default site)
|
|
- role_id = 3 (MEMBER)
|
|
- email (same as login_users)
|
|
- first_name, last_name, phone
|
|
- invite_accepted_at = now() (not an invite, auto-accepted)
|
|
5. If verification required:
|
|
- Generate PIN, store with expiration
|
|
- Send via email/SMS
|
|
- Show verification modal
|
|
- Return (don't log in yet)
|
|
6. If no verification required:
|
|
- Session::set_user(login_user)
|
|
- Session::set_site_id(1)
|
|
- Redirect to /dashboard
|
|
|
|
Route: /verify-account (POST)
|
|
Verify PIN code:
|
|
1. Get PIN from input
|
|
2. Find verification record for user
|
|
3. Check PIN matches and not expired
|
|
4. Mark login_user.is_verified = true
|
|
5. Delete verification record
|
|
6. Session::set_user(login_user)
|
|
7. Session::set_site_id(1)
|
|
8. Redirect to /dashboard
|
|
|
|
API REFERENCE
|
|
|
|
RsxAuth Facade:
|
|
|
|
RsxAuth::check(): bool
|
|
Returns true if user is logged in
|
|
|
|
RsxAuth::user(): ?Login_User_Model
|
|
Returns current login_user instance or null
|
|
|
|
RsxAuth::id(): ?int
|
|
Returns current login_user_id or null
|
|
|
|
RsxAuth::session(): ?Session
|
|
Returns current session instance or null
|
|
|
|
RsxAuth::login(Login_User_Model $user): void
|
|
Authenticate user and create session
|
|
|
|
RsxAuth::logout(): void
|
|
Clear session and log user out
|
|
|
|
Session Methods:
|
|
|
|
Session::init(): void
|
|
Initialize session for current request
|
|
Loads session by token + experience_id
|
|
Called automatically by framework
|
|
|
|
Session::set_user(Login_User_Model $user): void
|
|
Set logged in user for session
|
|
Updates session.login_user_id
|
|
Persists to database
|
|
|
|
Session::get_user(): ?Login_User_Model
|
|
Get current logged in user
|
|
Returns null if not logged in
|
|
|
|
Session::get_user_id(): ?int
|
|
Get current login_user_id
|
|
Returns null if not logged in
|
|
|
|
Session::set_site_id(int $site_id): void
|
|
Set current site context for session
|
|
Updates session.site_id
|
|
Persists to database
|
|
|
|
Session::get_site_id(): int
|
|
Get current site_id
|
|
Returns 0 if no site selected
|
|
|
|
Session::get_site(): ?Site_Model
|
|
Get current site model instance
|
|
Returns null if no site selected
|
|
|
|
Session::logout(): void
|
|
Clear session and destroy token
|
|
Redirects to login page
|
|
|
|
Session::id(): string
|
|
Get session token (creates session if needed)
|
|
Use for anonymous tracking (e.g., file uploads)
|
|
|
|
Experience Methods (Advanced):
|
|
|
|
Session::set_experience_id(int $experience_id): void
|
|
Set authentication realm for request
|
|
Used for customer portals vs staff panels
|
|
Default 0 for standard applications
|
|
See MULTI-REALM AUTHENTICATION section
|
|
|
|
Session::get_experience_id(): int
|
|
Get current experience_id
|
|
Default 0
|
|
|
|
Request Override Methods (Advanced):
|
|
|
|
Session::set_request_site_id_override(int $site_id): void
|
|
Force site_id for current request only
|
|
Does NOT persist to session record
|
|
Used for subdomain-based tenant enforcement
|
|
See SUBDOMAIN-BASED MULTI-TENANT section
|
|
|
|
Session::clear_request_site_id_override(): void
|
|
Remove request-scoped site override
|
|
|
|
Session::has_request_site_id_override(): bool
|
|
Check if request override is active
|
|
|
|
CONTROLLER PATTERNS
|
|
|
|
Route Protection:
|
|
|
|
Use #[Auth] attribute on routes to require authentication:
|
|
|
|
#[Route('/dashboard')]
|
|
#[Auth('Permission::authenticated()')]
|
|
public static function dashboard(Request $request, array $params = [])
|
|
{
|
|
// User guaranteed to be logged in
|
|
$user = RsxAuth::user();
|
|
}
|
|
|
|
Built-in permission methods:
|
|
|
|
Permission::anybody() Allow all (public route)
|
|
Permission::authenticated() Require login
|
|
|
|
Custom permissions in rsx/permission.php:
|
|
|
|
class Permission
|
|
{
|
|
public static function is_admin(): bool|Response
|
|
{
|
|
if (!RsxAuth::check()) {
|
|
return false;
|
|
}
|
|
|
|
$user_id = RsxAuth::id();
|
|
$site_id = Session::get_site_id();
|
|
|
|
$user = User_Model::where('login_user_id', $user_id)
|
|
->where('site_id', $site_id)
|
|
->first();
|
|
|
|
return $user && $user->role_id <= 2; // OWNER or ADMIN
|
|
}
|
|
}
|
|
|
|
Usage in routes:
|
|
|
|
#[Route('/admin/users')]
|
|
#[Auth('Permission::is_admin()')]
|
|
public static function admin_users(Request $request, array $params = [])
|
|
{
|
|
// User is logged in AND is admin
|
|
}
|
|
|
|
Ajax Endpoint Authentication:
|
|
|
|
Ajax endpoints check authentication same as regular routes:
|
|
|
|
#[Ajax_Endpoint]
|
|
#[Auth('Permission::authenticated()')]
|
|
public static function save_settings(Request $request, array $params = [])
|
|
{
|
|
$user_id = RsxAuth::id();
|
|
// Process request
|
|
}
|
|
|
|
On authentication failure:
|
|
Regular routes: Redirect to /login
|
|
Ajax endpoints: Return JSON: {success: false, error_type: "permission_denied"}
|
|
|
|
Manual Authentication in Main.php:
|
|
|
|
Use pre_dispatch() for application-wide auth logic:
|
|
|
|
public static function pre_dispatch(Request $request, array $params = [])
|
|
{
|
|
// Require login for all /app/* routes
|
|
if (str_starts_with($request->path(), 'app/')) {
|
|
if (!RsxAuth::check()) {
|
|
return redirect('/login');
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
SINGLE-SITE APPLICATIONS
|
|
|
|
Overview:
|
|
RSX is multi-tenant by default, but works perfectly for single-site
|
|
applications. Simply use site_id = 1 for everything.
|
|
|
|
Configuration:
|
|
No special configuration needed. Architecture is identical.
|
|
|
|
Behavior:
|
|
|
|
User Invitation:
|
|
- Admin invites user → users.site_id = 1
|
|
- User accepts → membership on site 1
|
|
|
|
Login Flow:
|
|
- User logs in → System finds 1 site membership
|
|
- Automatically sets session.site_id = 1
|
|
- No site picker shown (only one option)
|
|
|
|
User Removal:
|
|
- Remove user from site 1 → No site memberships
|
|
- Next login → "No site access" error
|
|
- User forced to logout
|
|
|
|
Code Access:
|
|
Current site ID: Session::get_site_id() → Always returns 1
|
|
Current site: Session::get_site() → Always returns site 1
|
|
|
|
Key Points:
|
|
- Invitation workflow identical to multi-tenant
|
|
- Site picker never shows (always exactly 1 site)
|
|
- User management CRUD same as multi-tenant
|
|
- No special single-site mode needed
|
|
- Easy upgrade path to multi-tenant if needed
|
|
|
|
MULTI-REALM AUTHENTICATION (ADVANCED)
|
|
|
|
Concept:
|
|
Experience = Authentication realm. One user can be logged into multiple
|
|
experiences simultaneously using the same cookie, with different
|
|
login_user_id per experience.
|
|
|
|
Use Cases:
|
|
- Customer portal (/portal/*) + Staff panel (/staff/*)
|
|
- Public site + Admin area with separate logins
|
|
- Multiple authentication contexts in one application
|
|
|
|
Database Structure:
|
|
sessions table has experience_id column (default 0)
|
|
Same session_token can have multiple rows with different experience_id
|
|
|
|
Implementation in Main.php:
|
|
|
|
public static function pre_dispatch(Request $request, array $params = [])
|
|
{
|
|
$path = $request->path();
|
|
|
|
if (str_starts_with($path, 'staff/')) {
|
|
Session::set_experience_id(1); // Staff realm
|
|
} elseif (str_starts_with($path, 'portal/')) {
|
|
Session::set_experience_id(2); // Customer realm
|
|
} else {
|
|
Session::set_experience_id(0); // Default realm
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
How It Works:
|
|
1. User visits /portal/dashboard → experience_id = 2
|
|
Session loads: WHERE token=X AND experience_id=2
|
|
User logged in as customer (login_user_id=123)
|
|
|
|
2. Same user visits /staff/admin → experience_id = 1
|
|
Session loads: WHERE token=X AND experience_id=1
|
|
User logged in as staff (login_user_id=456)
|
|
|
|
3. Both sessions active simultaneously with same cookie
|
|
|
|
Experience Methods:
|
|
Session::set_experience_id(int $id) Set realm for request
|
|
Session::get_experience_id() Get current realm
|
|
|
|
Important:
|
|
Most applications use default experience_id = 0
|
|
Only implement multi-realm if you need separate authentication
|
|
contexts within same application
|
|
|
|
SUBDOMAIN-BASED MULTI-TENANT (ADVANCED)
|
|
|
|
Concept:
|
|
Each site/tenant has their own subdomain. Site is determined by
|
|
subdomain, not user choice. Users cannot switch sites arbitrarily.
|
|
|
|
URL Structure:
|
|
acme.yourapp.com → site_id = 1 (Acme Corp)
|
|
widget.yourapp.com → site_id = 2 (Widget Inc)
|
|
|
|
Implementation in Main.php:
|
|
|
|
public static function pre_dispatch(Request $request, array $params = [])
|
|
{
|
|
$host = $request->getHost();
|
|
$subdomain = explode('.', $host)[0];
|
|
|
|
$site = Site_Model::where('slug', $subdomain)->first();
|
|
|
|
if (!$site) {
|
|
return response('Site not found', 404);
|
|
}
|
|
|
|
// Enforce site for current request (does NOT persist to session)
|
|
Session::set_request_site_id_override($site->id);
|
|
|
|
return null;
|
|
}
|
|
|
|
How It Works:
|
|
1. User visits acme.yourapp.com
|
|
2. Framework sets request override: site_id = 1
|
|
3. All code calling Session::get_site_id() gets site 1
|
|
4. Session record site_id remains null (not persisted)
|
|
5. Next request on widget.yourapp.com gets site 2
|
|
|
|
Benefits:
|
|
- No site picker needed (determined by URL)
|
|
- No session writes for site switching (performance)
|
|
- Clear tenant isolation via DNS
|
|
- Traditional "have account or don't" UX
|
|
|
|
Traditional Login Flow:
|
|
- User goes to acme.yourapp.com/login
|
|
- Subdomain determines site_id = 1
|
|
- User logs in
|
|
- System checks: Is user a member of site 1?
|
|
- If yes: Allow login, redirect to dashboard
|
|
- If no: Error "No access to this site"
|
|
|
|
This is a future planned feature. Current implementation uses
|
|
organization picker (Trello-style) by default.
|
|
|
|
CONFIGURATION
|
|
|
|
Configuration File: rsx/resource/config/rsx.php
|
|
|
|
return [
|
|
// Invitation expiration (days)
|
|
'invite_expiration_days' => 7,
|
|
|
|
// Session lifetime (minutes)
|
|
'session_lifetime' => 525600, // 365 days
|
|
|
|
// Session cookie name
|
|
'session_cookie_name' => 'rsx_session',
|
|
];
|
|
|
|
Usage in Code:
|
|
|
|
$days = config('rsx.invite_expiration_days', 7);
|
|
$expires_at = now()->addDays($days);
|
|
|
|
REQUIRED ROUTES
|
|
|
|
The following routes must be implemented for complete authentication:
|
|
|
|
Login/Logout:
|
|
/login Show login form
|
|
/login (POST) Process login
|
|
/logout Logout user
|
|
|
|
Invitation Acceptance:
|
|
/accept-invite/:code Show acceptance page
|
|
/accept-invite/:code/signup (POST)
|
|
Create account from invite
|
|
/accept-invite/:code/login (POST)
|
|
Login and accept invite
|
|
|
|
Site Selection (Multi-tenant):
|
|
/select-site Show site picker (multiple sites)
|
|
/select-site/:site_id (POST)
|
|
Set site and redirect
|
|
|
|
User Management (Admin):
|
|
/settings/user-management List users + Add User button
|
|
/settings/user-management/view/:id
|
|
View user details
|
|
|
|
Ajax Endpoints:
|
|
Frontend_Settings_User_Management_Controller::add_user()
|
|
Create invitation (modal form)
|
|
Frontend_Settings_User_Management_Controller::datagrid_fetch()
|
|
Load user list for DataGrid
|
|
|
|
SESSION LIFECYCLE
|
|
|
|
Session Creation:
|
|
Sessions are created lazily, not for every page view.
|
|
|
|
Created when:
|
|
- User logs in (Session::set_user())
|
|
- Session::id() explicitly called (e.g., file upload tracking)
|
|
- CSRF token requested (requires session)
|
|
|
|
NOT created for:
|
|
- Anonymous page views (public pages)
|
|
- Static asset requests (CSS, JS, images)
|
|
- Until explicitly needed by code
|
|
|
|
Session Fields:
|
|
session_token Unique identifier (stored in cookie)
|
|
login_user_id FK to login_users (NULL for anonymous)
|
|
site_id Current site context (NULL until selected)
|
|
experience_id Authentication realm (default 0)
|
|
active Session validity flag
|
|
expires_at Session expiration (default 365 days)
|
|
created_at Session creation timestamp
|
|
updated_at Last activity timestamp
|
|
|
|
Session States:
|
|
|
|
Anonymous Session:
|
|
- session_token exists
|
|
- login_user_id = NULL
|
|
- site_id = NULL
|
|
- Used for: File uploads, form CSRF, tracking
|
|
|
|
Authenticated Session (No Site):
|
|
- session_token exists
|
|
- login_user_id set
|
|
- site_id = NULL
|
|
- Occurs: After login, before site selection
|
|
|
|
Authenticated Session (With Site):
|
|
- session_token exists
|
|
- login_user_id set
|
|
- site_id set
|
|
- Normal working state for logged in users
|
|
|
|
Session Expiration:
|
|
Default lifetime: 365 days (configurable)
|
|
Updated on every request
|
|
Expired sessions automatically cleaned up
|
|
|
|
Important Notes:
|
|
- Session cookie ≠ Logged in user
|
|
- RsxAuth::check() only true if login_user_id set
|
|
- Session persists across login/logout
|
|
- Session token can outlive login status
|
|
|
|
SECURITY CONSIDERATIONS
|
|
|
|
Password Storage:
|
|
- Use password_hash() with bcrypt (PHP default)
|
|
- Never store plaintext passwords
|
|
- Verify with password_verify()
|
|
|
|
Email Verification:
|
|
- Invitation click verifies email ownership
|
|
- New accounts via invite auto-verified
|
|
- is_verified = true on invitation acceptance
|
|
|
|
Invitation Security:
|
|
- Unique random invite codes (generate_unique_code())
|
|
- Expiration enforced (default 7 days)
|
|
- First-login auto-accept only on first login
|
|
- Subsequent invites require email link click
|
|
- Email mismatch protection on acceptance
|
|
|
|
Session Security:
|
|
- Unique session tokens per session
|
|
- Token stored in httponly cookie
|
|
- Session expiration enforced
|
|
- Active flag for forced logout
|
|
|
|
CSRF Protection:
|
|
- All POST routes require CSRF token
|
|
- Token tied to session
|
|
- Automatic validation by framework
|
|
|
|
EXAMPLES
|
|
|
|
Example 1: Check if User is Logged In
|
|
|
|
if (RsxAuth::check()) {
|
|
$user = RsxAuth::user();
|
|
echo "Welcome, {$user->email}!";
|
|
} else {
|
|
echo "Please log in.";
|
|
}
|
|
|
|
Example 2: Get Current Site Membership
|
|
|
|
$login_user_id = RsxAuth::id();
|
|
$site_id = Session::get_site_id();
|
|
|
|
$membership = User_Model::where('login_user_id', $login_user_id)
|
|
->where('site_id', $site_id)
|
|
->first();
|
|
|
|
if ($membership) {
|
|
echo "Role: {$membership->role_id}";
|
|
echo "Name: {$membership->first_name} {$membership->last_name}";
|
|
}
|
|
|
|
Example 3: Create User Invitation
|
|
|
|
$user = new User_Model();
|
|
$user->site_id = Session::get_site_id();
|
|
$user->email = $request->input('email');
|
|
$user->first_name = $request->input('first_name');
|
|
$user->last_name = $request->input('last_name');
|
|
$user->role_id = $request->input('role_id');
|
|
$user->login_user_id = null; // Not linked yet
|
|
$user->invite_code = Str::random(32);
|
|
$user->invite_accepted_at = null;
|
|
$user->invite_expires_at = now()->addDays(
|
|
config('rsx.invite_expiration_days', 7)
|
|
);
|
|
$user->save();
|
|
|
|
// TODO: Send invitation email
|
|
// $url = url('/accept-invite/' . $user->invite_code);
|
|
// Mail::to($user->email)->send(new InvitationEmail($url));
|
|
|
|
Example 4: Accept Invitation (Existing User)
|
|
|
|
$invite = User_Model::where('invite_code', $code)
|
|
->whereNull('invite_accepted_at')
|
|
->where('invite_expires_at', '>', now())
|
|
->firstOrFail();
|
|
|
|
$login_user = RsxAuth::user();
|
|
|
|
if ($login_user->email !== $invite->email) {
|
|
return error('Email mismatch. This invite was sent to a different email.');
|
|
}
|
|
|
|
$invite->login_user_id = $login_user->id;
|
|
$invite->invite_accepted_at = now();
|
|
$invite->save();
|
|
|
|
Session::set_site_id($invite->site_id);
|
|
return redirect('/dashboard');
|
|
|
|
Example 5: Site Selection After Login
|
|
|
|
$login_user = RsxAuth::user();
|
|
|
|
$sites = User_Model::where('login_user_id', $login_user->id)
|
|
->whereNotNull('invite_accepted_at')
|
|
->where('invite_expires_at', '>', now())
|
|
->with('site')
|
|
->get();
|
|
|
|
if ($sites->count() === 0) {
|
|
RsxAuth::logout();
|
|
return error('No site access');
|
|
}
|
|
|
|
if ($sites->count() === 1) {
|
|
Session::set_site_id($sites[0]->site_id);
|
|
return redirect('/dashboard');
|
|
}
|
|
|
|
// Multiple sites - show picker
|
|
return view('auth/site-picker', ['sites' => $sites]);
|
|
|
|
Example 6: Protect Route with Custom Permission
|
|
|
|
In rsx/permission.php:
|
|
|
|
public static function is_owner(): bool|Response
|
|
{
|
|
if (!RsxAuth::check()) {
|
|
return false;
|
|
}
|
|
|
|
$membership = User_Model::where('login_user_id', RsxAuth::id())
|
|
->where('site_id', Session::get_site_id())
|
|
->first();
|
|
|
|
return $membership && $membership->role_id === 1;
|
|
}
|
|
|
|
In controller:
|
|
|
|
#[Route('/admin/delete-site')]
|
|
#[Auth('Permission::is_owner()')]
|
|
public static function delete_site(Request $request, array $params = [])
|
|
{
|
|
// Only site owners can access this
|
|
}
|
|
|
|
FUTURE PLANNED FEATURES
|
|
|
|
The following features are documented here for completeness but not yet
|
|
implemented. They represent the roadmap for authentication enhancements.
|
|
|
|
Enterprise Domain Locking:
|
|
Allow enterprise admins to "claim" email domains and control how
|
|
corporate email addresses are used with the platform.
|
|
|
|
Use Cases:
|
|
- Corporate IT controls where company email addresses are used
|
|
- Prevents employees from accepting invitations to external sites
|
|
using corporate email
|
|
- Centralized governance of corporate identity usage
|
|
- Audit trail of all sites using company domain
|
|
|
|
Domain Claiming:
|
|
- Enterprise admin claims domain (e.g., @acmecorp.com)
|
|
- Proves ownership via DNS TXT record or email verification
|
|
- Sets policy for that domain across entire platform
|
|
|
|
Policy Options:
|
|
1. Block External Sites
|
|
- Employees cannot accept invitations to other sites
|
|
- All @acmecorp.com addresses restricted to company's sites only
|
|
- Invitations to external sites automatically rejected
|
|
|
|
2. Require Approval
|
|
- External invitations go to approval queue
|
|
- Enterprise admin reviews and approves/rejects
|
|
- Approved invitations proceed normally
|
|
|
|
3. Allow But Notify
|
|
- Employees can accept any invitation
|
|
- Enterprise admin receives notification
|
|
- Provides audit trail without blocking access
|
|
|
|
Implementation Considerations:
|
|
- Similar to Slack's "Workspace Claiming" feature
|
|
- Similar to Microsoft's "Tenant Restrictions"
|
|
- Standard enterprise feature in B2B SaaS
|
|
- Balances employee flexibility with corporate control
|
|
|
|
Future Enhancement:
|
|
- SSO integration (SAML/OAuth)
|
|
- Automatic provisioning/deprovisioning via SCIM
|
|
- Group-based access control
|
|
- Conditional access policies
|
|
|
|
User Quota Enforcement:
|
|
- Track user count per site (sites.user_count)
|
|
- Prevent invitations when at limit
|
|
- Different quota tiers per plan/subscription
|
|
- Grace period for over-quota sites
|
|
- Admin override for quota enforcement
|
|
|
|
Email System Integration:
|
|
- Send invitation emails with acceptance link
|
|
- Send acceptance confirmation to admin
|
|
- Send "User joined site" notification
|
|
- Admin notification for email mismatch requests
|
|
- Resend invitation functionality
|
|
- Email template customization per site
|
|
|
|
Invitation Management UI:
|
|
- Resend invitation (generates new code)
|
|
- Cancel/revoke pending invitations
|
|
- View invitation status and history
|
|
- Bulk invitation import (CSV)
|
|
- Invitation acceptance tracking
|
|
|
|
Subdomain-Based Tenant Isolation:
|
|
- Traditional login flow (have account or don't)
|
|
- No site picker (determined by subdomain)
|
|
- Site-specific branding and theming
|
|
- Custom domains per tenant
|
|
- DNS-based site enforcement
|
|
|
|
Advanced Permissions:
|
|
- Granular permission system beyond roles
|
|
- Custom permissions per site
|
|
- Permission inheritance and grouping
|
|
- Role-based access control (RBAC) framework
|
|
- Permission caching for performance
|
|
|
|
Account Management:
|
|
- Password reset flow
|
|
- Email change verification
|
|
- Two-factor authentication (2FA)
|
|
- Account recovery options
|
|
- Login history and security audit log
|
|
|
|
Session Management:
|
|
- Active session listing
|
|
- Force logout from all devices
|
|
- Session activity monitoring
|
|
- Concurrent session limits
|
|
- IP-based session validation
|
|
|
|
SEE ALSO
|
|
session - RSX session management API reference
|
|
model - Model system with relationships
|
|
routing - Type-safe URL generation and route patterns
|
|
permission - Custom permission implementation
|