Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,7 @@
# MaplePress Frontend Environment Variables
# Backend API URL
VITE_API_BASE_URL=http://localhost:8000
# Development/Production mode (set by Vite automatically)
# NODE_ENV=development

24
web/maplepress-frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1,237 @@
# MaplePress Frontend
React-based web application for managing MaplePress cloud services for WordPress.
## Features
- User authentication (login/register)
- Dashboard for managing WordPress sites
- Cloud-based search indexing
- API key management
- Analytics and metrics
## Tech Stack
- **React 19** - UI framework
- **Vite** - Build tool and dev server
- **TailwindCSS 4** - Utility-first CSS
- **react-router** - Client-side routing
## Getting Started
### Prerequisites
- Node.js 18+
- npm or yarn
### Installation
```bash
# Install dependencies
npm install
# Copy environment variables
cp .env.example .env
# Edit .env and configure your backend API URL
```
### Development
```bash
# Start development server
npm run dev
# The app will be available at http://localhost:5173
```
### Building
```bash
# Build for production
npm run build
# Preview production build
npm run preview
```
### Linting
```bash
# Run ESLint
npm run lint
```
## Project Structure
```
src/
├── services/ # Service layer (business logic)
│ ├── Manager/ # Manager services
│ ├── API/ # API client
│ └── Services.jsx # Service registry
├── pages/ # Page components
│ ├── Home/ # Landing page
│ ├── Auth/ # Login/Register
│ └── Dashboard/ # User dashboard
├── App.jsx # Main app with routing
└── main.jsx # Entry point
```
## Architecture
The application follows a clean architecture pattern with:
1. **Service Layer** - Business logic and API communication
2. **Pages** - UI components organized by feature
3. **Dependency Injection** - Services provided via React Context
See [FRONTEND_ARCHITECTURE.md](./docs/FRONTEND_ARCHITECTURE.md) for detailed architecture documentation.
## Current Implementation Status
✅ **Implemented:**
- Project structure and organization
- Service layer with dependency injection
- **AuthManager service** (complete with token storage and automatic refresh)
- **ApiClient** for HTTP requests (with automatic token refresh)
- **HealthService** - Backend health check and monitoring
- **RegisterService** - Full registration API integration
- **LoginService** - Full login API integration
- **RefreshTokenService** - Full token refresh API integration
- **HelloService** - Authenticated test endpoint
- **MeService** - User profile with role-based helpers
- **TenantService** - Tenant/organization management
- **UserService** - User management with tenant context
- **SiteService** - WordPress site management with API key handling
- **AdminService** - Admin account management and lockout handling
- Home/landing page
- **Login page** - Complete with backend integration
- **Register page** - Complete with all required fields
- Dashboard page (stub)
- Routing setup
- Token storage in localStorage
- Token expiry detection
- **Automatic token refresh** (proactive and reactive)
- Session persistence
🚧 **To be implemented:**
- Protected route guards
- Logout API integration
- API key management UI
- Site management UI
- Search index configuration
- Analytics dashboard
- User profile management
- Password recovery flow
## Environment Variables
Create a `.env` file based on `.env.example`:
```bash
# Backend API URL
VITE_API_BASE_URL=http://localhost:8000
```
## Available Pages
- `/` - Home/landing page
- `/login` - User login
- `/register` - User registration
- `/dashboard` - User dashboard (requires auth)
## Development Workflow
1. Services are located in `src/services/`
2. Pages are located in `src/pages/`
3. Use service hooks to access backend functionality:
```javascript
const { authManager } = useAuth();
const { apiClient } = useApi();
```
4. Follow TailwindCSS utility patterns for styling
## Contributing
When adding new features:
1. Create services in `src/services/` for business logic
2. Create pages in `src/pages/` organized by feature
3. Add routes in `App.jsx`
4. Use existing service patterns for consistency
## Backend Integration
This frontend is designed to work with the MaplePress backend API at:
`cloud/mapleopentech-backend`
The backend should be running on the URL specified in `VITE_API_BASE_URL`.
### Error Handling with RFC 9457
The frontend fully supports **RFC 9457 (Problem Details for HTTP APIs)** for error handling. The backend returns standardized error responses that are automatically parsed by `ApiClient`:
```json
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "One or more validation errors occurred",
"errors": {
"email": ["Invalid email format"],
"password": ["Password must be at least 8 characters"]
}
}
```
**Key Features:**
- 🔄 Automatic error parsing in `ApiClient.js`
- 📋 Field-level validation errors preserved
- 🎯 User-friendly error messages
- 🔗 Consistent with backend error format
Services that handle validation errors:
- `RegisterService` - User registration with detailed field errors
- `SiteService` - WordPress site creation with domain validation
- All services inherit RFC 9457 parsing from `ApiClient`
See detailed documentation in [`docs/API/REGISTRATION_API.md`](./docs/API/REGISTRATION_API.md#error-handling) for examples.
## Documentation
Comprehensive documentation is available in the `docs/` directory:
### Architecture Documentation
- **[FRONTEND_ARCHITECTURE.md](./docs/FRONTEND_ARCHITECTURE.md)** - Complete architecture guide with three-layer service pattern
- **[ARCHITECTURE_SIMPLE.md](./docs/ARCHITECTURE_SIMPLE.md)** - Quick reference guide
- **[ACCESS_REFRESH_TOKEN_IMPLEMENTATION.md](./docs/ACCESS_REFRESH_TOKEN_IMPLEMENTATION.md)** - Token refresh implementation guide
- **[TOKEN_REFRESH_ANALYSIS.md](./docs/TOKEN_REFRESH_ANALYSIS.md)** - Token refresh analysis
### API Integration Documentation
All API integration guides are located in [docs/API/](./docs/API/):
- **[HEALTH_API.md](./docs/API/HEALTH_API.md)** - Backend health check and monitoring
- **[REGISTRATION_API.md](./docs/API/REGISTRATION_API.md)** - User registration implementation and API integration
- **[LOGIN_API.md](./docs/API/LOGIN_API.md)** - User login implementation and API integration
- **[REFRESH_TOKEN_API.md](./docs/API/REFRESH_TOKEN_API.md)** - Token refresh implementation with automatic session maintenance
- **[HELLO_API.md](./docs/API/HELLO_API.md)** - Hello endpoint for authentication testing
- **[ME_API.md](./docs/API/ME_API.md)** - User profile endpoint with role-based access helpers
- **[TENANT_API.md](./docs/API/TENANT_API.md)** - Tenant management for multi-tenant architecture
- **[USER_API.md](./docs/API/USER_API.md)** - User management with tenant context
- **[SITE_API.md](./docs/API/SITE_API.md)** - WordPress site management with API key handling
- **[ADMIN_API.md](./docs/API/ADMIN_API.md)** - Admin account management and lockout handling
## License
This application is licensed under the [**GNU Affero General Public License v3.0**](https://opensource.org/license/agpl-v3). See [LICENSE](../../LICENSE) for more information.
## Related Projects
- [MaplePress Backend](../../cloud/mapleopentech-backend) - Backend API
- [MaplePress WordPress Plugin](../../native/wordpress/maplepress-plugin) - WordPress integration
- [MapleFile](../../web/maplefile-frontend) - Encrypted file storage
## Support
Found a bug? Want a feature to improve MaplePress? Please create an [issue](https://codeberg.org/mapleopentech/monorepo/issues/new).

View file

@ -0,0 +1,54 @@
version: "3"
tasks:
# Development task to start the local development server
dev:
desc: "Start the development server with hot module replacement"
cmds:
- npm run dev
# Production build task
build:
desc: "Build the production version of the project"
cmds:
# Build the project using Vite's build command
- npm run build
# Deployment task (similar to the original, but adapted for Vite)
deploy:
desc: "Build and deploy the production version to a static site repository"
cmds:
# Build the project
- npm run build
# Checkout prod branch in the target repo
- git -C ../../../maplepress-frontend-static checkout -B prod
# Copy build files (Vite generates the "dist" directory by default)
- cp -Rf ./dist/* ../../../maplepress-frontend-static
# Remove build directory
- rm -Rf ./dist
# Commit and push changes
- git -C ../../../maplepress-frontend-static add --all
- git -C ../../../maplepress-frontend-static commit -m 'Latest production deployment.'
- git -C ../../../maplepress-frontend-static push origin prod
# Optional: Lint and type-check task
lint:
desc: "Run ESLint and TypeScript type checking"
cmds:
- npm run lint
- npm run typecheck
# Optional: Run tests
test:
desc: "Run project tests"
cmds:
- npm run test
undelast:
desc: Undue last commit which was not pushed. Special thanks to https://www.nobledesktop.com/learn/git/undo-changes.
cmds:
- git reset --soft HEAD~

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,827 @@
# Admin API Implementation (Account Management)
This document describes the implementation of the Admin API endpoints for the MaplePress frontend, integrated with the MaplePress backend API.
## Overview
The Admin API endpoints provide administrative operations for managing user accounts, specifically handling account lockouts that occur due to failed login attempts. These endpoints implement CWE-307 protection (Improper Restriction of Excessive Authentication Attempts) by allowing administrators to check lock status and manually unlock accounts.
**⚠️ SECURITY**: These endpoints require admin authentication and should only be accessible to users with admin or root roles.
## Backend API Endpoints
### Check Account Lock Status
**Endpoint**: `GET /api/v1/admin/account-status`
**Authentication**: Required (JWT token with admin role)
**Query Parameters**: `email` (required)
### Unlock Locked Account
**Endpoint**: `POST /api/v1/admin/unlock-account`
**Authentication**: Required (JWT token with admin role)
**Source Files**:
- `cloud/maplepress-backend/internal/interface/http/handler/admin/account_status_handler.go`
- `cloud/maplepress-backend/internal/interface/http/handler/admin/unlock_account_handler.go`
## Request/Response Structures
### Check Account Status Request
```
GET /api/v1/admin/account-status?email=user@example.com
```
**Headers Required:**
- `Authorization: JWT {access_token}` (admin role required)
### Check Account Status Response
```json
{
"email": "user@example.com",
"is_locked": true,
"failed_attempts": 5,
"remaining_time": "5 minutes 30 seconds",
"remaining_seconds": 330
}
```
**When Account Not Locked:**
```json
{
"email": "user@example.com",
"is_locked": false,
"failed_attempts": 2
}
```
### Unlock Account Request
```json
{
"email": "user@example.com"
}
```
**Headers Required:**
- `Content-Type: application/json`
- `Authorization: JWT {access_token}` (admin role required)
### Unlock Account Response
```json
{
"success": true,
"message": "Account unlocked successfully",
"email": "user@example.com"
}
```
**When Account Not Locked:**
```json
{
"success": true,
"message": "Account is not locked",
"email": "user@example.com"
}
```
## Frontend Implementation
### AdminService (`src/services/API/AdminService.js`)
Handles all admin operations for account management.
**Key Features:**
- Check account lock status with detailed information
- Unlock locked accounts (with security event logging)
- Helper to check if account needs unlocking
- Client-side email validation
- Remaining time formatting
- Admin role enforcement with clear error messages
**Methods:**
#### `getAccountStatus(email)`
Check if a user account is locked due to failed login attempts.
```javascript
import AdminService from './services/API/AdminService';
const status = await AdminService.getAccountStatus("user@example.com");
console.log(status);
// Output:
// {
// email: "user@example.com",
// isLocked: true,
// failedAttempts: 5,
// remainingTime: "5 minutes 30 seconds",
// remainingSeconds: 330
// }
```
**Parameters:**
- `email` (string, required): User's email address to check
**Returns:**
```javascript
{
email: string, // User's email
isLocked: boolean, // Whether account is locked
failedAttempts: number, // Number of failed login attempts
remainingTime: string, // Human-readable time until unlock
remainingSeconds: number // Seconds until automatic unlock
}
```
**Throws:**
- "Email is required" - If email is missing
- "Email cannot be empty" - If email is empty after trimming
- "Invalid email format" - If email format is invalid
- "Admin authentication required. Please log in with admin credentials." - Missing/invalid admin token (401)
- "Access denied. Admin privileges required for this operation." - User is not admin (403)
#### `unlockAccount(email)`
Unlock a user account that has been locked due to failed login attempts.
```javascript
const result = await AdminService.unlockAccount("user@example.com");
console.log(result);
// Output:
// {
// success: true,
// message: "Account unlocked successfully",
// email: "user@example.com"
// }
```
**⚠️ SECURITY EVENT**: This operation logs a security event (`ACCOUNT_UNLOCKED`) with the admin user ID who performed the unlock operation. This creates an audit trail for security compliance.
**Parameters:**
- `email` (string, required): User's email address to unlock
**Returns:**
```javascript
{
success: boolean,
message: string,
email: string
}
```
**Throws:**
- "Email is required" - If email is missing
- "Email cannot be empty" - If email is empty after trimming
- "Invalid email format" - If email format is invalid
- "Account is not currently locked." - If account is not locked
- "Admin authentication required. Please log in with admin credentials." - Missing/invalid admin token (401)
- "Access denied. Admin privileges required for this operation." - User is not admin (403)
#### `needsUnlock(email)`
Check if an account needs unlocking (is locked with remaining time).
```javascript
const needs = await AdminService.needsUnlock("user@example.com");
console.log(needs); // true or false
```
**Parameters:**
- `email` (string, required): User's email address to check
**Returns:** `boolean` - True if account is locked and needs admin unlock
**Use Case:** Check before showing "Unlock Account" button in admin UI.
#### `validateEmail(email)`
Validate email format.
```javascript
const result = AdminService.validateEmail("user@example.com");
console.log(result); // { valid: true, error: null }
const invalid = AdminService.validateEmail("invalid-email");
console.log(invalid); // { valid: false, error: "Invalid email format" }
```
**Returns:** `{ valid: boolean, error: string|null }`
**Validation Rules:**
- Required (non-empty)
- Valid email format (`user@domain.com`)
- Maximum 255 characters
#### `formatRemainingTime(seconds)`
Format remaining time for display.
```javascript
const formatted = AdminService.formatRemainingTime(330);
console.log(formatted); // "5 minutes 30 seconds"
const oneHour = AdminService.formatRemainingTime(3661);
console.log(oneHour); // "1 hour 1 minute 1 second"
```
**Returns:** `string` (e.g., "5 minutes 30 seconds", "1 hour", "30 seconds")
## Data Flow
### Check Account Status Flow
```
Admin provides email to check
AdminService.getAccountStatus()
Validate email format (client-side)
ApiClient.get() with JWT token (admin role)
Token automatically refreshed if needed
GET /api/v1/admin/account-status?email=...
Backend checks Redis for lock status
Backend returns lock info and failed attempts
AdminService transforms response
Component displays lock status
```
### Unlock Account Flow
```
Admin requests account unlock
AdminService.unlockAccount()
Validate email format (client-side)
ApiClient.post() with JWT token (admin role)
Token automatically refreshed if needed
POST /api/v1/admin/unlock-account
Backend checks if account is locked
Backend clears lock status in Redis
Backend logs security event (ACCOUNT_UNLOCKED)
Backend returns success response
AdminService transforms response
Component displays unlock confirmation
```
## Error Handling
### Error Types
| Error Condition | Response | Frontend Behavior |
|----------------|----------|-------------------|
| Missing admin authentication | 401 Unauthorized | "Admin authentication required." |
| Insufficient privileges | 403 Forbidden | "Access denied. Admin privileges required." |
| Invalid email | 400 Bad Request | Specific validation error |
| Account not locked (unlock) | 200 OK | "Account is not locked" (success) |
| Server error | 500 Internal Server Error | Generic error message |
## Usage Examples
### Admin Panel - Check Account Status
```javascript
import React, { useState } from 'react';
import AdminService from '../../services/API/AdminService';
function AccountStatusChecker() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleCheck = async (e) => {
e.preventDefault();
setError('');
setStatus(null);
setLoading(true);
// Validate before sending
const validation = AdminService.validateEmail(email);
if (!validation.valid) {
setError(validation.error);
setLoading(false);
return;
}
try {
const accountStatus = await AdminService.getAccountStatus(email);
setStatus(accountStatus);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="account-status-checker">
<h2>Check Account Lock Status</h2>
<form onSubmit={handleCheck}>
<div>
<label>Email Address:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
maxLength={255}
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Checking...' : 'Check Status'}
</button>
</form>
{status && (
<div className="status-result">
<h3>Account Status for {status.email}</h3>
{status.isLocked ? (
<div className="locked-status">
<p className="warning">🔒 Account is LOCKED</p>
<p>Failed Attempts: {status.failedAttempts}</p>
<p>Automatic Unlock In: {status.remainingTime}</p>
<p>({status.remainingSeconds} seconds remaining)</p>
</div>
) : (
<div className="unlocked-status">
<p className="success">✓ Account is NOT locked</p>
<p>Failed Attempts: {status.failedAttempts}</p>
</div>
)}
</div>
)}
</div>
);
}
export default AccountStatusChecker;
```
### Admin Panel - Unlock Account
```javascript
import React, { useState } from 'react';
import AdminService from '../../services/API/AdminService';
function AccountUnlocker() {
const [email, setEmail] = useState('');
const [result, setResult] = useState(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleUnlock = async (e) => {
e.preventDefault();
setError('');
setResult(null);
setLoading(true);
// Validate before sending
const validation = AdminService.validateEmail(email);
if (!validation.valid) {
setError(validation.error);
setLoading(false);
return;
}
// Confirm action
const confirmed = confirm(
`Are you sure you want to unlock the account for "${email}"?\n\n` +
`This will:\n` +
`- Clear all failed login attempts\n` +
`- Remove the account lock immediately\n` +
`- Log a security event with your admin ID\n\n` +
`Continue?`
);
if (!confirmed) {
setLoading(false);
return;
}
try {
const unlockResult = await AdminService.unlockAccount(email);
setResult(unlockResult);
// Clear form on success
if (unlockResult.success) {
setEmail('');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="account-unlocker">
<h2>Unlock User Account</h2>
<p className="warning">
⚠️ Admin action: This operation will be logged for security audit.
</p>
<form onSubmit={handleUnlock}>
<div>
<label>Email Address:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
maxLength={255}
/>
</div>
{error && <p className="error">{error}</p>}
{result && (
<div className={result.success ? "success" : "error"}>
<p>{result.message}</p>
<p>Email: {result.email}</p>
</div>
)}
<button type="submit" disabled={loading}>
{loading ? 'Unlocking...' : 'Unlock Account'}
</button>
</form>
</div>
);
}
export default AccountUnlocker;
```
### Combined Admin Panel - Status + Unlock
```javascript
import React, { useState, useEffect } from 'react';
import AdminService from '../../services/API/AdminService';
function AccountManager() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [unlocking, setUnlocking] = useState(false);
const checkStatus = async () => {
if (!email) return;
setError('');
setLoading(true);
try {
const accountStatus = await AdminService.getAccountStatus(email);
setStatus(accountStatus);
} catch (err) {
setError(err.message);
setStatus(null);
} finally {
setLoading(false);
}
};
const handleUnlock = async () => {
if (!confirm(`Unlock account for "${email}"?`)) {
return;
}
setError('');
setUnlocking(true);
try {
await AdminService.unlockAccount(email);
// Refresh status after unlock
await checkStatus();
alert(`Account unlocked successfully for ${email}`);
} catch (err) {
setError(err.message);
} finally {
setUnlocking(false);
}
};
return (
<div className="account-manager">
<h2>Account Management</h2>
<div className="search-form">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter email address"
maxLength={255}
/>
<button onClick={checkStatus} disabled={loading || !email}>
{loading ? 'Checking...' : 'Check Status'}
</button>
</div>
{error && <p className="error">{error}</p>}
{status && (
<div className="account-info">
<h3>{status.email}</h3>
<div className="status-details">
<p>
Status:{" "}
<strong className={status.isLocked ? "text-red" : "text-green"}>
{status.isLocked ? "🔒 LOCKED" : "✓ Not Locked"}
</strong>
</p>
<p>Failed Attempts: {status.failedAttempts}</p>
{status.isLocked && (
<>
<p>Automatic Unlock In: {status.remainingTime}</p>
<p className="text-muted">
({status.remainingSeconds} seconds)
</p>
<button
onClick={handleUnlock}
disabled={unlocking}
className="btn-danger"
>
{unlocking ? 'Unlocking...' : 'Unlock Account Now'}
</button>
</>
)}
</div>
</div>
)}
</div>
);
}
export default AccountManager;
```
### Locked Users Dashboard
```javascript
import React, { useState, useEffect } from 'react';
import AdminService from '../../services/API/AdminService';
function LockedUsersDashboard({ suspectedEmails }) {
const [lockedUsers, setLockedUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkAllUsers = async () => {
setLoading(true);
const locked = [];
for (const email of suspectedEmails) {
try {
const status = await AdminService.getAccountStatus(email);
if (status.isLocked) {
locked.push(status);
}
} catch (err) {
console.error(`Failed to check ${email}:`, err);
}
}
setLockedUsers(locked);
setLoading(false);
};
checkAllUsers();
}, [suspectedEmails]);
const unlockUser = async (email) => {
try {
await AdminService.unlockAccount(email);
// Remove from locked users list
setLockedUsers(prev => prev.filter(user => user.email !== email));
} catch (err) {
alert(`Failed to unlock ${email}: ${err.message}`);
}
};
if (loading) return <div>Checking locked accounts...</div>;
if (lockedUsers.length === 0) {
return <div>No locked accounts found.</div>;
}
return (
<div className="locked-users-dashboard">
<h2>Locked User Accounts ({lockedUsers.length})</h2>
<table>
<thead>
<tr>
<th>Email</th>
<th>Failed Attempts</th>
<th>Unlocks In</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{lockedUsers.map(user => (
<tr key={user.email}>
<td>{user.email}</td>
<td>{user.failedAttempts}</td>
<td>{user.remainingTime}</td>
<td>
<button onClick={() => unlockUser(user.email)}>
Unlock Now
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default LockedUsersDashboard;
```
## Testing
### Test Check Account Status
```javascript
// In browser console after admin login
import AdminService from './services/API/AdminService';
// Validate email
const validation = AdminService.validateEmail("user@example.com");
console.log("Email valid:", validation.valid);
// Check account status
const status = await AdminService.getAccountStatus("user@example.com");
console.log("Account status:", status);
console.log("Is locked:", status.isLocked);
console.log("Failed attempts:", status.failedAttempts);
if (status.isLocked) {
console.log("Remaining time:", status.remainingTime);
console.log("Remaining seconds:", status.remainingSeconds);
}
```
### Test Unlock Account
```javascript
// Check if needs unlock
const needs = await AdminService.needsUnlock("user@example.com");
console.log("Needs unlock:", needs);
// Unlock account
if (needs) {
const result = await AdminService.unlockAccount("user@example.com");
console.log("Unlock result:", result);
console.log("Success:", result.success);
console.log("Message:", result.message);
}
```
### Test with curl
```bash
# 1. Login as admin and get access token
ACCESS_TOKEN=$(curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@example.com",
"password": "AdminPass123!"
}' | jq -r '.access_token')
# 2. Check account status
curl -X GET "http://localhost:8000/api/v1/admin/account-status?email=user@example.com" \
-H "Authorization: JWT $ACCESS_TOKEN" | jq
# Example response:
# {
# "email": "user@example.com",
# "is_locked": true,
# "failed_attempts": 5,
# "remaining_time": "5 minutes 30 seconds",
# "remaining_seconds": 330
# }
# 3. Unlock account
curl -X POST http://localhost:8000/api/v1/admin/unlock-account \
-H "Content-Type: application/json" \
-H "Authorization: JWT $ACCESS_TOKEN" \
-d '{
"email": "user@example.com"
}' | jq
# Example response:
# {
# "success": true,
# "message": "Account unlocked successfully",
# "email": "user@example.com"
# }
# 4. Verify account is unlocked
curl -X GET "http://localhost:8000/api/v1/admin/account-status?email=user@example.com" \
-H "Authorization: JWT $ACCESS_TOKEN" | jq
# Should show:
# {
# "email": "user@example.com",
# "is_locked": false,
# "failed_attempts": 0
# }
```
## Important Notes
### Security and Authorization
1. **Admin Role Required**: Both endpoints require admin authentication
2. **Security Event Logging**: Unlock operations log security events for audit trail
3. **Admin User ID**: The admin who performs unlock is logged (from JWT)
4. **Rate Limiting**: Generic rate limiting applied to prevent abuse
### Account Lockout Mechanism
- Accounts are locked after **excessive failed login attempts** (configurable)
- Lock duration is typically **15-30 minutes** (configurable)
- Failed attempts counter resets after successful login
- Automatic unlock occurs when lock time expires
- Admin can unlock immediately without waiting
### Integration with CWE-307 Protection
These endpoints are part of the security system that protects against:
- **CWE-307**: Improper Restriction of Excessive Authentication Attempts
- **CWE-770**: Allocation of Resources Without Limits or Throttling
The login rate limiter tracks:
- Failed login attempts per email
- Account lock status and expiry
- Grace period for automatic unlock
### Best Practices
1. **Verify Admin Role**: Always check user has admin role before showing UI
2. **Confirm Before Unlock**: Always require confirmation before unlocking
3. **Audit Trail**: Log all admin actions for security compliance
4. **User Notification**: Consider notifying users when their account is unlocked by admin
5. **Regular Review**: Periodically review locked accounts dashboard
## Related Files
### Created Files
```
src/services/API/AdminService.js
docs/ADMIN_API.md
```
### Backend Reference Files
```
cloud/maplepress-backend/internal/interface/http/handler/admin/account_status_handler.go
cloud/maplepress-backend/internal/interface/http/handler/admin/unlock_account_handler.go
cloud/maplepress-backend/internal/interface/http/server.go (routes)
```
## Related Documentation
- [LOGIN_API.md](./LOGIN_API.md) - User login with rate limiting
- [ME_API.md](./ME_API.md) - Current user profile includes role checking
- [USER_API.md](./USER_API.md) - User management operations
- [FRONTEND_ARCHITECTURE.md](./FRONTEND_ARCHITECTURE.md) - Architecture overview
- [README.md](./README.md) - Documentation index
## Summary
The Admin API implementation provides:
1. **Account Status Checking**: View lock status, failed attempts, and remaining time
2. **Account Unlocking**: Manually unlock accounts with security event logging
3. **Helper Functions**: Email validation and time formatting
4. **Security Compliance**: Admin role enforcement and audit trail
5. **Error Handling**: Clear error messages and graceful failures
Essential for managing user account security and providing admin support for locked-out users while maintaining security compliance (CWE-307 protection).
---
**Last Updated**: October 30, 2024
**Frontend Version**: 0.0.0
**Documentation Version**: 1.0.0

View file

@ -0,0 +1,719 @@
# Health Check API Integration
This document describes the integration between the MaplePress frontend and the Health Check API endpoint.
## Table of Contents
- [Overview](#overview)
- [Backend API Specification](#backend-api-specification)
- [Frontend Implementation](#frontend-implementation)
- [Service Methods](#service-methods)
- [Usage Examples](#usage-examples)
- [Use Cases](#use-cases)
- [Error Handling](#error-handling)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
---
## Overview
The Health Check API is a simple, unauthenticated endpoint that verifies the MaplePress backend service is running and operational. It's commonly used for:
- **Monitoring**: Service availability checks
- **Load Balancers**: Health probe endpoints
- **Startup Verification**: Ensuring backend is ready before initializing frontend
- **API Connectivity**: Testing network connectivity to backend
### Key Features
- ✅ **No Authentication Required**: Public endpoint
- ✅ **Simple Response**: Returns `{ "status": "healthy" }`
- ✅ **Fast Response**: Lightweight check with minimal processing
- ✅ **Always Available**: Does not depend on database or external services
---
## Backend API Specification
### Endpoint
```
GET /health
```
### Authentication
**None required** - This is a public endpoint.
### Request Headers
No headers required.
### Request Parameters
None.
### Response (200 OK)
```json
{
"status": "healthy"
}
```
### Response Fields
| Field | Type | Description |
|-------|------|-------------|
| status | string | Health status - always "healthy" when service is running |
### Error Responses
- `503 Service Unavailable`: Backend service is down or unreachable
- Network errors: Connection refused, timeout, etc.
### Backend Implementation
**Handler File**: `cloud/maplepress-backend/internal/interface/http/handler/healthcheck/healthcheck_handler.go`
```go
func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) {
response := map[string]string{
"status": "healthy",
}
httpresponse.OK(w, response)
}
```
---
## Frontend Implementation
### Service File
**Location**: `src/services/API/HealthService.js`
### Dependencies
```javascript
import ApiClient from "./ApiClient";
```
### Architecture
The HealthService follows the same three-layer architecture as other API services:
```
┌─────────────────────────────────────┐
│ React Components (UI) │
│ - Dashboard, Status Indicators │
└─────────────┬───────────────────────┘
┌─────────────────────────────────────┐
│ HealthService (API Layer) │
│ - checkHealth() │
│ - isHealthy() │
│ - waitUntilHealthy() │
│ - getDetailedStatus() │
└─────────────┬───────────────────────┘
┌─────────────────────────────────────┐
│ ApiClient (HTTP Layer) │
│ - GET /health │
└─────────────┬───────────────────────┘
┌─────────────────────────────────────┐
│ Backend API │
│ GET /health → { status: "healthy" }│
└─────────────────────────────────────┘
```
---
## Service Methods
### 1. `checkHealth()`
Check if the backend service is healthy.
**Signature**:
```javascript
async function checkHealth(): Promise<Object>
```
**Returns**:
```javascript
{
status: "healthy"
}
```
**Throws**: `Error` if service is unreachable or unhealthy
**Example**:
```javascript
import HealthService from './services/API/HealthService';
try {
const health = await HealthService.checkHealth();
if (health.status === 'healthy') {
console.log('✅ Backend is healthy');
}
} catch (error) {
console.error('❌ Backend is down:', error.message);
}
```
---
### 2. `isHealthy()`
Simple boolean check for backend health.
**Signature**:
```javascript
async function isHealthy(): Promise<boolean>
```
**Returns**: `true` if backend is healthy, `false` otherwise
**Example**:
```javascript
const healthy = await HealthService.isHealthy();
if (healthy) {
console.log('✅ Backend is ready');
} else {
console.error('❌ Backend is not available');
}
```
**Use Case**: Quick status checks, conditional rendering
---
### 3. `waitUntilHealthy()`
Wait for the backend to become healthy (with retries).
**Signature**:
```javascript
async function waitUntilHealthy(options?: Object): Promise<boolean>
```
**Parameters**:
```javascript
{
maxAttempts: number, // Maximum retry attempts (default: 30)
retryDelayMs: number // Delay between attempts in ms (default: 1000)
}
```
**Returns**: `true` if backend became healthy, `false` if timeout
**Example**:
```javascript
// Wait up to 10 seconds (10 attempts x 1 second)
const ready = await HealthService.waitUntilHealthy({
maxAttempts: 10,
retryDelayMs: 1000
});
if (ready) {
console.log('✅ Backend is ready!');
// Proceed with app initialization
} else {
console.error('❌ Backend did not become ready in time');
// Show error message to user
}
```
**Use Case**: Application startup, waiting for backend deployment
---
### 4. `getDetailedStatus()`
Get detailed health status with timing information.
**Signature**:
```javascript
async function getDetailedStatus(): Promise<Object>
```
**Returns**:
```javascript
{
healthy: boolean, // Whether backend is healthy
status: string, // "healthy" or "unhealthy"
responseTimeMs: number, // Response time in milliseconds
timestamp: Date, // When check was performed
error?: string // Error message if unhealthy
}
```
**Example**:
```javascript
const status = await HealthService.getDetailedStatus();
console.log(`Backend Status: ${status.status}`);
console.log(`Response Time: ${status.responseTimeMs}ms`);
console.log(`Checked At: ${status.timestamp.toISOString()}`);
if (!status.healthy) {
console.error(`Error: ${status.error}`);
}
```
**Use Case**: Monitoring dashboards, performance tracking, diagnostics
---
## Usage Examples
### Example 1: Application Startup Check
Check backend health before initializing the app:
```javascript
// In App.jsx or main.jsx
import { useEffect, useState } from 'react';
import HealthService from './services/API/HealthService';
function App() {
const [backendReady, setBackendReady] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
async function checkBackend() {
try {
const healthy = await HealthService.isHealthy();
setBackendReady(healthy);
if (!healthy) {
setError('Backend service is not available');
}
} catch (err) {
setError(err.message);
}
}
checkBackend();
}, []);
if (error) {
return <div className="error">Backend Error: {error}</div>;
}
if (!backendReady) {
return <div className="loading">Connecting to backend...</div>;
}
return <div>App content...</div>;
}
```
---
### Example 2: Status Indicator Component
Show real-time backend status:
```javascript
import { useEffect, useState } from 'react';
import HealthService from '../services/API/HealthService';
function BackendStatusIndicator() {
const [status, setStatus] = useState(null);
useEffect(() => {
// Check status every 30 seconds
const checkStatus = async () => {
const detail = await HealthService.getDetailedStatus();
setStatus(detail);
};
checkStatus();
const interval = setInterval(checkStatus, 30000);
return () => clearInterval(interval);
}, []);
if (!status) {
return <span>Checking...</span>;
}
return (
<div className={`status-indicator ${status.healthy ? 'healthy' : 'unhealthy'}`}>
<span className="status-dot"></span>
<span className="status-text">
Backend: {status.status} ({status.responseTimeMs}ms)
</span>
</div>
);
}
```
---
### Example 3: Deployment Health Check
Wait for backend after deployment:
```javascript
import HealthService from './services/API/HealthService';
async function waitForBackend() {
console.log('Waiting for backend to become ready...');
const ready = await HealthService.waitUntilHealthy({
maxAttempts: 60, // Wait up to 1 minute
retryDelayMs: 1000 // Check every second
});
if (ready) {
console.log('✅ Backend is ready!');
return true;
} else {
console.error('❌ Backend deployment timeout');
return false;
}
}
// Usage in deployment script
if (await waitForBackend()) {
// Proceed with app initialization
} else {
// Show deployment error message
}
```
---
### Example 4: Error Recovery
Retry failed API calls after backend recovers:
```javascript
import HealthService from './services/API/HealthService';
import SiteService from './services/API/SiteService';
async function fetchSitesWithRetry() {
try {
// Try to fetch sites
const sites = await SiteService.listSites();
return sites;
} catch (error) {
console.error('Failed to fetch sites:', error);
// Check if backend is healthy
const healthy = await HealthService.isHealthy();
if (!healthy) {
console.log('Backend is down, waiting for recovery...');
// Wait for backend to recover
const recovered = await HealthService.waitUntilHealthy({
maxAttempts: 10,
retryDelayMs: 2000
});
if (recovered) {
console.log('Backend recovered, retrying...');
return await SiteService.listSites();
}
}
throw error;
}
}
```
---
## Use Cases
### 1. **Monitoring & Observability**
- Service uptime monitoring
- Response time tracking
- Health dashboard indicators
- Alerting on service degradation
### 2. **Load Balancer Integration**
- Health probe endpoint for AWS/Azure/GCP load balancers
- Kubernetes liveness/readiness probes
- Docker healthcheck configuration
### 3. **Application Lifecycle**
- Startup health verification
- Graceful degradation on backend issues
- Post-deployment verification
- Environment validation (dev/staging/prod)
### 4. **User Experience**
- Show connection status to users
- Prevent API calls when backend is down
- Display maintenance mode messages
- Automatic retry on recovery
### 5. **Development & Testing**
- Verify backend is running before tests
- E2E test prerequisites
- Local development environment checks
- CI/CD pipeline health gates
---
## Error Handling
### Network Errors
```javascript
try {
await HealthService.checkHealth();
} catch (error) {
if (error.message.includes('network') || error.message.includes('fetch')) {
// Network connectivity issue
console.error('Cannot reach backend - check network');
}
}
```
### Service Unavailable
```javascript
try {
await HealthService.checkHealth();
} catch (error) {
if (error.message.includes('503') || error.message.includes('unavailable')) {
// Backend is down or restarting
console.error('Backend service is unavailable');
}
}
```
### Timeout Handling
```javascript
// With timeout wrapper
async function checkHealthWithTimeout(timeoutMs = 5000) {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Health check timeout')), timeoutMs)
);
try {
return await Promise.race([
HealthService.checkHealth(),
timeoutPromise
]);
} catch (error) {
console.error('Health check failed or timed out:', error.message);
return { status: 'unhealthy' };
}
}
```
---
## Testing
### Manual Testing
1. **Start the backend**:
```bash
cd cloud/maplepress-backend
task dev
```
2. **Test with curl**:
```bash
curl -X GET http://localhost:8000/health
```
Expected response:
```json
{ "status": "healthy" }
```
3. **Test from frontend**:
```javascript
// In browser console
import HealthService from './services/API/HealthService.js';
const health = await HealthService.checkHealth();
console.log(health); // { status: "healthy" }
```
### Automated Testing
```javascript
// Example test (using Jest or Vitest)
import HealthService from '../services/API/HealthService';
describe('HealthService', () => {
it('should return healthy status when backend is up', async () => {
const health = await HealthService.checkHealth();
expect(health.status).toBe('healthy');
});
it('should return true for isHealthy()', async () => {
const healthy = await HealthService.isHealthy();
expect(healthy).toBe(true);
});
it('should include response time in detailed status', async () => {
const status = await HealthService.getDetailedStatus();
expect(status).toHaveProperty('responseTimeMs');
expect(status.responseTimeMs).toBeGreaterThan(0);
});
});
```
---
## Troubleshooting
### Issue: "Unable to connect to backend service"
**Symptoms**: Network or fetch errors
**Solutions**:
1. Verify backend is running: `curl http://localhost:8000/health`
2. Check `VITE_API_BASE_URL` in `.env` file
3. Verify no CORS issues in browser console
4. Check firewall/network settings
### Issue: "Backend service is temporarily unavailable"
**Symptoms**: 503 status code
**Solutions**:
1. Backend may be starting up - wait a few seconds
2. Check backend logs: `docker logs mapleopentech_backend`
3. Verify backend services (Cassandra, Redis) are running
4. Restart backend: `task end && task dev`
### Issue: Health check timeout
**Symptoms**: Slow or no response
**Solutions**:
1. Check backend server load
2. Verify network latency
3. Check if backend is overloaded
4. Consider increasing timeout in `waitUntilHealthy()`
### Issue: Always returns unhealthy
**Symptoms**: `isHealthy()` always returns false
**Solutions**:
1. Check browser console for errors
2. Verify API base URL is correct
3. Check CORS configuration
4. Test endpoint directly with curl
---
## Best Practices
### 1. **Use During Initialization**
Always check backend health during app startup:
```javascript
useEffect(() => {
HealthService.isHealthy().then(healthy => {
if (!healthy) {
showBackendError();
}
});
}, []);
```
### 2. **Periodic Health Checks**
For long-running apps, check periodically:
```javascript
// Every 5 minutes
setInterval(async () => {
const healthy = await HealthService.isHealthy();
updateStatusIndicator(healthy);
}, 300000);
```
### 3. **Handle Failures Gracefully**
Don't throw errors to users - handle them gracefully:
```javascript
const healthy = await HealthService.isHealthy().catch(() => false);
if (!healthy) {
showOfflineMode();
}
```
### 4. **Log Response Times**
Monitor performance over time:
```javascript
const status = await HealthService.getDetailedStatus();
analytics.track('backend_health', {
responseTime: status.responseTimeMs,
healthy: status.healthy
});
```
---
## Integration with Other Services
The HealthService can be combined with other services for robust error handling:
```javascript
import HealthService from './services/API/HealthService';
import AuthManager from './services/Manager/AuthManager';
async function safeLogin(email, password) {
// Check backend health first
const healthy = await HealthService.isHealthy();
if (!healthy) {
throw new Error('Backend is currently unavailable. Please try again later.');
}
// Proceed with login
return await AuthManager.login({ email, password });
}
```
---
## Summary
The Health Check API provides a simple, reliable way to verify backend availability:
- ✅ **Simple Integration**: One GET request, no auth required
- ✅ **Multiple Helper Methods**: `checkHealth()`, `isHealthy()`, `waitUntilHealthy()`, `getDetailedStatus()`
- ✅ **Error Handling**: Comprehensive error detection and user-friendly messages
- ✅ **Flexible Usage**: Startup checks, monitoring, status indicators, deployment verification
- ✅ **Production Ready**: Tested against backend implementation
For questions or issues, refer to the [Troubleshooting](#troubleshooting) section or check the main [README](../../README.md).
---
**Last Updated**: October 30, 2024
**Frontend Version**: 0.0.0
**Backend API Version**: 1.0.0

View file

@ -0,0 +1,580 @@
# Hello API Implementation
This document describes the implementation of the Hello API endpoint for the MaplePress frontend, integrated with the MaplePress backend API.
## Overview
The Hello API is a simple authenticated endpoint that returns a personalized greeting message. It demonstrates JWT authentication and can be used to verify that access tokens are working correctly. This is useful for testing authentication flows and ensuring the token refresh system is functioning properly.
## Backend API Endpoint
**Endpoint**: `POST /api/v1/hello`
**Authentication**: Required (JWT token)
**Documentation**: `/cloud/maplepress-backend/docs/API.md` (lines 326-372)
### Request Structure
```json
{
"name": "Alice"
}
```
**Headers Required:**
- `Content-Type: application/json`
- `Authorization: JWT {access_token}`
### Response Structure
```json
{
"message": "Hello, Alice! Welcome to MaplePress Backend."
}
```
### Validation Rules
**Backend Validation:**
- Name is required (cannot be empty)
- Name length: 1-100 characters
- Must contain only printable characters
- No HTML tags allowed (XSS prevention)
- Input is sanitized and HTML-escaped
**Security Features:**
- CWE-20: Comprehensive input validation
- CWE-79: XSS prevention (HTML escaping)
- CWE-117: Log injection prevention (name is hashed in logs)
- CWE-436: Strict Content-Type validation
## Frontend Implementation
### HelloService (`src/services/API/HelloService.js`)
Handles direct communication with the backend Hello API.
**Key Features:**
- Client-side validation before API call
- Request body formatting
- Authenticated requests (uses JWT token)
- User-friendly error message mapping
- XSS prevention (HTML tag validation)
**Methods:**
#### `hello(name)`
Main method to send a hello request.
```javascript
import HelloService from './services/API/HelloService';
const response = await HelloService.hello("Alice");
console.log(response.message);
// Output: "Hello, Alice! Welcome to MaplePress Backend."
```
**Parameters:**
- `name` (string, required): Name to include in greeting (1-100 characters)
**Returns:**
```javascript
{
message: string // Personalized greeting message
}
```
**Throws:**
- "Name is required" - If name is missing or not a string
- "Name cannot be empty" - If name is empty after trimming
- "Name must be 100 characters or less" - If name exceeds limit
- "Name cannot contain HTML tags" - If name contains `<>` tags
- "Authentication required. Please log in to continue." - If JWT token is invalid/expired
- "Invalid name provided. Please check your input." - If backend validation fails
- Generic error message for other failures
#### `validateName(name)`
Client-side validation helper to check name before sending.
```javascript
const validation = HelloService.validateName("Alice");
if (!validation.valid) {
console.error(validation.error);
} else {
// Proceed with API call
}
```
**Returns:**
```javascript
{
valid: boolean, // true if name is valid
error: string|null // Error message if invalid, null if valid
}
```
## Data Flow
```
User provides name
HelloService.validateName() (optional client-side check)
HelloService.hello(name)
ApiClient.post() with JWT token
Token automatically refreshed if needed (ApiClient feature)
POST /api/v1/hello with Authorization header
Backend validates JWT token
Backend validates name (length, characters, no HTML)
Backend sanitizes and HTML-escapes name
Backend returns personalized greeting
Frontend receives response
```
## Validation Rules
### Client-Side Validation
1. **Required**: Name cannot be null, undefined, or empty string
2. **Type**: Must be a string
3. **Length**: 1-100 characters (after trimming)
4. **HTML Tags**: Must not contain `<` or `>` characters
5. **Printable**: No control characters (0x00-0x1F, 0x7F-0x9F)
### Backend Validation
1. **Required**: Name field must be present and non-empty
2. **Length**: 1-100 characters
3. **Printable**: Only printable characters allowed
4. **No HTML**: Validated for HTML tags (XSS prevention)
5. **Sanitization**: Input is sanitized and HTML-escaped
6. **Logging**: Name is hashed in logs (PII protection)
## Error Handling
### Error Types
| Error Condition | Response | Frontend Behavior |
|----------------|----------|-------------------|
| Name missing | 400 Bad Request | "Name is required" |
| Name empty | 400 Bad Request | "Name cannot be empty" |
| Name too long | 400 Bad Request | "Name must be 100 characters or less" |
| HTML tags in name | 400 Bad Request | "Name cannot contain HTML tags" |
| Invalid JWT token | 401 Unauthorized | "Authentication required. Please log in to continue." |
| JWT token expired | 401 Unauthorized | Token auto-refresh triggered, then retry |
### Error Message Mapping
```javascript
// Backend error → Frontend error
"unauthorized" → "Authentication required. Please log in to continue."
"name" → "Invalid name provided. Please check your input."
Other errors → Original error message or generic fallback
```
## Security Features
1. **Authentication Required**: Endpoint requires valid JWT token
2. **XSS Prevention**: HTML tags are rejected both client and server-side
3. **Input Sanitization**: Backend sanitizes and HTML-escapes all input
4. **Length Limits**: Prevents buffer overflow attacks
5. **Printable Characters Only**: Prevents control character injection
6. **PII Protection**: Name is hashed in backend logs
7. **Automatic Token Refresh**: ApiClient ensures token is valid before request
## Usage Examples
### Basic Usage in React Component
```javascript
import React, { useState } from 'react';
import { useAuth } from '../services/Services';
import HelloService from '../services/API/HelloService';
function HelloExample() {
const { authManager } = useAuth();
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setMessage('');
setLoading(true);
// Check authentication
if (!authManager.isAuthenticated()) {
setError('Please log in first');
setLoading(false);
return;
}
// Optional: Validate before sending
const validation = HelloService.validateName(name);
if (!validation.valid) {
setError(validation.error);
setLoading(false);
return;
}
try {
const response = await HelloService.hello(name);
setMessage(response.message);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
maxLength={100}
/>
<button type="submit" disabled={loading}>
{loading ? 'Sending...' : 'Say Hello'}
</button>
</form>
{message && <p className="success">{message}</p>}
{error && <p className="error">{error}</p>}
</div>
);
}
export default HelloExample;
```
### Testing Authentication
```javascript
// Test if authentication is working
import HelloService from './services/API/HelloService';
async function testAuthentication() {
try {
const response = await HelloService.hello("Test User");
console.log("✅ Authentication working:", response.message);
return true;
} catch (error) {
console.error("❌ Authentication failed:", error.message);
return false;
}
}
```
### Validation Testing
```javascript
// Test validation rules
const testCases = [
{ name: "", expected: "Name cannot be empty" },
{ name: "Alice", expected: null },
{ name: "<script>alert('xss')</script>", expected: "Name cannot contain HTML tags" },
{ name: "A".repeat(101), expected: "Name must be 100 characters or less" },
];
testCases.forEach(({ name, expected }) => {
const result = HelloService.validateName(name);
console.log(`Input: "${name}"`);
console.log(`Expected: ${expected}`);
console.log(`Got: ${result.error}`);
console.log(`✓ Pass: ${result.error === expected}\n`);
});
```
## Testing the Hello API
### 1. Prerequisites
- Backend running at `http://localhost:8000`
- Frontend running at `http://localhost:5173`
- User logged in (valid JWT token)
### 2. Testing via Dashboard
You can add a simple test component to the dashboard:
```javascript
// Add to Dashboard.jsx
import HelloService from '../../services/API/HelloService';
// Inside Dashboard component
const [helloMessage, setHelloMessage] = useState('');
const testHello = async () => {
try {
const response = await HelloService.hello(user.name);
setHelloMessage(response.message);
} catch (error) {
console.error('Hello test failed:', error);
}
};
// In JSX
<button onClick={testHello}>Test Hello API</button>
{helloMessage && <p>{helloMessage}</p>}
```
### 3. Testing via Browser Console
```javascript
// After logging in, open browser console
// Test basic hello
const response = await window.HelloService.hello("Alice");
console.log(response.message);
// Expected: "Hello, Alice! Welcome to MaplePress Backend."
// Test validation
const validation = window.HelloService.validateName("<script>test</script>");
console.log(validation);
// Expected: { valid: false, error: "Name cannot contain HTML tags" }
// Test with empty name
try {
await window.HelloService.hello("");
} catch (error) {
console.log(error.message);
// Expected: "Name cannot be empty"
}
```
### 4. Testing with curl
```bash
# 1. Login and get access token
ACCESS_TOKEN=$(curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "SecurePass123!"
}' | jq -r '.access_token')
# 2. Test hello endpoint
curl -X POST http://localhost:8000/api/v1/hello \
-H "Content-Type: application/json" \
-H "Authorization: JWT $ACCESS_TOKEN" \
-d '{"name": "Alice"}' | jq
# Expected output:
# {
# "message": "Hello, Alice! Welcome to MaplePress Backend."
# }
# 3. Test with empty name (should fail)
curl -X POST http://localhost:8000/api/v1/hello \
-H "Content-Type: application/json" \
-H "Authorization: JWT $ACCESS_TOKEN" \
-d '{"name": ""}' | jq
# Expected: 400 Bad Request
# 4. Test without authentication (should fail)
curl -X POST http://localhost:8000/api/v1/hello \
-H "Content-Type: application/json" \
-d '{"name": "Alice"}' | jq
# Expected: 401 Unauthorized
```
### 5. Testing Token Refresh Integration
```javascript
// Test that token refresh works automatically
// 1. Login
// 2. Manually set token expiry to 30 seconds from now
const newExpiry = new Date(Date.now() + 30000).toISOString();
localStorage.setItem('maplepress_access_expiry', newExpiry);
// 3. Wait 31 seconds, then call hello
await new Promise(resolve => setTimeout(resolve, 31000));
const response = await HelloService.hello("Test");
// 4. Check console logs - should see automatic token refresh
// 5. Response should be successful despite expired token
console.log(response.message);
```
## Integration with Existing Services
The HelloService automatically integrates with existing infrastructure:
### Authentication (AuthManager)
```javascript
// HelloService uses ApiClient, which automatically:
// - Adds JWT token to Authorization header
// - Refreshes token if expired
// - Handles 401 errors
```
### Token Refresh (RefreshTokenService)
```javascript
// Automatic token refresh before hello request
// If token expires within 1 minute, it's refreshed proactively
// If 401 received, token is refreshed and request is retried
```
### Error Handling
```javascript
// All errors are caught and mapped to user-friendly messages
// Authentication errors trigger automatic login redirect (in components)
```
## Use Cases
### 1. Authentication Testing
Use the Hello endpoint to verify that JWT authentication is working correctly after login or token refresh.
```javascript
// After login
const testAuth = async () => {
try {
await HelloService.hello("Test");
console.log("✅ Authentication successful");
} catch (error) {
console.error("❌ Authentication failed");
// Redirect to login
}
};
```
### 2. Token Validity Check
Check if the current access token is valid without making a critical API call.
```javascript
// Before important operation
const isTokenValid = async () => {
try {
await HelloService.hello("TokenCheck");
return true;
} catch (error) {
return false;
}
};
```
### 3. User Greeting
Display a personalized welcome message on the dashboard.
```javascript
// On dashboard load
useEffect(() => {
const greetUser = async () => {
try {
const response = await HelloService.hello(user.name);
setGreeting(response.message);
} catch (error) {
console.error("Failed to load greeting:", error);
}
};
greetUser();
}, [user.name]);
```
## Troubleshooting
### "Authentication required" error
**Possible causes:**
1. User not logged in
2. Access token expired
3. Refresh token expired
4. Session invalidated
**Solution:**
- Check `authManager.isAuthenticated()` before calling
- Login again if session expired
- Check browser console for token refresh logs
### "Name cannot contain HTML tags" error
**Possible causes:**
1. User input contains `<` or `>` characters
2. Attempt to inject HTML/JavaScript
**Solution:**
- Sanitize user input before calling HelloService
- Use `validateName()` to check input first
- Inform user about character restrictions
### Request succeeds but token expired shortly after
**Possible causes:**
1. Token was refreshed but expiry is still 15 minutes
2. High request frequency without refresh
**Solution:**
- Token automatically refreshes 1 minute before expiry
- This is expected behavior (15-minute access token lifetime)
- Refresh token handles session extension
### 401 error despite valid token
**Possible causes:**
1. Clock skew between client and server
2. Token format incorrect
3. Backend session invalidated
**Solution:**
- Check system clock synchronization
- Verify token in localStorage is properly formatted
- Clear session and login again
## Related Files
### Created Files
```
src/services/API/HelloService.js
docs/HELLO_API.md
```
### Backend Reference Files
```
cloud/maplepress-backend/docs/API.md (lines 326-372)
cloud/maplepress-backend/internal/interface/http/handler/gateway/hello_handler.go
```
## Related Documentation
- [LOGIN_API.md](./LOGIN_API.md) - Login and JWT token acquisition
- [REFRESH_TOKEN_API.md](./REFRESH_TOKEN_API.md) - Automatic token refresh
- [FRONTEND_ARCHITECTURE.md](./FRONTEND_ARCHITECTURE.md) - Architecture overview
- [README.md](./README.md) - Documentation index
- [Backend API Documentation](../../../cloud/maplepress-backend/docs/API.md) - Complete API reference
## Summary
The Hello API implementation provides:
1. **Simple Authentication Test**: Verify JWT tokens are working
2. **Security Features**: XSS prevention, input validation, sanitization
3. **User-Friendly**: Personalized greeting messages
4. **Automatic Integration**: Works seamlessly with token refresh
5. **Error Handling**: Clear error messages and graceful failures
6. **Validation Helpers**: Client-side validation before API calls
This endpoint is perfect for testing authentication flows and demonstrating the three-layer service architecture in action.
---
**Last Updated**: October 30, 2024
**Frontend Version**: 0.0.0
**Documentation Version**: 1.0.0

View file

@ -0,0 +1,487 @@
# Login API Implementation
This document describes the complete implementation of the user login feature for the MaplePress frontend, integrated with the MaplePress backend API.
## Overview
The login feature allows existing users to authenticate with their email and password credentials. Upon successful login, users receive authentication tokens and are automatically logged in to their dashboard.
## Backend API Endpoint
**Endpoint**: `POST /api/v1/login`
**Authentication**: None required (public endpoint)
**Documentation**: `/cloud/maplepress-backend/docs/API.md` (lines 168-228)
### Request Structure
```json
{
"email": "user@example.com",
"password": "SecurePassword123!"
}
```
### Response Structure
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"user_email": "user@example.com",
"user_name": "John Doe",
"user_role": "user",
"tenant_id": "650e8400-e29b-41d4-a716-446655440000",
"session_id": "750e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"access_expiry": "2024-10-24T12:15:00Z",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_expiry": "2024-10-31T00:00:00Z",
"login_at": "2024-10-24T00:00:00Z"
}
```
### Key Differences from Registration
The login endpoint differs from registration in several ways:
- **Simpler Request**: Only email and password required
- **No Tenant Details in Response**: Tenant name/slug not included
- **Login Timestamp**: Includes `login_at` instead of `created_at`
- **Existing Session**: Authenticates existing user, doesn't create new account
## Frontend Implementation
### 1. LoginService (`src/services/API/LoginService.js`)
Handles direct communication with the backend login API.
**Key Features:**
- Request validation (required fields)
- Request body formatting (snake_case for backend)
- Response transformation (camelCase for frontend)
- User-friendly error message mapping
- Rate limit error handling
- Account lockout detection
**Methods:**
- `login(credentials)` - Main login method
**Usage:**
```javascript
import LoginService from './services/API/LoginService';
const response = await LoginService.login({
email: "user@example.com",
password: "SecurePassword123!",
});
```
### 2. AuthManager Enhancement (`src/services/Manager/AuthManager.js`)
Updated to support login functionality while maintaining registration support.
**New/Updated Methods:**
- `login(email, password)` - Login and store auth data
- `storeAuthData(authResponse)` - Updated to handle optional tenant fields
**Key Features:**
- Handles both registration and login responses
- Gracefully handles missing tenant name/slug from login
- Maintains same token storage mechanism
- Consistent session management
**Login Flow:**
```javascript
const authManager = useAuth().authManager;
// Login
await authManager.login("user@example.com", "password123");
// Check authentication
if (authManager.isAuthenticated()) {
const user = authManager.getUser();
const tenant = authManager.getTenant();
}
```
### 3. Login Page (`src/pages/Auth/Login.jsx`)
Simple and clean login form ready for production use.
**Form Fields:**
- Email Address (required)
- Password (required)
**Features:**
- Email validation (HTML5 + backend)
- Loading state during submission
- Error message display
- Navigation to registration
- Navigation back to home
## Data Flow
```
User enters credentials
Login.jsx validates data
AuthManager.login(email, password)
LoginService.login(credentials)
HTTP POST to /api/v1/login
Backend validates credentials
Backend returns tokens and user data
LoginService transforms response
AuthManager stores tokens in localStorage
User redirected to /dashboard
```
## Validation Rules
### Frontend Validation
1. **Email**: Required, valid email format (HTML5)
2. **Password**: Required (no client-side length check for login)
### Backend Validation
Backend performs:
- Email format validation
- Email normalization (lowercase, trim)
- Password verification against stored hash
- Rate limiting checks
- Account lockout checks
## Error Handling
### Error Messages
Errors are mapped to user-friendly messages:
| Backend Error | Frontend Message |
|--------------|------------------|
| "Invalid email or password" | "Invalid email or password. Please try again." |
| "X attempts remaining" | Original message with attempt counter |
| "locked" or "Too many" | "Account temporarily locked due to too many failed attempts. Please try again later." |
| "invalid email" | "Invalid email address." |
### Rate Limiting & Account Lockout
The backend implements sophisticated rate limiting:
**Per-IP Rate Limiting:**
- Limit: Multiple attempts per 15 minutes
- Response: `429 Too Many Requests`
- Header: `Retry-After: 900` (15 minutes)
**Per-Account Rate Limiting:**
- Limit: 5 failed attempts
- Lockout: 30 minutes after 5 failures
- Response: `429 Too Many Requests`
- Header: `Retry-After: 1800` (30 minutes)
- Warning: Shows remaining attempts when ≤ 3
**Behavior:**
1. First 2 failures: Generic error message
2. 3rd-5th failure: Shows remaining attempts
3. After 5th failure: Account locked for 30 minutes
4. Successful login: Resets all counters
### Security Event Logging
Backend logs security events for:
- Failed login attempts (with email hash)
- Successful logins (with email hash)
- IP rate limit exceeded
- Account lockouts
## Security Features
1. **Token Storage**: Tokens stored in localStorage
2. **Token Expiry**: Automatic expiry checking
3. **Rate Limiting**: Backend enforces rate limits
4. **Account Lockout**: Protects against brute force
5. **Email Normalization**: Prevents bypass via casing
6. **Secure Logging**: PII never logged, only hashes
7. **Password Hash Verification**: Uses bcrypt on backend
## Token Lifetime
- **Access Token**: 15 minutes
- **Refresh Token**: 7 days
- **Session**: 14 days (max inactivity)
## Testing the Login Flow
### 1. Prerequisites
Ensure you have a registered user. If not, register first:
```bash
# Navigate to registration
http://localhost:5173/register
# Or use curl to register via backend
curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "SecurePass123!",
"name": "Test User",
"tenant_name": "Test Corp",
"tenant_slug": "test-corp",
"agree_terms_of_service": true
}'
```
### 2. Start Services
```bash
# Backend
cd cloud/maplepress-backend
task dev
# Frontend
cd web/maplepress-frontend
npm run dev
```
### 3. Navigate to Login
Open browser to: `http://localhost:5173/login`
### 4. Fill Form
- **Email**: test@example.com
- **Password**: SecurePass123!
### 5. Submit
Click "Sign In" button
### 6. Expected Result
- Loading state appears ("Signing in...")
- Request sent to backend
- Tokens stored in localStorage
- User redirected to `/dashboard`
- Dashboard shows user information
### 7. Verify in Browser Console
```javascript
// Check stored tokens
localStorage.getItem('maplepress_access_token')
localStorage.getItem('maplepress_user')
localStorage.getItem('maplepress_tenant')
// Check service instance
window.maplePressServices.authManager.isAuthenticated() // true
window.maplePressServices.authManager.getUser()
// Returns: { id: "...", email: "test@example.com", name: "Test User", role: "..." }
window.maplePressServices.authManager.getTenant()
// Returns: { id: "...", name: null, slug: null }
// Note: name/slug are null because login endpoint doesn't provide them
```
## Error Testing
### Test Invalid Credentials
```javascript
// In login form, enter:
Email: test@example.com
Password: WrongPassword123
// Expected: "Invalid email or password. Please try again."
```
### Test Rate Limiting
```javascript
// Attempt login 3 times with wrong password
// Expected on 3rd attempt: "Invalid email or password. 2 attempts remaining before account lockout."
// Attempt 2 more times
// Expected on 5th attempt: "Account temporarily locked due to too many failed attempts. Please try again later."
```
### Test Missing Fields
```javascript
// Leave email blank
// Expected: Browser validation error (HTML5 required)
// Leave password blank
// Expected: Browser validation error (HTML5 required)
```
## Differences from Registration
| Feature | Registration | Login |
|---------|-------------|-------|
| Request Fields | 10+ fields | 2 fields only |
| Response Fields | Includes tenant name/slug | No tenant name/slug |
| Timestamp | `created_at` | `login_at` |
| Creates User | Yes | No |
| Creates Tenant | Yes | No |
| Terms Agreement | Required | Not needed |
| Organization Info | Required | Not needed |
## Token Storage Compatibility
Both registration and login use the same storage mechanism:
```javascript
// Storage Keys (7 total)
maplepress_access_token // JWT access token
maplepress_refresh_token // JWT refresh token
maplepress_access_expiry // ISO date string
maplepress_refresh_expiry // ISO date string
maplepress_user // JSON: {id, email, name, role}
maplepress_tenant // JSON: {id, name, slug}
maplepress_session_id // Session UUID
```
**Note**: After login, `tenant.name` and `tenant.slug` will be `null`. This is expected behavior. If needed, fetch tenant details separately using the `/api/v1/tenants/{id}` endpoint.
## Session Persistence
Authentication state persists across:
- Page refreshes
- Browser restarts (if localStorage not cleared)
- Tab changes
Session is cleared on:
- User logout
- Token expiry detection
- Manual localStorage clear
## Integration with Dashboard
The dashboard automatically checks authentication:
```javascript
// Dashboard.jsx
useEffect(() => {
if (!authManager.isAuthenticated()) {
navigate("/login");
return;
}
const userData = authManager.getUser();
setUser(userData);
}, [authManager, navigate]);
```
## API Client Configuration
The `ApiClient` automatically handles:
- JSON content-type headers
- Request/response transformation
- Error parsing
- Authentication headers (for protected endpoints)
**Note**: Login endpoint doesn't require authentication headers, but subsequent API calls will use the stored access token.
## Future Enhancements
### Planned Features
- [ ] "Remember Me" checkbox (longer session)
- [ ] "Forgot Password" link
- [ ] Social authentication (Google, GitHub)
- [ ] Two-factor authentication (2FA/TOTP)
- [ ] Session management (view active sessions)
- [ ] Device fingerprinting
- [ ] Suspicious login detection
### Security Improvements
- [ ] CSRF token implementation
- [ ] HTTP-only cookie option
- [ ] Session fingerprinting
- [ ] Geolocation tracking
- [ ] Email notification on new login
- [ ] Passwordless login option
## Troubleshooting
### "Invalid email or password" but credentials are correct
**Possible causes:**
1. Email case sensitivity - backend normalizes to lowercase
2. Extra whitespace in password field
3. User not yet registered
4. Account locked due to previous failed attempts
**Solution:**
- Wait 30 minutes if account is locked
- Try registering if user doesn't exist
- Check browser console for detailed errors
### Tokens not being stored
**Possible causes:**
1. localStorage disabled in browser
2. Private/Incognito mode restrictions
3. Browser extension blocking storage
**Solution:**
- Enable localStorage in browser settings
- Use regular browser window
- Disable blocking extensions temporarily
### Redirected back to login after successful login
**Possible causes:**
1. Token expiry detection triggered
2. Token format invalid
3. localStorage cleared between operations
**Solution:**
- Check browser console for errors
- Verify localStorage contains tokens
- Check token expiry dates
## Related Files
### Created Files
```
src/services/API/LoginService.js
```
### Modified Files
```
src/services/Manager/AuthManager.js
src/pages/Auth/Login.jsx
```
### Backend Reference Files
```
cloud/maplepress-backend/docs/API.md
cloud/maplepress-backend/internal/interface/http/dto/gateway/login_dto.go
cloud/maplepress-backend/internal/interface/http/handler/gateway/login_handler.go
```
## Related Documentation
- [REGISTRATION_API.md](./REGISTRATION_API.md) - Registration implementation
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Frontend architecture overview
- [README.md](./README.md) - Getting started guide
- [Backend API Documentation](../../cloud/maplepress-backend/docs/API.md) - Complete API reference
## Support
For issues:
1. Check backend logs: `docker logs mapleopentech_backend`
2. Check browser console for errors
3. Verify backend is running on port 8000
4. Test backend endpoint directly with curl
5. Check rate limiting status (wait 30 minutes if locked)

View file

@ -0,0 +1,676 @@
# Me API Implementation (User Profile)
This document describes the implementation of the Me API endpoint for the MaplePress frontend, integrated with the MaplePress backend API.
## Overview
The Me API endpoint returns the authenticated user's profile information extracted directly from the JWT token. This is a lightweight endpoint that requires no database queries, making it ideal for displaying user information in the dashboard and verifying the current user's identity.
## Backend API Endpoint
**Endpoint**: `GET /api/v1/me`
**Authentication**: Required (JWT token)
**Documentation**: `/cloud/maplepress-backend/docs/API.md` (lines 374-413)
### Request Structure
No request body required. Authentication is provided via the Authorization header:
**Headers Required:**
- `Authorization: JWT {access_token}`
### Response Structure
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "john@example.com",
"name": "John Doe",
"role": "owner",
"tenant_id": "650e8400-e29b-41d4-a716-446655440000"
}
```
### Key Features
- **No Database Query**: All data extracted from JWT token claims
- **Fast Response**: Minimal processing required
- **Current User Only**: Returns data for the authenticated user
- **Role Information**: Includes user's role for authorization checks
- **Tenant Context**: Includes tenant ID for multi-tenant operations
## Frontend Implementation
### MeService (`src/services/API/MeService.js`)
Handles direct communication with the backend Me API and provides utility methods for user profile operations.
**Key Features:**
- GET request with automatic JWT authentication
- Response transformation (snake_case to camelCase)
- User-friendly error message mapping
- Helper methods for common use cases
- Automatic token refresh integration
**Methods:**
#### `getMe()`
Main method to fetch the current user's profile.
```javascript
import MeService from './services/API/MeService';
const profile = await MeService.getMe();
console.log(profile);
// Output:
// {
// userId: "550e8400-e29b-41d4-a716-446655440000",
// email: "john@example.com",
// name: "John Doe",
// role: "owner",
// tenantId: "650e8400-e29b-41d4-a716-446655440000"
// }
```
**Returns:**
```javascript
{
userId: string, // User's unique identifier (UUID)
email: string, // User's email address
name: string, // User's full name
role: string, // User's role (e.g., "owner", "admin", "user")
tenantId: string // User's tenant/organization ID (UUID)
}
```
**Throws:**
- "Authentication required. Please log in to continue." - If JWT token is missing/invalid
- "Invalid or expired authentication token. Please log in again." - If token has expired
- Generic error message for other failures
#### `hasRole(requiredRole)`
Check if the current user has a specific role.
```javascript
const isOwner = await MeService.hasRole("owner");
if (isOwner) {
console.log("User is an owner");
}
const isAdmin = await MeService.hasRole("admin");
```
**Parameters:**
- `requiredRole` (string): Role to check for (e.g., "owner", "admin", "user")
**Returns:** `boolean` - True if user has the required role
#### `getTenantId()`
Get the current user's tenant ID.
```javascript
const tenantId = await MeService.getTenantId();
console.log("User's tenant:", tenantId);
```
**Returns:** `string|null` - Tenant ID or null if not available
#### `isCurrentUser(userId)`
Verify that the current user matches a specific user ID.
```javascript
const isSameUser = await MeService.isCurrentUser("550e8400-...");
if (isSameUser) {
console.log("This is the current user");
}
```
**Parameters:**
- `userId` (string): User ID to verify against
**Returns:** `boolean` - True if the current user matches the provided ID
## Data Flow
```
User is authenticated (has JWT token)
Component calls MeService.getMe()
ApiClient.get("/api/v1/me")
Token automatically refreshed if needed (ApiClient feature)
GET /api/v1/me with Authorization header
Backend JWT middleware extracts user from token
Backend returns user profile from JWT claims
MeService transforms response (snake_case → camelCase)
Component receives user profile
```
## Error Handling
### Error Types
| Error Condition | Response | Frontend Behavior |
|----------------|----------|-------------------|
| Missing JWT token | 401 Unauthorized | "Authentication required. Please log in to continue." |
| Invalid JWT token | 401 Unauthorized | "Invalid or expired authentication token." |
| Expired JWT token | 401 Unauthorized | Token auto-refresh triggered, then retry |
| Server error | 500 Internal Server Error | Generic error message |
### Error Message Mapping
```javascript
// Backend error → Frontend error
"unauthorized" → "Authentication required. Please log in to continue."
"token" → "Invalid or expired authentication token. Please log in again."
Other errors → Original error message or generic fallback
```
## Security Features
1. **JWT Authentication Required**: Endpoint requires valid JWT token
2. **Token Validation**: Backend validates token signature and expiration
3. **No PII in Logs**: Backend uses hashed/redacted email in logs (CWE-532)
4. **Automatic Token Refresh**: ApiClient ensures token is valid before request
5. **Context Extraction**: User data extracted from secure JWT context
## Usage Examples
### Display User Profile in Dashboard
```javascript
import React, { useEffect, useState } from 'react';
import MeService from '../../services/API/MeService';
function UserProfile() {
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchProfile = async () => {
try {
const data = await MeService.getMe();
setProfile(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchProfile();
}, []);
if (loading) return <div>Loading profile...</div>;
if (error) return <div>Error: {error}</div>;
if (!profile) return null;
return (
<div className="user-profile">
<h2>Welcome, {profile.name}!</h2>
<p>Email: {profile.email}</p>
<p>Role: {profile.role}</p>
<p>User ID: {profile.userId}</p>
</div>
);
}
export default UserProfile;
```
### Role-Based Access Control
```javascript
import React, { useEffect, useState } from 'react';
import MeService from '../../services/API/MeService';
function AdminPanel() {
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkAdmin = async () => {
const hasAdminRole = await MeService.hasRole("owner");
setIsAdmin(hasAdminRole);
setLoading(false);
};
checkAdmin();
}, []);
if (loading) return <div>Checking permissions...</div>;
if (!isAdmin) {
return <div>Access denied. Admin rights required.</div>;
}
return (
<div className="admin-panel">
<h2>Admin Panel</h2>
{/* Admin content */}
</div>
);
}
export default AdminPanel;
```
### Sync Profile with AuthManager
```javascript
import { useAuth } from '../../services/Services';
import MeService from '../../services/API/MeService';
function Dashboard() {
const { authManager } = useAuth();
useEffect(() => {
const syncProfile = async () => {
try {
// Get fresh profile from backend
const profile = await MeService.getMe();
// Get stored profile from AuthManager
const storedUser = authManager.getUser();
// Check if data is in sync
if (storedUser.email !== profile.email) {
console.warn("Profile data mismatch detected");
// Could update AuthManager or prompt user
}
console.log("Profile synced successfully");
} catch (error) {
console.error("Failed to sync profile:", error);
}
};
syncProfile();
}, [authManager]);
return <div>Dashboard</div>;
}
```
### Check Tenant Context
```javascript
import MeService from '../../services/API/MeService';
async function verifyTenantAccess(requiredTenantId) {
const userTenantId = await MeService.getTenantId();
if (userTenantId !== requiredTenantId) {
throw new Error("Access denied. Wrong tenant context.");
}
console.log("Tenant access verified");
return true;
}
// Usage
try {
await verifyTenantAccess("650e8400-e29b-41d4-a716-446655440000");
// Proceed with tenant-specific operations
} catch (error) {
console.error(error.message);
// Redirect or show error
}
```
### Verify Current User
```javascript
import MeService from '../../services/API/MeService';
function EditProfileButton({ profileOwnerId }) {
const [canEdit, setCanEdit] = useState(false);
useEffect(() => {
const checkOwnership = async () => {
const isOwner = await MeService.isCurrentUser(profileOwnerId);
setCanEdit(isOwner);
};
checkOwnership();
}, [profileOwnerId]);
if (!canEdit) return null;
return <button>Edit Profile</button>;
}
```
## Testing the Me API
### 1. Prerequisites
- Backend running at `http://localhost:8000`
- Frontend running at `http://localhost:5173`
- User logged in (valid JWT token)
### 2. Testing via Dashboard
Add a profile component to the dashboard:
```javascript
// In Dashboard.jsx
import MeService from '../../services/API/MeService';
const [userProfile, setUserProfile] = useState(null);
useEffect(() => {
const loadProfile = async () => {
try {
const profile = await MeService.getMe();
setUserProfile(profile);
console.log("Profile loaded:", profile);
} catch (error) {
console.error("Failed to load profile:", error);
}
};
loadProfile();
}, []);
// In JSX
{userProfile && (
<div>
<h3>{userProfile.name}</h3>
<p>{userProfile.email}</p>
<p>Role: {userProfile.role}</p>
</div>
)}
```
### 3. Testing via Browser Console
```javascript
// After logging in, open browser console
// Test basic profile fetch
const profile = await MeService.getMe();
console.log(profile);
// Expected: User profile object
// Test role check
const isOwner = await MeService.hasRole("owner");
console.log("Is owner:", isOwner);
// Test tenant ID
const tenantId = await MeService.getTenantId();
console.log("Tenant ID:", tenantId);
// Test user verification
const isCurrent = await MeService.isCurrentUser(profile.userId);
console.log("Is current user:", isCurrent); // Should be true
```
### 4. Testing with curl
```bash
# 1. Login and get access token
ACCESS_TOKEN=$(curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "SecurePass123!"
}' | jq -r '.access_token')
# 2. Test me endpoint
curl -X GET http://localhost:8000/api/v1/me \
-H "Authorization: JWT $ACCESS_TOKEN" | jq
# Expected output:
# {
# "user_id": "550e8400-...",
# "email": "test@example.com",
# "name": "Test User",
# "role": "owner",
# "tenant_id": "650e8400-..."
# }
# 3. Test without authentication (should fail)
curl -X GET http://localhost:8000/api/v1/me | jq
# Expected: 401 Unauthorized
# 4. Test with invalid token (should fail)
curl -X GET http://localhost:8000/api/v1/me \
-H "Authorization: JWT invalid_token" | jq
# Expected: 401 Unauthorized
```
### 5. Testing Token Refresh Integration
```javascript
// Test that token refresh works automatically
// 1. Login
// 2. Manually set token expiry to 30 seconds from now
const newExpiry = new Date(Date.now() + 30000).toISOString();
localStorage.setItem('maplepress_access_expiry', newExpiry);
// 3. Wait 31 seconds, then call getMe
await new Promise(resolve => setTimeout(resolve, 31000));
const profile = await MeService.getMe();
// 4. Check console logs - should see automatic token refresh
// 5. Response should be successful despite expired token
console.log("Profile after token refresh:", profile);
```
### 6. Testing Against Stored Data
```javascript
// Compare Me API response with stored AuthManager data
import { useAuth } from './services/Services';
import MeService from './services/API/MeService';
const { authManager } = useAuth();
// Get stored user
const storedUser = authManager.getUser();
console.log("Stored user:", storedUser);
// Get fresh profile from API
const profile = await MeService.getMe();
console.log("API profile:", profile);
// Compare
console.log("Email match:", storedUser.email === profile.email);
console.log("Name match:", storedUser.name === profile.name);
console.log("Role match:", storedUser.role === profile.role);
```
## Integration with Existing Services
The MeService automatically integrates with existing infrastructure:
### Authentication (AuthManager)
```javascript
// MeService uses ApiClient, which automatically:
// - Adds JWT token to Authorization header
// - Refreshes token if expired
// - Handles 401 errors
```
### Token Refresh (RefreshTokenService)
```javascript
// Automatic token refresh before getMe request
// If token expires within 1 minute, it's refreshed proactively
// If 401 received, token is refreshed and request is retried
```
### Data Consistency
```javascript
// Me API returns data from JWT token
// AuthManager stores data from login/register/refresh
// Both should be in sync, but Me API is source of truth
```
## Use Cases
### 1. Dashboard User Display
Display the current user's name and email in the dashboard header.
```javascript
const profile = await MeService.getMe();
setDashboardHeader(profile.name, profile.email);
```
### 2. Role-Based UI
Show or hide UI elements based on user role.
```javascript
const isAdmin = await MeService.hasRole("owner");
setShowAdminPanel(isAdmin);
```
### 3. Tenant Context
Ensure user is operating in the correct tenant context.
```javascript
const tenantId = await MeService.getTenantId();
loadTenantSpecificData(tenantId);
```
### 4. Profile Verification
Verify that the current user matches a resource owner.
```javascript
const canEdit = await MeService.isCurrentUser(resourceOwnerId);
```
### 5. Session Validation
Periodically verify that the session is still valid.
```javascript
setInterval(async () => {
try {
await MeService.getMe();
console.log("Session still valid");
} catch (error) {
console.log("Session expired, logging out");
authManager.logout();
}
}, 5 * 60 * 1000); // Every 5 minutes
```
## Data Comparison: JWT vs Stored
### JWT Token Data (from Me API)
- **Source**: JWT token claims
- **Updated**: On login, registration, or token refresh
- **Authoritative**: Yes (always current)
- **Requires Network**: Yes
### AuthManager Stored Data
- **Source**: localStorage (from login/register/refresh responses)
- **Updated**: On login, registration, or token refresh
- **Authoritative**: No (could be stale)
- **Requires Network**: No
**Recommendation**: Use Me API when you need authoritative current data, use AuthManager for quick access without network calls.
## Troubleshooting
### "Authentication required" error
**Possible causes:**
1. User not logged in
2. Access token expired
3. Refresh token expired
4. Session invalidated
**Solution:**
- Check `authManager.isAuthenticated()` before calling
- Login again if session expired
- Check browser console for token refresh logs
### Profile data doesn't match stored data
**Possible causes:**
1. Token was refreshed but AuthManager not updated
2. Profile was updated on another device
3. Data corruption in localStorage
**Solution:**
- Me API is source of truth - use its data
- Update AuthManager with fresh data if needed
- Clear localStorage and login again if corrupted
### Request succeeds but returns empty fields
**Possible causes:**
1. JWT token missing required claims
2. Backend not setting context values correctly
3. Token format incorrect
**Solution:**
- Check token structure (decode JWT at jwt.io)
- Verify backend is setting all required claims
- Re-login to get fresh token
### 401 error despite valid token
**Possible causes:**
1. Clock skew between client and server
2. Token signature invalid
3. Token expired but not detected
**Solution:**
- Check system clock synchronization
- Clear tokens and login again
- Verify token expiry dates in localStorage
## Related Files
### Created Files
```
src/services/API/MeService.js
docs/ME_API.md
```
### Backend Reference Files
```
cloud/maplepress-backend/docs/API.md (lines 374-413)
cloud/maplepress-backend/internal/interface/http/handler/gateway/me_handler.go
```
## Related Documentation
- [LOGIN_API.md](./LOGIN_API.md) - Login and JWT token acquisition
- [REFRESH_TOKEN_API.md](./REFRESH_TOKEN_API.md) - Automatic token refresh
- [HELLO_API.md](./HELLO_API.md) - Simple authenticated endpoint testing
- [FRONTEND_ARCHITECTURE.md](./FRONTEND_ARCHITECTURE.md) - Architecture overview
- [README.md](./README.md) - Documentation index
- [Backend API Documentation](../../../cloud/maplepress-backend/docs/API.md) - Complete API reference
## Summary
The Me API implementation provides:
1. **User Profile Access**: Get current user's profile from JWT token
2. **Fast & Efficient**: No database queries, data from token claims
3. **Helper Methods**: Role checking, tenant ID, user verification
4. **Automatic Integration**: Works seamlessly with token refresh
5. **Error Handling**: Clear error messages and graceful failures
6. **Use Case Focused**: Methods designed for common scenarios
This endpoint is essential for displaying user information in the dashboard and implementing role-based access control.
---
**Last Updated**: October 30, 2024
**Frontend Version**: 0.0.0
**Documentation Version**: 1.0.0

View file

@ -0,0 +1,631 @@
# Token Refresh API Implementation
This document describes the complete implementation of the token refresh feature for the MaplePress frontend, integrated with the MaplePress backend API.
## Overview
The token refresh feature allows users to obtain new access tokens without requiring them to log in again. This maintains seamless user sessions by automatically refreshing expired tokens in the background.
## Backend API Endpoint
**Endpoint**: `POST /api/v1/refresh`
**Authentication**: None required (public endpoint, but requires valid refresh token)
**Documentation**: `/cloud/maplepress-backend/docs/API.md` (lines 230-324)
### Request Structure
```json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
### Response Structure
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"user_email": "john@example.com",
"user_name": "John Doe",
"user_role": "user",
"tenant_id": "650e8400-e29b-41d4-a716-446655440000",
"session_id": "750e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"access_expiry": "2024-10-24T12:30:00Z",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_expiry": "2024-10-31T00:15:00Z",
"refreshed_at": "2024-10-24T12:15:00Z"
}
```
### Key Features
- **Token Rotation**: Both access and refresh tokens are regenerated on each refresh
- **Old Token Invalidation**: Previous tokens become invalid immediately
- **Session Validation**: Validates that the session still exists and is active
- **Automatic Expiry**: Access tokens expire after 15 minutes, refresh tokens after 7 days
## Frontend Implementation
### 1. RefreshTokenService (`src/services/API/RefreshTokenService.js`)
Handles direct communication with the backend refresh API.
**Key Features:**
- Request validation (required refresh token)
- Request body formatting (snake_case for backend)
- Response transformation (camelCase for frontend)
- User-friendly error message mapping
- Helper functions for token expiry checking
**Methods:**
#### `refreshToken(refreshToken)`
Main refresh method that calls the backend API.
```javascript
import RefreshTokenService from './services/API/RefreshTokenService';
const response = await RefreshTokenService.refreshToken("eyJhbGci...");
```
**Returns:**
```javascript
{
userId: string,
userEmail: string,
userName: string,
userRole: string,
tenantId: string,
sessionId: string,
accessToken: string,
accessExpiry: Date,
refreshToken: string,
refreshExpiry: Date,
refreshedAt: Date
}
```
#### `shouldRefreshToken(accessExpiry, bufferMinutes = 1)`
Checks if token should be refreshed based on expiry time.
```javascript
const needsRefresh = RefreshTokenService.shouldRefreshToken(
new Date('2024-10-24T12:14:30Z'), // Access expiry
1 // Refresh if expiring within 1 minute
);
```
#### `isRefreshTokenValid(refreshExpiry)`
Checks if the refresh token itself is still valid.
```javascript
const isValid = RefreshTokenService.isRefreshTokenValid(
new Date('2024-10-31T00:15:00Z')
);
```
### 2. AuthManager Enhancement (`src/services/Manager/AuthManager.js`)
Updated with complete token refresh functionality and automatic refresh logic.
**New Features:**
- Token refresh on initialization if access token expired
- Prevents duplicate refresh requests with `refreshPromise` tracking
- Automatic token refresh before API requests
- Handles refresh failures gracefully
**New Methods:**
#### `async refreshAccessToken()`
Refreshes the access token using the stored refresh token.
```javascript
const authManager = useAuth().authManager;
try {
await authManager.refreshAccessToken();
console.log("Token refreshed successfully");
} catch (error) {
console.error("Refresh failed:", error);
// User will be logged out automatically
}
```
**Features:**
- Prevents duplicate refresh requests (returns same promise if refresh in progress)
- Validates refresh token exists and is not expired
- Clears session on refresh failure
- Implements token rotation (stores new tokens)
- Preserves tenant name/slug from previous session
#### `shouldRefreshToken(bufferMinutes = 1)`
Checks if the current access token needs refresh.
```javascript
if (authManager.shouldRefreshToken()) {
await authManager.refreshAccessToken();
}
```
#### `async ensureValidToken()`
Automatically refreshes token if needed. Called before API requests.
```javascript
// Automatically called by ApiClient before each request
await authManager.ensureValidToken();
```
### 3. ApiClient Enhancement (`src/services/API/ApiClient.js`)
Updated to automatically refresh tokens before making requests and handle 401 errors.
**Automatic Token Refresh:**
- Calls `ensureValidToken()` before every API request
- Skips refresh for the `/api/v1/refresh` endpoint itself
- Can be disabled per-request with `skipTokenRefresh` option
**401 Error Handling:**
- Automatically attempts token refresh on 401 Unauthorized
- Retries the original request once with new token
- Logs out user if refresh fails
**Enhanced Request Flow:**
```javascript
1. Check if token needs refresh (within 1 minute of expiry)
2. If yes, refresh token proactively
3. Make API request with current token
4. If 401 received, force refresh and retry once
5. If retry fails, throw error and clear session
```
## Data Flow
### Initial Authentication
```
User logs in
Backend returns tokens
AuthManager stores tokens
Access Token: valid for 15 minutes
Refresh Token: valid for 7 days
```
### Proactive Token Refresh (Before Expiry)
```
User makes API request
ApiClient checks token expiry
Token expires in < 1 minute?
Yes: Refresh token proactively
AuthManager.ensureValidToken()
AuthManager.refreshAccessToken()
RefreshTokenService.refreshToken()
POST /api/v1/refresh
Backend validates session
Backend returns new tokens
AuthManager stores new tokens
Continue with original API request
```
### Reactive Token Refresh (After 401 Error)
```
API request made
Backend returns 401 Unauthorized
ApiClient detects 401
Attempt token refresh
Retry original request
Success: Return response
Failure: Clear session and throw error
```
### Token Refresh on App Initialization
```
App starts
AuthManager.initialize()
Load tokens from localStorage
Access token expired?
Yes: Check refresh token
Refresh token valid?
Yes: Refresh access token
No: Clear session
```
## Token Lifecycle
### Token Expiry Timeline
```
Login (00:00)
├─ Access Token: expires at 00:15 (15 minutes)
│ └─ Proactive refresh at 00:14 (1 minute before)
└─ Refresh Token: expires at 7 days later
└─ Requires new login when expired
```
### Session Validation
- **Session Duration**: 14 days of inactivity (backend setting)
- **Access Token**: 15 minutes (can be refreshed)
- **Refresh Token**: 7 days (single-use due to rotation)
If the backend session expires (14 days), token refresh will fail even if the refresh token hasn't expired yet.
## Error Handling
### Error Types
| Error Condition | Response | Frontend Behavior |
|----------------|----------|-------------------|
| Missing refresh token | 400 Bad Request | Clear session, throw error |
| Invalid refresh token | 401 Unauthorized | Clear session, show "Session expired" |
| Expired refresh token | 401 Unauthorized | Clear session, show "Session expired" |
| Session invalidated | 401 Unauthorized | Clear session, show "Session expired or invalidated" |
| Server error | 500 Internal Server Error | Clear session, show generic error |
### Error Messages
The frontend maps backend errors to user-friendly messages:
```javascript
// Backend: "invalid or expired refresh token"
// Frontend: "Your session has expired. Please log in again to continue."
// Backend: "session not found or expired"
// Frontend: "Session has expired or been invalidated. Please log in again."
// Backend: Generic error
// Frontend: "Unable to refresh your session. Please log in again to continue."
```
## Security Features
1. **Token Rotation**: Each refresh generates new tokens, invalidating old ones
2. **Session Validation**: Refresh validates against active backend session
3. **Prevents Token Reuse**: Old tokens become invalid immediately
4. **CWE-613 Prevention**: Session validation prevents token use after logout
5. **Proactive Refresh**: Reduces window of expired token exposure
6. **Duplicate Prevention**: `refreshPromise` tracking prevents race conditions
7. **Automatic Cleanup**: Failed refresh automatically clears session
## Usage Examples
### Manual Token Refresh
```javascript
import { useAuth } from './services/Services';
function MyComponent() {
const { authManager } = useAuth();
const handleManualRefresh = async () => {
try {
await authManager.refreshAccessToken();
console.log("Token refreshed manually");
} catch (error) {
console.error("Manual refresh failed:", error);
// User will be redirected to login
}
};
return <button onClick={handleManualRefresh}>Refresh Session</button>;
}
```
### Checking Token Status
```javascript
const { authManager } = useAuth();
// Check if token needs refresh
if (authManager.shouldRefreshToken()) {
console.log("Token expires soon");
}
// Check if refresh token is valid
const refreshExpiry = authManager.refreshExpiry;
if (RefreshTokenService.isRefreshTokenValid(refreshExpiry)) {
console.log("Refresh token is still valid");
}
```
### Automatic Refresh (Built-in)
```javascript
// Automatic refresh is built into ApiClient
// No manual intervention needed
const { apiClient } = useApi();
// Token will be automatically refreshed if needed
const response = await apiClient.get('/api/v1/some-endpoint');
```
## Testing the Token Refresh Flow
### 1. Prerequisites
- Backend running at `http://localhost:8000`
- Frontend running at `http://localhost:5173`
- User already logged in
### 2. Test Proactive Refresh
```javascript
// In browser console
// 1. Login first
// (Use the login form)
// 2. Check token expiry
const expiry = localStorage.getItem('maplepress_access_expiry');
console.log('Token expires at:', expiry);
// 3. Manually set expiry to 30 seconds from now (for testing)
const newExpiry = new Date(Date.now() + 30000).toISOString();
localStorage.setItem('maplepress_access_expiry', newExpiry);
// 4. Make an API request (will trigger proactive refresh after 30 seconds)
// The ApiClient will automatically refresh before the request
// 5. Check that new token was stored
setTimeout(() => {
const newToken = localStorage.getItem('maplepress_access_token');
console.log('New token:', newToken);
}, 31000);
```
### 3. Test Refresh on Initialization
```bash
# 1. Login to the app
# 2. Open browser DevTools → Application → Local Storage
# 3. Find 'maplepress_access_expiry'
# 4. Change the date to yesterday
# 5. Refresh the page
# 6. Check console logs - should see token refresh attempt
```
### 4. Test 401 Handling
This requires backend cooperation (backend must return 401):
```javascript
// In browser console after login
// 1. Make access token invalid
localStorage.setItem('maplepress_access_token', 'invalid_token');
// 2. Make an authenticated API request
// The ApiClient will receive 401, attempt refresh, and retry
// 3. Check console for refresh logs
```
### 5. Test Refresh Failure
```javascript
// In browser console
// 1. Make refresh token invalid
localStorage.setItem('maplepress_refresh_token', 'invalid_refresh');
// 2. Try to refresh
window.maplePressServices.authManager.refreshAccessToken()
.catch(error => console.log('Expected error:', error));
// 3. Check that session was cleared
window.maplePressServices.authManager.isAuthenticated() // Should be false
```
### 6. Backend Testing with curl
```bash
# 1. Login and get refresh token
curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "SecurePass123!"
}' | jq -r '.refresh_token'
# 2. Use refresh token to get new tokens
curl -X POST http://localhost:8000/api/v1/refresh \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "eyJhbGci..."
}' | jq
# Expected: New access_token and refresh_token
# 3. Try to use old refresh token (should fail)
curl -X POST http://localhost:8000/api/v1/refresh \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "eyJhbGci..."
}'
# Expected: 401 Unauthorized
```
## Best Practices
### Implemented Best Practices
**Proactive Refresh**: Tokens are refreshed 1 minute before expiry
**Automatic Refresh**: Built into ApiClient, no manual intervention needed
**Token Rotation**: Both tokens regenerated on refresh
**Session Validation**: Backend validates session on each refresh
**Duplicate Prevention**: Only one refresh request at a time
**Error Handling**: Failed refresh clears session and redirects to login
**Secure Storage**: Tokens stored in localStorage (consider upgrading to httpOnly cookies for production)
### Recommended Enhancements
- [ ] Move to HTTP-only cookies for token storage (more secure than localStorage)
- [ ] Implement refresh token allowlist (backend: store valid refresh tokens)
- [ ] Add refresh token fingerprinting (tie token to device)
- [ ] Implement sliding sessions (extend session on activity)
- [ ] Add token refresh monitoring/metrics
- [ ] Implement token refresh failure notifications
## Troubleshooting
### Token refresh fails immediately after login
**Possible causes:**
1. Clock skew between client and server
2. Token expiry dates incorrect
3. localStorage corruption
**Solution:**
- Clear localStorage and login again
- Check browser console for expiry dates
- Verify system clock is correct
### Token refresh succeeds but returns 401 on next request
**Possible causes:**
1. Token not being stored correctly
2. ApiClient not using updated token
3. Race condition between refresh and request
**Solution:**
- Check localStorage after refresh
- Verify `authManager.getAccessToken()` returns new token
- Check for duplicate refresh requests in network tab
### Session cleared unexpectedly
**Possible causes:**
1. Refresh token expired (after 7 days)
2. Backend session invalidated (after 14 days inactivity)
3. User logged out on another device
4. Backend restarted and cleared sessions
**Solution:**
- Check `maplepress_refresh_expiry` in localStorage
- Verify backend session still exists
- Login again to create new session
### Infinite refresh loop
**Possible causes:**
1. Token expiry dates set incorrectly
2. Backend always returning expired tokens
3. `skipTokenRefresh` not working
**Solution:**
- Check for `/api/v1/refresh` endpoint exclusion in ApiClient
- Verify token expiry dates in localStorage
- Check backend logs for refresh endpoint errors
## Integration with Dashboard
The dashboard automatically benefits from token refresh:
```javascript
// Dashboard.jsx
useEffect(() => {
if (!authManager.isAuthenticated()) {
navigate("/login");
return;
}
// Token will be automatically refreshed if needed
const userData = authManager.getUser();
setUser(userData);
}, [authManager, navigate]);
```
Any API calls made from the dashboard will automatically trigger token refresh if needed.
## Future Enhancements
### Planned Features
- [ ] **Background Refresh Timer**: Periodic refresh check every 5 minutes
- [ ] **Session Extension Prompt**: Ask user to extend session before refresh token expires
- [ ] **Multi-tab Synchronization**: Coordinate token refresh across browser tabs
- [ ] **Refresh Analytics**: Track refresh success/failure rates
- [ ] **Grace Period Handling**: Handle partial network failures gracefully
### Security Improvements
- [ ] **HTTP-only Cookies**: Move from localStorage to secure cookies
- [ ] **Token Binding**: Bind tokens to specific devices/browsers
- [ ] **Refresh Token Allowlist**: Backend maintains list of valid refresh tokens
- [ ] **Token Rotation Detection**: Detect and prevent token replay attacks
- [ ] **Session Fingerprinting**: Additional validation beyond token
## Related Files
### Created Files
```
src/services/API/RefreshTokenService.js
```
### Modified Files
```
src/services/Manager/AuthManager.js
src/services/API/ApiClient.js
```
### Backend Reference Files
```
cloud/maplepress-backend/docs/API.md (lines 230-324)
cloud/maplepress-backend/internal/interface/http/dto/gateway/refresh_dto.go
cloud/maplepress-backend/internal/interface/http/handler/gateway/refresh_handler.go
```
## Related Documentation
- [LOGIN_API.md](./LOGIN_API.md) - Login implementation and token storage
- [REGISTRATION_API.md](./REGISTRATION_API.md) - Registration implementation
- [FRONTEND_ARCHITECTURE.md](./FRONTEND_ARCHITECTURE.md) - Complete architecture guide
- [README.md](./README.md) - Documentation index
- [Backend API Documentation](../../../cloud/maplepress-backend/docs/API.md) - Complete API reference
## Summary
The token refresh implementation provides:
1. **Seamless Sessions**: Users stay logged in without interruption
2. **Automatic Refresh**: No manual intervention required
3. **Proactive Strategy**: Tokens refreshed before expiry
4. **Reactive Fallback**: Handles unexpected 401 errors
5. **Security**: Token rotation prevents reuse
6. **Reliability**: Duplicate prevention and error handling
The implementation follows backend best practices and integrates seamlessly with the existing three-layer service architecture.
---
**Last Updated**: October 30, 2024
**Frontend Version**: 0.0.0
**Documentation Version**: 1.0.0

View file

@ -0,0 +1,480 @@
# Registration API Implementation
This document describes the complete implementation of the user registration feature for the MaplePress frontend, integrated with the MaplePress backend API.
## Overview
The registration feature allows new users to create an account and automatically sets up their tenant (organization) in a single step. Upon successful registration, users receive authentication tokens and are automatically logged in.
## Backend API Endpoint
**Endpoint**: `POST /api/v1/register`
**Authentication**: None required (public endpoint)
**Documentation**: `/cloud/maplepress-backend/docs/API.md` (lines 66-166)
### Request Structure
```json
{
"email": "user@example.com",
"password": "SecurePassword123!",
"confirm_password": "SecurePassword123!",
"first_name": "John",
"last_name": "Doe",
"tenant_name": "Acme Corporation",
"timezone": "America/New_York",
"agree_terms_of_service": true,
"agree_promotions": false,
"agree_to_tracking_across_third_party_apps_and_services": false
}
```
### Response Structure
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"user_email": "user@example.com",
"user_name": "John Doe",
"user_role": "manager",
"tenant_id": "650e8400-e29b-41d4-a716-446655440000",
"tenant_name": "Acme Corporation",
"tenant_slug": "acme-corp",
"session_id": "750e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"access_expiry": "2024-10-24T12:00:00Z",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_expiry": "2024-11-24T00:00:00Z",
"created_at": "2024-10-24T00:00:00Z"
}
```
## Frontend Implementation
### 1. RegisterService (`src/services/API/RegisterService.js`)
Handles direct communication with the backend registration API.
**Key Features:**
- Request validation (required fields, terms agreement)
- Request body formatting (snake_case for backend)
- Response transformation (camelCase for frontend)
- User-friendly error message mapping
- Tenant slug validation and generation utilities
**Methods:**
- `register(data)` - Main registration method
- `validateTenantSlug(slug)` - Validate slug format
- `generateTenantSlug(name)` - Auto-generate slug from name
**Usage:**
```javascript
import RegisterService from './services/API/RegisterService';
const response = await RegisterService.register({
email: "user@example.com",
password: "SecurePassword123!",
confirmPassword: "SecurePassword123!",
first_name: "John",
last_name: "Doe",
tenant_name: "Acme Corp",
agree_terms_of_service: true,
});
```
### 2. AuthManager (`src/services/Manager/AuthManager.js`)
Manages authentication state, token storage, and session lifecycle.
**Key Features:**
- Token management (access & refresh tokens)
- LocalStorage persistence
- Token expiry checking
- User and tenant data storage
- Session initialization and cleanup
**New Methods:**
- `register(registrationData)` - Register and store auth data
- `storeAuthData(authResponse)` - Store tokens and user data
- `clearSession()` - Clear all auth data
- `isTokenExpired(expiry)` - Check token expiry
- `getTenant()` - Get current tenant information
**Storage Keys:**
```javascript
maplepress_access_token
maplepress_refresh_token
maplepress_access_expiry
maplepress_refresh_expiry
maplepress_user
maplepress_tenant
maplepress_session_id
```
### 3. Register Page (`src/pages/Auth/Register.jsx`)
Complete registration form with all required fields.
**Form Sections:**
1. **Personal Information**
- First Name (required)
- Last Name (required)
- Email Address (required)
- Password (required, min 8 chars)
- Confirm Password (required)
2. **Organization Information**
- Organization Name (required, slug auto-generated by backend)
3. **Terms and Consent**
- Terms of Service agreement (required)
- Promotional emails (optional)
**Features:**
- Password match validation (backend-validated)
- Terms of service requirement
- Automatic timezone detection
- Loading state during submission
- RFC 9457 error message display with field-specific errors
## Data Flow
```
User fills form
Register.jsx validates data
AuthManager.register(data)
RegisterService.register(data)
HTTP POST to /api/v1/register
Backend validates and creates user + tenant
Backend returns tokens and user data
RegisterService transforms response
AuthManager stores tokens in localStorage
User redirected to /dashboard
```
## Validation Rules
### Frontend Validation
1. **Email**: Required, valid email format
2. **Password**:
- Required
- Minimum 8 characters
3. **Confirm Password**:
- Required
- Must match password (validated by backend)
4. **Name**: Required
5. **Tenant Name**: Required
6. **Tenant Slug**:
- Required
- Only lowercase letters, numbers, and hyphens
- Validated by regex: `/^[a-z0-9-]+$/`
7. **Terms of Service**: Must be checked
### Backend Validation
Backend performs additional validation:
- Email normalization and format validation
- Password strength requirements (uppercase, lowercase, number, special char)
- Password confirmation matching
- Tenant slug uniqueness
- Email uniqueness
- No HTML in text fields
- Input sanitization
## Error Handling
### RFC 9457 (Problem Details for HTTP APIs)
The backend returns validation errors in **RFC 9457** format, which provides a structured, machine-readable error response.
**Error Response Structure**:
```json
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "One or more validation errors occurred",
"errors": {
"email": ["Invalid email format"],
"password": ["Field is required", "Password must be at least 8 characters"],
"confirm_password": ["Field is required", "Passwords do not match"],
"first_name": ["Field is required"],
"last_name": ["Field is required"],
"tenant_name": ["Field is required"],
"agree_terms_of_service": ["Must agree to terms of service"]
}
}
```
**Content-Type**: `application/problem+json`
**Key Fields**:
- `type`: URI reference identifying the problem type (uses `about:blank` for generic errors)
- `title`: Short, human-readable summary of the error type
- `status`: HTTP status code
- `detail`: Human-readable explanation of the error
- `errors`: Dictionary/map of field-specific validation errors (extension field)
### Common Validation Errors
| Field | Error Message |
|-------|--------------|
| email | `email: invalid email format` |
| email | `email: email is required` |
| password | `password: password is required` |
| password | `password: password must be at least 8 characters` |
| password | `password: password must contain at least one uppercase letter (A-Z)` |
| password | `password: password must contain at least one lowercase letter (a-z)` |
| password | `password: password must contain at least one number (0-9)` |
| password | `password: password must contain at least one special character` |
| confirm_password | `confirm_password: field is required` |
| confirm_password | `confirm_password: passwords do not match` |
| first_name | `first_name: field is required` |
| first_name | `first_name: first_name must be between 1 and 100 characters` |
| last_name | `last_name: field is required` |
| last_name | `last_name: last_name must be between 1 and 100 characters` |
| tenant_name | `tenant_name: tenant_name is required` |
| tenant_name | `tenant_name: tenant_name must be between 1 and 100 characters` |
| agree_terms_of_service | `agree_terms_of_service: must agree to terms of service` |
### Frontend Error Parsing
The Register page component (`src/pages/Auth/Register.jsx`) includes a `parseBackendError()` function that:
1. Checks if the error object contains RFC 9457 `validationErrors` structure
2. Iterates through the errors dictionary and maps each field to its error messages
3. Joins multiple error messages for the same field with semicolons
4. Displays field-specific errors with red borders and inline messages
5. Shows an error summary box at the top with all errors listed
**Error Parsing Implementation**:
```javascript
const parseBackendError = (error) => {
const fieldErrors = {};
let generalError = null;
// Check if error has RFC 9457 validation errors structure
if (error.validationErrors && typeof error.validationErrors === 'object') {
// Process each field's errors
Object.entries(error.validationErrors).forEach(([fieldName, errorMessages]) => {
if (Array.isArray(errorMessages) && errorMessages.length > 0) {
// Join multiple error messages for the same field
fieldErrors[fieldName] = errorMessages.join('; ');
}
});
// Use the detail as general error if available
if (error.message) {
generalError = error.message;
}
} else {
// Fallback for non-RFC 9457 errors
generalError = error.message || "An error occurred. Please try again.";
}
return { fieldErrors, generalError };
};
```
**Example Error Display**:
When submitting an empty form, the user sees:
- **Error Summary Box**: Lists all validation errors at the top of the form with the general message "One or more validation errors occurred"
- **Highlighted Fields**: Red border on email, password, first_name, last_name, tenant_name, etc.
- **Inline Messages**: Error text displayed below each affected field (e.g., "Field is required")
### ApiClient Error Handling
The `ApiClient` (`src/services/API/ApiClient.js`) automatically parses RFC 9457 error responses and attaches the validation errors to the error object:
```javascript
// Create error with RFC 9457 data if available
const error = new Error(
errorData.detail || errorData.message || `HTTP ${response.status}: ${response.statusText}`
);
// Attach RFC 9457 fields to error for parsing
if (errorData.errors) {
error.validationErrors = errorData.errors; // RFC 9457 validation errors
}
if (errorData.title) {
error.title = errorData.title;
}
if (errorData.status) {
error.status = errorData.status;
}
throw error;
```
**Note**: The frontend **does not perform field validation** - all validation, including password matching, is handled by the backend. The form submits whatever the user enters, and the backend returns comprehensive validation errors in RFC 9457 format.
## Security Features
1. **Token Storage**: Tokens stored in localStorage (client-side only)
2. **Token Expiry**: Automatic expiry checking on initialization
3. **Password Validation**: Client and server-side validation
4. **HTTPS Required**: Production should use HTTPS
5. **Terms Agreement**: Required before account creation
6. **Input Sanitization**: Backend sanitizes all inputs
## Testing the Registration Flow
### 1. Start Backend
```bash
cd cloud/mapleopentech-backend
task dev
```
### 2. Start Frontend
```bash
cd web/maplepress-frontend
npm run dev
```
### 3. Navigate to Registration
Open browser to: `http://localhost:5173/register`
### 4. Fill Form
- **First Name**: John (required)
- **Last Name**: Doe (required)
- **Email**: test@example.com
- **Password**: SecurePass123!
- **Confirm Password**: SecurePass123!
- **Organization Name**: Test Corp (slug auto-generated by backend)
- **Terms of Service**: ✓ (checked)
### 5. Submit
Click "Create Account" button
### 6. Expected Result
- Loading state appears
- Request sent to backend
- Tokens stored in localStorage
- User redirected to `/dashboard`
- Dashboard shows user information
### 7. Verify in Browser Console
```javascript
// Check stored tokens
localStorage.getItem('maplepress_access_token')
localStorage.getItem('maplepress_user')
localStorage.getItem('maplepress_tenant')
// Check service instance
window.maplePressServices.authManager.isAuthenticated()
window.maplePressServices.authManager.getUser()
window.maplePressServices.authManager.getTenant()
```
## Configuration
### Environment Variables
Create `.env` file:
```bash
# Backend API URL
VITE_API_BASE_URL=http://localhost:8000
```
### Production Configuration
For production:
```bash
VITE_API_BASE_URL=https://api.maplepress.io
```
## Future Enhancements
### Planned Features
- [ ] Email verification flow
- [ ] Password strength indicator
- [ ] Captcha integration
- [ ] Social authentication (Google, GitHub)
- [ ] Organization logo upload
- [ ] Custom domain support
- [ ] Invitation codes/referrals
- [ ] Account recovery flow
### Security Improvements
- [ ] CSRF token implementation
- [ ] Rate limiting on frontend
- [ ] Session fingerprinting
- [ ] Two-factor authentication
- [ ] Password breach checking
- [ ] Secure token storage (HTTP-only cookies)
## File Reference
### Created Files
```
src/services/API/RegisterService.js
```
### Modified Files
```
src/services/Manager/AuthManager.js
src/pages/Auth/Register.jsx
```
### Backend Reference Files
```
cloud/maplepress-backend/docs/API.md
cloud/maplepress-backend/internal/interface/http/dto/gateway/register_dto.go
cloud/maplepress-backend/internal/interface/http/handler/gateway/register_handler.go
```
## API Client Configuration
The `ApiClient` automatically handles:
- JSON content-type headers
- Request/response transformation
- Error parsing
- Authentication headers (for protected endpoints)
### Base Configuration
```javascript
API_CONFIG = {
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8000",
timeout: 30000,
}
```
## Support
For issues or questions:
1. Check backend logs: `docker logs mapleopentech_backend`
2. Check frontend console for errors
3. Verify backend is running on port 8000
4. Verify frontend environment variables
5. Test backend endpoint directly with curl (see API.md)
## Related Documentation
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Frontend architecture overview
- [README.md](./README.md) - Getting started guide
- [Backend API Documentation](../../cloud/maplepress-backend/docs/API.md) - Complete API reference

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,792 @@
# Tenant API Implementation (Tenant Management)
This document describes the implementation of the Tenant Management API endpoints for the MaplePress frontend, integrated with the MaplePress backend API.
## Overview
The Tenant API endpoints manage tenants (organizations) in the multi-tenant system. Each tenant represents an organization with its own users, resources, and isolated data. Tenants are identified by both UUID and a URL-friendly slug.
## Backend API Endpoints
### Create Tenant
**Endpoint**: `POST /api/v1/tenants`
**Authentication**: Required (JWT token)
### Get Tenant by ID
**Endpoint**: `GET /api/v1/tenants/{id}`
**Authentication**: Required (JWT token)
### Get Tenant by Slug
**Endpoint**: `GET /api/v1/tenants/slug/{slug}`
**Authentication**: Required (JWT token)
**Documentation**: `/cloud/maplepress-backend/docs/API.md` (lines 416-558)
## Request/Response Structures
### Create Tenant Request
```json
{
"name": "TechStart Inc",
"slug": "techstart"
}
```
### Tenant Response
```json
{
"id": "850e8400-e29b-41d4-a716-446655440000",
"name": "TechStart Inc",
"slug": "techstart",
"status": "active",
"created_at": "2024-10-24T00:00:00Z",
"updated_at": "2024-10-24T00:00:00Z"
}
```
## Frontend Implementation
### TenantService (`src/services/API/TenantService.js`)
Handles all tenant management operations with the backend API.
**Key Features:**
- Create new tenants
- Retrieve tenant by ID or slug
- Client-side validation (name and slug)
- Slug generation helper
- Response transformation (snake_case to camelCase)
- User-friendly error message mapping
**Methods:**
#### `createTenant(tenantData)`
Create a new tenant (organization).
```javascript
import TenantService from './services/API/TenantService';
const tenant = await TenantService.createTenant({
name: "TechStart Inc",
slug: "techstart"
});
console.log(tenant);
// Output:
// {
// id: "850e8400-...",
// name: "TechStart Inc",
// slug: "techstart",
// status: "active",
// createdAt: Date object
// }
```
**Parameters:**
- `tenantData.name` (string, required): Tenant/organization name
- `tenantData.slug` (string, required): URL-friendly identifier (lowercase, hyphens only)
**Returns:**
```javascript
{
id: string, // Tenant ID (UUID)
name: string, // Tenant name
slug: string, // Tenant slug
status: string, // Tenant status (e.g., "active")
createdAt: Date // Creation timestamp
}
```
**Throws:**
- "Tenant data is required" - If tenantData is missing
- "Tenant name is required" - If name is missing
- "Tenant slug is required" - If slug is missing
- "Tenant slug must contain only lowercase letters, numbers, and hyphens" - Invalid slug format
- "Tenant slug already exists. Please choose a different slug." - Slug conflict (409)
- "Authentication required. Please log in to continue." - Missing/invalid token
#### `getTenantById(tenantId)`
Retrieve tenant information by ID.
```javascript
const tenant = await TenantService.getTenantById("850e8400-...");
console.log(tenant.name); // "TechStart Inc"
```
**Parameters:**
- `tenantId` (string, required): Tenant ID (UUID format)
**Returns:**
```javascript
{
id: string,
name: string,
slug: string,
status: string,
createdAt: Date,
updatedAt: Date
}
```
**Throws:**
- "Tenant ID is required" - If ID is missing
- "Invalid tenant ID format" - If ID is not a valid UUID
- "Tenant not found." - If tenant doesn't exist (404)
- "Authentication required. Please log in to continue." - Missing/invalid token
#### `getTenantBySlug(slug)`
Retrieve tenant information by slug.
```javascript
const tenant = await TenantService.getTenantBySlug("techstart");
console.log(tenant.id); // "850e8400-..."
```
**Parameters:**
- `slug` (string, required): Tenant slug
**Returns:**
```javascript
{
id: string,
name: string,
slug: string,
status: string,
createdAt: Date,
updatedAt: Date
}
```
**Throws:**
- "Tenant slug is required" - If slug is missing
- "Tenant slug cannot be empty" - If slug is empty after trimming
- "Tenant not found." - If tenant doesn't exist (404)
- "Authentication required. Please log in to continue." - Missing/invalid token
#### `generateSlug(name)`
Generate a URL-friendly slug from a tenant name.
```javascript
const slug = TenantService.generateSlug("TechStart Inc!");
console.log(slug); // "techstart-inc"
const slug2 = TenantService.generateSlug("My Company");
console.log(slug2); // "my-company"
```
**Parameters:**
- `name` (string): Tenant name
**Returns:** `string` - Generated slug (lowercase, hyphens, alphanumeric only)
**Transformation Rules:**
- Converts to lowercase
- Replaces spaces with hyphens
- Removes special characters
- Removes consecutive hyphens
- Removes leading/trailing hyphens
#### `validateSlug(slug)`
Validate a tenant slug format.
```javascript
const result = TenantService.validateSlug("techstart");
console.log(result); // { valid: true, error: null }
const invalid = TenantService.validateSlug("Tech Start!");
console.log(invalid); // { valid: false, error: "Slug must contain..." }
```
**Parameters:**
- `slug` (string): Slug to validate
**Returns:**
```javascript
{
valid: boolean, // true if slug is valid
error: string|null // Error message if invalid, null if valid
}
```
**Validation Rules:**
- Required (non-empty)
- Length: 2-50 characters
- Format: lowercase letters, numbers, hyphens only
- Cannot start or end with hyphen
- Cannot contain consecutive hyphens
#### `validateName(name)`
Validate a tenant name.
```javascript
const result = TenantService.validateName("TechStart Inc");
console.log(result); // { valid: true, error: null }
const invalid = TenantService.validateName("A");
console.log(invalid); // { valid: false, error: "Name must be at least 2 characters" }
```
**Parameters:**
- `name` (string): Name to validate
**Returns:**
```javascript
{
valid: boolean,
error: string|null
}
```
**Validation Rules:**
- Required (non-empty)
- Length: 2-100 characters
## Data Flow
### Create Tenant Flow
```
User provides tenant name and slug
TenantService.createTenant()
Validate name and slug (client-side)
ApiClient.post() with JWT token
Token automatically refreshed if needed
POST /api/v1/tenants
Backend validates (slug uniqueness, format)
Backend creates tenant in database
Backend returns tenant data
TenantService transforms response
Component receives tenant data
```
### Get Tenant Flow
```
User requests tenant (by ID or slug)
TenantService.getTenantById() or getTenantBySlug()
Validate input format
ApiClient.get() with JWT token
Token automatically refreshed if needed
GET /api/v1/tenants/{id} or /api/v1/tenants/slug/{slug}
Backend retrieves tenant from database
Backend returns tenant data
TenantService transforms response
Component receives tenant data
```
## Error Handling
### Error Types
| Error Condition | Response | Frontend Behavior |
|----------------|----------|-------------------|
| Missing authentication | 401 Unauthorized | "Authentication required. Please log in to continue." |
| Invalid tenant data | 400 Bad Request | Specific validation error |
| Slug already exists | 409 Conflict | "Tenant slug already exists. Please choose a different slug." |
| Tenant not found | 404 Not Found | "Tenant not found." |
| Server error | 500 Internal Server Error | Generic error message |
### Error Message Mapping
```javascript
// Backend error → Frontend error
"unauthorized" → "Authentication required. Please log in to continue."
"conflict" / "already exists" → "Tenant slug already exists. Please choose a different slug."
"not found" → "Tenant not found."
"slug" → "Invalid tenant slug. Must contain only lowercase letters, numbers, and hyphens."
"name" → "Invalid tenant name provided."
```
## Validation Rules
### Tenant Name
- **Required**: Cannot be empty
- **Length**: 2-100 characters
- **Format**: Any printable characters allowed
### Tenant Slug
- **Required**: Cannot be empty
- **Length**: 2-50 characters
- **Format**: Lowercase letters, numbers, hyphens only
- **Pattern**: `^[a-z0-9-]+$`
- **Restrictions**:
- Cannot start or end with hyphen
- Cannot contain consecutive hyphens
- Must be URL-safe
## Usage Examples
### Create a New Tenant
```javascript
import React, { useState } from 'react';
import TenantService from '../../services/API/TenantService';
function CreateTenantForm() {
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Auto-generate slug from name
const handleNameChange = (e) => {
const newName = e.target.value;
setName(newName);
// Generate slug automatically
const generatedSlug = TenantService.generateSlug(newName);
setSlug(generatedSlug);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
// Validate before sending
const nameValidation = TenantService.validateName(name);
if (!nameValidation.valid) {
setError(nameValidation.error);
setLoading(false);
return;
}
const slugValidation = TenantService.validateSlug(slug);
if (!slugValidation.valid) {
setError(slugValidation.error);
setLoading(false);
return;
}
try {
const tenant = await TenantService.createTenant({ name, slug });
console.log("Tenant created:", tenant);
// Redirect or show success message
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Tenant Name:</label>
<input
type="text"
value={name}
onChange={handleNameChange}
placeholder="TechStart Inc"
maxLength={100}
/>
</div>
<div>
<label>Tenant Slug:</label>
<input
type="text"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase())}
placeholder="techstart"
maxLength={50}
/>
<small>URL: /tenants/{slug}</small>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Tenant'}
</button>
</form>
);
}
export default CreateTenantForm;
```
### Display Tenant Information
```javascript
import React, { useEffect, useState } from 'react';
import TenantService from '../../services/API/TenantService';
function TenantProfile({ tenantId }) {
const [tenant, setTenant] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchTenant = async () => {
try {
const data = await TenantService.getTenantById(tenantId);
setTenant(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchTenant();
}, [tenantId]);
if (loading) return <div>Loading tenant...</div>;
if (error) return <div>Error: {error}</div>;
if (!tenant) return null;
return (
<div className="tenant-profile">
<h2>{tenant.name}</h2>
<p>Slug: {tenant.slug}</p>
<p>Status: {tenant.status}</p>
<p>Created: {tenant.createdAt.toLocaleDateString()}</p>
<p>Updated: {tenant.updatedAt.toLocaleDateString()}</p>
</div>
);
}
export default TenantProfile;
```
### Search Tenant by Slug
```javascript
import TenantService from '../../services/API/TenantService';
function TenantLookup() {
const [slug, setSlug] = useState('');
const [tenant, setTenant] = useState(null);
const handleSearch = async () => {
try {
const data = await TenantService.getTenantBySlug(slug);
setTenant(data);
} catch (error) {
console.error("Tenant not found:", error.message);
setTenant(null);
}
};
return (
<div>
<input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="Enter tenant slug"
/>
<button onClick={handleSearch}>Search</button>
{tenant && (
<div>
<h3>{tenant.name}</h3>
<p>ID: {tenant.id}</p>
</div>
)}
</div>
);
}
```
### Validate Slug in Real-Time
```javascript
import React, { useState, useEffect } from 'react';
import TenantService from '../../services/API/TenantService';
function SlugInput({ value, onChange }) {
const [validation, setValidation] = useState({ valid: true, error: null });
useEffect(() => {
if (value) {
const result = TenantService.validateSlug(value);
setValidation(result);
}
}, [value]);
return (
<div>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value.toLowerCase())}
className={validation.valid ? '' : 'error'}
/>
{!validation.valid && (
<span className="error-message">{validation.error}</span>
)}
{validation.valid && value && (
<span className="success-message">✓ Valid slug</span>
)}
</div>
);
}
```
## Testing the Tenant API
### 1. Prerequisites
- Backend running at `http://localhost:8000`
- Frontend running at `http://localhost:5173`
- User logged in (valid JWT token)
### 2. Test Create Tenant
```javascript
// In browser console after login
import TenantService from './services/API/TenantService';
// Generate slug from name
const slug = TenantService.generateSlug("My New Company");
console.log("Generated slug:", slug); // "my-new-company"
// Validate before creating
const validation = TenantService.validateSlug(slug);
console.log("Slug valid:", validation.valid); // true
// Create tenant
const tenant = await TenantService.createTenant({
name: "My New Company",
slug: slug
});
console.log("Created tenant:", tenant);
```
### 3. Test Get Tenant by ID
```javascript
// Using tenant ID from creation
const tenant = await TenantService.getTenantById("850e8400-...");
console.log("Tenant by ID:", tenant);
```
### 4. Test Get Tenant by Slug
```javascript
const tenant = await TenantService.getTenantBySlug("my-new-company");
console.log("Tenant by slug:", tenant);
```
### 5. Test with curl
```bash
# 1. Login and get access token
ACCESS_TOKEN=$(curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "SecurePass123!"
}' | jq -r '.access_token')
# 2. Create tenant
curl -X POST http://localhost:8000/api/v1/tenants \
-H "Content-Type: application/json" \
-H "Authorization: JWT $ACCESS_TOKEN" \
-d '{
"name": "Test Company",
"slug": "test-company"
}' | jq
# 3. Get tenant by ID (use ID from creation response)
TENANT_ID="850e8400-e29b-41d4-a716-446655440000"
curl -X GET "http://localhost:8000/api/v1/tenants/$TENANT_ID" \
-H "Authorization: JWT $ACCESS_TOKEN" | jq
# 4. Get tenant by slug
curl -X GET "http://localhost:8000/api/v1/tenants/slug/test-company" \
-H "Authorization: JWT $ACCESS_TOKEN" | jq
# 5. Test slug conflict (should fail with 409)
curl -X POST http://localhost:8000/api/v1/tenants \
-H "Content-Type: application/json" \
-H "Authorization: JWT $ACCESS_TOKEN" \
-d '{
"name": "Another Company",
"slug": "test-company"
}' | jq
# Expected: 409 Conflict
```
### 6. Test Slug Generation
```javascript
// Test various slug generation scenarios
const tests = [
{ input: "TechStart Inc", expected: "techstart-inc" },
{ input: "My Company!", expected: "my-company" },
{ input: " Spaces Everywhere ", expected: "spaces-everywhere" },
{ input: "Multiple---Hyphens", expected: "multiple-hyphens" },
{ input: "123 Numbers", expected: "123-numbers" },
];
tests.forEach(({ input, expected }) => {
const result = TenantService.generateSlug(input);
console.log(`Input: "${input}"`);
console.log(`Expected: "${expected}"`);
console.log(`Got: "${result}"`);
console.log(`✓ Pass: ${result === expected}\n`);
});
```
## Integration with AuthManager
The tenant created during registration is stored in AuthManager:
```javascript
import { useAuth } from './services/Services';
import TenantService from './services/API/TenantService';
const { authManager } = useAuth();
// Get stored tenant from AuthManager
const storedTenant = authManager.getTenant();
console.log("Stored tenant:", storedTenant);
// { id: "...", name: "...", slug: "..." }
// Fetch fresh tenant data from API
const freshTenant = await TenantService.getTenantById(storedTenant.id);
console.log("Fresh tenant:", freshTenant);
// Compare
if (storedTenant.name !== freshTenant.name) {
console.warn("Tenant data has changed");
}
```
## Multi-Tenant Context
Tenants provide isolation for multi-tenant applications:
```javascript
// Get current user's tenant
import MeService from './services/API/MeService';
import TenantService from './services/API/TenantService';
const profile = await MeService.getMe();
const tenantId = profile.tenantId;
// Get full tenant details
const tenant = await TenantService.getTenantById(tenantId);
console.log("Current tenant:", tenant.name);
// Use tenant context for operations
console.log("Working in tenant:", tenant.slug);
```
## Use Cases
### 1. Organization Switcher
Allow users to switch between tenants (if they belong to multiple).
### 2. Tenant Profile Display
Show tenant information in dashboard header or settings.
### 3. Tenant Creation Wizard
Guide users through creating a new tenant/organization.
### 4. Tenant Settings Page
Display and edit tenant information.
### 5. Multi-Tenant Routing
Use tenant slug in URLs (e.g., `/t/acme-corp/dashboard`).
## Troubleshooting
### "Tenant slug already exists" error
**Possible causes:**
1. Slug is already taken by another tenant
2. User trying to create duplicate tenant
**Solution:**
- Try a different slug
- Add numbers or suffix to make it unique
- Use the slug validation before submitting
### "Tenant not found" error
**Possible causes:**
1. Tenant ID is incorrect
2. Tenant was deleted
3. User doesn't have access to tenant
**Solution:**
- Verify tenant ID is correct UUID format
- Check if tenant still exists
- Verify user has proper access rights
### Slug validation fails unexpectedly
**Possible causes:**
1. Special characters in slug
2. Uppercase letters
3. Leading/trailing spaces or hyphens
**Solution:**
- Use `generateSlug()` to auto-generate valid slug
- Use `validateSlug()` before submitting
- Convert to lowercase and trim whitespace
## Related Files
### Created Files
```
src/services/API/TenantService.js
docs/TENANT_API.md
```
### Backend Reference Files
```
cloud/maplepress-backend/docs/API.md (lines 416-558)
cloud/maplepress-backend/internal/usecase/tenant/
cloud/maplepress-backend/internal/repository/tenant/
```
## Related Documentation
- [REGISTRATION_API.md](./REGISTRATION_API.md) - Initial tenant creation during registration
- [ME_API.md](./ME_API.md) - User profile includes tenant ID
- [FRONTEND_ARCHITECTURE.md](./FRONTEND_ARCHITECTURE.md) - Architecture overview
- [README.md](./README.md) - Documentation index
- [Backend API Documentation](../../../cloud/maplepress-backend/docs/API.md) - Complete API reference
## Summary
The Tenant API implementation provides:
1. **Tenant Creation**: Create new organizations with validation
2. **Tenant Retrieval**: Get tenant by ID or slug
3. **Slug Generation**: Auto-generate URL-friendly slugs
4. **Validation Helpers**: Client-side validation before API calls
5. **Error Handling**: Clear error messages and graceful failures
6. **Multi-Tenant Support**: Foundation for multi-tenant architecture
This is essential for managing organizations in a multi-tenant SaaS application.
---
**Last Updated**: October 30, 2024
**Frontend Version**: 0.0.0
**Documentation Version**: 1.0.0

View file

@ -0,0 +1,557 @@
# User API Implementation (User Management)
This document describes the implementation of the User Management API endpoints for the MaplePress frontend, integrated with the MaplePress backend API.
## Overview
The User API endpoints manage users within a tenant (organization). All operations require both JWT authentication and tenant context via the `X-Tenant-ID` header. Users are scoped to tenants, providing data isolation in the multi-tenant architecture.
## Backend API Endpoints
### Create User
**Endpoint**: `POST /api/v1/users`
**Authentication**: Required (JWT token)
**Tenant Context**: Required (`X-Tenant-ID` header)
### Get User by ID
**Endpoint**: `GET /api/v1/users/{id}`
**Authentication**: Required (JWT token)
**Tenant Context**: Required (`X-Tenant-ID` header)
**Documentation**: `/cloud/maplepress-backend/docs/API.md` (lines 560-660)
## Request/Response Structures
### Create User Request
```json
{
"email": "jane@techstart.com",
"name": "Jane Smith"
}
```
**Headers Required:**
- `Content-Type: application/json`
- `Authorization: JWT {access_token}`
- `X-Tenant-ID: {tenant_id}` (required in development mode)
### User Response
```json
{
"id": "950e8400-e29b-41d4-a716-446655440000",
"email": "jane@techstart.com",
"name": "Jane Smith",
"created_at": "2024-10-24T00:00:00Z",
"updated_at": "2024-10-24T00:00:00Z"
}
```
## Frontend Implementation
### UserService (`src/services/API/UserService.js`)
Handles all user management operations with tenant context.
**Key Features:**
- Create new users within a tenant
- Retrieve user by ID within tenant context
- Client-side validation (email and name)
- Tenant context support via X-Tenant-ID header
- Response transformation (snake_case to camelCase)
- User-friendly error message mapping
**Methods:**
#### `createUser(userData, tenantId)`
Create a new user within a tenant.
```javascript
import UserService from './services/API/UserService';
const user = await UserService.createUser({
email: "jane@techstart.com",
name: "Jane Smith"
}, "850e8400-..."); // tenant ID
console.log(user);
// Output:
// {
// id: "950e8400-...",
// email: "jane@techstart.com",
// name: "Jane Smith",
// createdAt: Date object
// }
```
**Parameters:**
- `userData.email` (string, required): User's email address
- `userData.name` (string, required): User's full name
- `tenantId` (string, optional): Tenant ID for X-Tenant-ID header
**Returns:**
```javascript
{
id: string, // User ID (UUID)
email: string, // User email
name: string, // User name
createdAt: Date // Creation timestamp
}
```
**Throws:**
- "User data is required" - If userData is missing
- "User email is required" - If email is missing
- "User name is required" - If name is missing
- "Invalid email format" - If email format is invalid
- "User email already exists in this tenant." - Email conflict (409)
- "Tenant context required. Please provide X-Tenant-ID header." - Missing tenant context
- "Authentication required. Please log in to continue." - Missing/invalid token
#### `getUserById(userId, tenantId)`
Retrieve user information by ID within tenant context.
```javascript
const user = await UserService.getUserById("950e8400-...", "850e8400-...");
console.log(user.name); // "Jane Smith"
```
**Parameters:**
- `userId` (string, required): User ID (UUID format)
- `tenantId` (string, optional): Tenant ID for X-Tenant-ID header
**Returns:**
```javascript
{
id: string,
email: string,
name: string,
createdAt: Date,
updatedAt: Date
}
```
**Throws:**
- "User ID is required" - If ID is missing
- "Invalid user ID format" - If ID is not a valid UUID
- "User not found in this tenant." - If user doesn't exist in tenant (404)
- "Tenant context required." - Missing tenant context
- "Authentication required." - Missing/invalid token
#### `validateEmail(email)`
Validate an email address format.
```javascript
const result = UserService.validateEmail("jane@example.com");
console.log(result); // { valid: true, error: null }
const invalid = UserService.validateEmail("invalid-email");
console.log(invalid); // { valid: false, error: "Invalid email format" }
```
**Returns:** `{ valid: boolean, error: string|null }`
**Validation Rules:**
- Required (non-empty)
- Valid email format (`user@domain.com`)
- Maximum 255 characters
#### `validateName(name)`
Validate a user name.
```javascript
const result = UserService.validateName("Jane Smith");
console.log(result); // { valid: true, error: null }
```
**Returns:** `{ valid: boolean, error: string|null }`
**Validation Rules:**
- Required (non-empty)
- Length: 2-100 characters
#### `isValidUUID(uuid)`
Check if a string is a valid UUID.
```javascript
const isValid = UserService.isValidUUID("950e8400-e29b-41d4-a716-446655440000");
console.log(isValid); // true
```
**Returns:** `boolean`
## Important: Tenant Context
All user operations require tenant context via the `X-Tenant-ID` header. This header can be provided in two ways:
### Option 1: Explicit Tenant ID
Pass tenant ID to each method call:
```javascript
const tenantId = "850e8400-...";
const user = await UserService.createUser(userData, tenantId);
```
### Option 2: Automatic from Current User (Recommended)
Enhance ApiClient to automatically add X-Tenant-ID from the current user's tenant:
```javascript
// In ApiClient.js
import { authManager } from './Services';
// Add tenant header automatically
const tenant = authManager.getTenant();
if (tenant && tenant.id) {
requestHeaders["X-Tenant-ID"] = tenant.id;
}
```
Then use without explicit tenant ID:
```javascript
// Tenant ID automatically added from current user
const user = await UserService.createUser(userData);
```
## Data Flow
### Create User Flow
```
User provides email and name
UserService.createUser()
Validate email and name (client-side)
ApiClient.post() with JWT token + X-Tenant-ID
Token automatically refreshed if needed
POST /api/v1/users with tenant context
Backend validates email uniqueness within tenant
Backend creates user in database (scoped to tenant)
Backend returns user data
UserService transforms response
Component receives user data
```
### Get User Flow
```
Component needs user data
UserService.getUserById()
Validate UUID format
ApiClient.get() with JWT token + X-Tenant-ID
Token automatically refreshed if needed
GET /api/v1/users/{id} with tenant context
Backend retrieves user from database (tenant-scoped)
Backend returns user data
UserService transforms response
Component receives user data
```
## Error Handling
### Error Types
| Error Condition | Response | Frontend Behavior |
|----------------|----------|-------------------|
| Missing authentication | 401 Unauthorized | "Authentication required." |
| Missing tenant context | 400 Bad Request | "Tenant context required." |
| Invalid user data | 400 Bad Request | Specific validation error |
| Email already exists | 409 Conflict | "User email already exists in this tenant." |
| User not found | 404 Not Found | "User not found in this tenant." |
| Server error | 500 Internal Server Error | Generic error message |
## Usage Examples
### Create a New User
```javascript
import React, { useState } from 'react';
import UserService from '../../services/API/UserService';
import MeService from '../../services/API/MeService';
function CreateUserForm() {
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
// Validate before sending
const emailValidation = UserService.validateEmail(email);
if (!emailValidation.valid) {
setError(emailValidation.error);
setLoading(false);
return;
}
const nameValidation = UserService.validateName(name);
if (!nameValidation.valid) {
setError(nameValidation.error);
setLoading(false);
return;
}
try {
// Get current user's tenant ID
const profile = await MeService.getMe();
const tenantId = profile.tenantId;
// Create user in current tenant
const user = await UserService.createUser(
{ email, name },
tenantId
);
console.log("User created:", user);
// Reset form or redirect
setEmail('');
setName('');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
maxLength={255}
/>
</div>
<div>
<label>Name:</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
maxLength={100}
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create User'}
</button>
</form>
);
}
export default CreateUserForm;
```
### Display User Profile
```javascript
import React, { useEffect, useState } from 'react';
import UserService from '../../services/API/UserService';
import MeService from '../../services/API/MeService';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchUser = async () => {
try {
// Get tenant context
const profile = await MeService.getMe();
// Get user data
const data = await UserService.getUserById(userId, profile.tenantId);
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>Loading user...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>ID: {user.id}</p>
<p>Created: {user.createdAt.toLocaleDateString()}</p>
<p>Updated: {user.updatedAt.toLocaleDateString()}</p>
</div>
);
}
export default UserProfile;
```
### List Users in Tenant (Helper)
```javascript
// Note: There's no list endpoint in the API yet
// This is a pattern for when it's added
async function listUsersInTenant(tenantId) {
// This would call GET /api/v1/users with tenant context
// For now, you can only get users by ID
console.log("List endpoint not yet available");
}
```
## Testing
### Test Create User
```javascript
// In browser console after login
import UserService from './services/API/UserService';
import MeService from './services/API/MeService';
// Get tenant context
const profile = await MeService.getMe();
const tenantId = profile.tenantId;
// Validate email
const validation = UserService.validateEmail("test@example.com");
console.log("Email valid:", validation.valid);
// Create user
const user = await UserService.createUser({
email: "test@example.com",
name: "Test User"
}, tenantId);
console.log("Created user:", user);
```
### Test Get User
```javascript
// Using user ID from creation
const user = await UserService.getUserById(user.id, tenantId);
console.log("Retrieved user:", user);
```
### Test with curl
```bash
# 1. Login and get access token
ACCESS_TOKEN=$(curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "SecurePass123!"
}' | jq -r '.access_token')
# Get tenant ID from login response
TENANT_ID=$(curl -X GET http://localhost:8000/api/v1/me \
-H "Authorization: JWT $ACCESS_TOKEN" | jq -r '.tenant_id')
# 2. Create user
curl -X POST http://localhost:8000/api/v1/users \
-H "Content-Type: application/json" \
-H "Authorization: JWT $ACCESS_TOKEN" \
-H "X-Tenant-ID: $TENANT_ID" \
-d '{
"email": "newuser@example.com",
"name": "New User"
}' | jq
# 3. Get user by ID (use ID from creation response)
USER_ID="950e8400-..."
curl -X GET "http://localhost:8000/api/v1/users/$USER_ID" \
-H "Authorization: JWT $ACCESS_TOKEN" \
-H "X-Tenant-ID: $TENANT_ID" | jq
```
## Multi-Tenant Isolation
Users are scoped to tenants for data isolation:
```javascript
// Users in tenant A cannot see users in tenant B
const tenantA = "850e8400-...";
const tenantB = "950e8400-...";
// Create user in tenant A
const userA = await UserService.createUser(userData, tenantA);
// Try to get user A from tenant B context (will fail - not found)
try {
const user = await UserService.getUserById(userA.id, tenantB);
} catch (error) {
console.log("Cannot access user from different tenant");
}
```
## Related Files
### Created Files
```
src/services/API/UserService.js
docs/USER_API.md
```
### Backend Reference Files
```
cloud/maplepress-backend/docs/API.md (lines 560-660)
```
## Related Documentation
- [TENANT_API.md](./TENANT_API.md) - Tenant management (parent context)
- [ME_API.md](./ME_API.md) - Current user profile includes tenant ID
- [REGISTRATION_API.md](./REGISTRATION_API.md) - Initial user creation
- [FRONTEND_ARCHITECTURE.md](./FRONTEND_ARCHITECTURE.md) - Architecture overview
- [README.md](./README.md) - Documentation index
## Summary
The User API implementation provides:
1. **User Creation**: Create users within tenant context
2. **User Retrieval**: Get user by ID with tenant isolation
3. **Validation Helpers**: Client-side validation before API calls
4. **Tenant Context**: Multi-tenant data isolation
5. **Error Handling**: Clear error messages and graceful failures
Essential for managing team members within organizations (tenants) in a multi-tenant SaaS application.
---
**Last Updated**: October 30, 2024
**Frontend Version**: 0.0.0
**Documentation Version**: 1.0.0

View file

@ -0,0 +1,224 @@
# MaplePress Frontend Architecture
This document describes the architecture and organization of the MaplePress React frontend application.
## Overview
The MaplePress frontend is built with React 19, Vite, and TailwindCSS, following a clean separation of concerns with a service layer pattern similar to the maplefile-frontend architecture.
## Technology Stack
- **React 19** - UI framework
- **Vite** - Build tool and dev server
- **TailwindCSS 4** - Utility-first CSS framework
- **react-router** - Client-side routing
## Directory Structure
```
src/
├── services/ # Service layer (business logic)
│ ├── Manager/ # Manager services (orchestration)
│ │ └── AuthManager.js
│ ├── API/ # API client services
│ │ └── ApiClient.js
│ └── Services.jsx # Service registry and DI container
├── pages/ # Page components
│ ├── Home/ # Home/landing page
│ │ └── IndexPage.jsx
│ ├── Auth/ # Authentication pages
│ │ ├── Login.jsx
│ │ └── Register.jsx
│ └── Dashboard/ # Dashboard pages
│ └── Dashboard.jsx
├── hooks/ # Custom React hooks (future)
├── App.jsx # Main app component with routing
└── main.jsx # Application entry point
```
## Architecture Layers
### 1. Service Layer (`src/services/`)
The service layer provides a clean separation between UI components and business logic, following dependency injection principles.
**Key Files:**
- `Services.jsx` - Central service registry with React Context
- `Manager/AuthManager.js` - Authentication manager
- `API/ApiClient.js` - HTTP client for backend API
**Service Pattern:**
```javascript
// Services are created with dependency injection
const authManager = new AuthManager();
const apiClient = ApiClient;
// Services are provided via React Context
<ServiceProvider>
{/* App components */}
</ServiceProvider>
// Components access services via hooks
const { authManager } = useAuth();
```
### 2. Pages (`src/pages/`)
Page components represent full-page views. Each page is organized by feature:
- **Home/** - Landing page with navigation to auth
- **Auth/** - Login and registration pages
- **Dashboard/** - User dashboard and management
### 3. Routing (`src/App.jsx`)
Centralized routing configuration using react-router:
```javascript
<Routes>
{/* Public routes */}
<Route path="/" element={<IndexPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Protected routes */}
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
```
## Service Layer Details
### AuthManager
Manages authentication state and operations:
- `initialize()` - Initialize auth manager
- `isAuthenticated()` - Check if user is logged in
- `login(email, password)` - User login
- `register(email, password, name)` - User registration
- `logout()` - User logout
- `getAccessToken()` - Get current access token
- `getUser()` - Get current user data
### ApiClient
Handles HTTP communication with the backend:
- `get(endpoint, options)` - GET request
- `post(endpoint, body, options)` - POST request
- `put(endpoint, body, options)` - PUT request
- `patch(endpoint, body, options)` - PATCH request
- `delete(endpoint, options)` - DELETE request
Automatically handles:
- Authorization headers
- JSON serialization
- Error handling
### Service Hooks
Custom hooks provide easy access to services:
```javascript
// Get all services
const services = useServices();
// Get auth services
const { authManager } = useAuth();
// Get API client
const { apiClient } = useApi();
```
## Page Components
### IndexPage (Home)
- Landing page with MaplePress branding
- Navigation to login/register
- Feature highlights
- Responsive design with TailwindCSS
### Login
- Email/password authentication form
- Form validation
- Error handling
- Navigation to register and home
### Register
- User registration form
- Password confirmation
- Password strength validation
- Navigation to login and home
### Dashboard
- Protected route (requires authentication)
- User welcome section
- Stats display (sites, indexes, API requests)
- Quick action buttons
- Getting started guide
## State Management
Currently using React Context for service layer dependency injection. Future expansions may include:
- Local component state with `useState`
- Form state management
- Potential integration with Redux/Zustand if needed
## Styling
Using TailwindCSS 4 utility classes for styling:
- Responsive design with mobile-first approach
- Consistent color scheme (indigo/blue palette)
- Shadow and border utilities for depth
- Hover states and transitions
## Development Workflow
1. **Start dev server**: `npm run dev`
2. **Build for production**: `npm run build`
3. **Lint code**: `npm run lint`
4. **Preview build**: `npm run preview`
## API Integration
The ApiClient is configured to communicate with the MaplePress backend:
- Base URL: `VITE_API_BASE_URL` environment variable (default: `http://localhost:8000`)
- Authentication: Bearer token in Authorization header
- Content-Type: `application/json`
## Future Enhancements
Current implementation provides stubs for:
- [ ] Actual backend API integration
- [ ] Token storage and refresh logic
- [ ] Protected route guards
- [ ] User profile management
- [ ] API key management
- [ ] Site management
- [ ] Search index configuration
- [ ] Analytics dashboard
## Testing
Testing structure to be implemented:
- Unit tests for services
- Component tests for pages
- Integration tests for workflows
- E2E tests for critical paths
## Contributing
When adding new features:
1. Create services in `src/services/` for business logic
2. Create pages in `src/pages/` organized by feature
3. Add routes in `App.jsx`
4. Use service hooks to access backend functionality
5. Follow TailwindCSS utility patterns for styling
6. Keep components focused and single-responsibility
## References
This architecture is based on the maplefile-frontend pattern, adapted for MaplePress requirements.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,367 @@
# MaplePress Frontend Documentation
This directory contains comprehensive documentation for the MaplePress React frontend application.
## Documentation Index
### Architecture & Design
- **[FRONTEND_ARCHITECTURE.md](./FRONTEND_ARCHITECTURE.md)** - Complete architecture guide
- Three-layer service architecture (API → Manager → Storage)
- Dependency injection system
- Component patterns
- Authentication & authorization
- State management
- Best practices and conventions
- **[ARCHITECTURE_SIMPLE.md](./ARCHITECTURE_SIMPLE.md)** - Quick reference guide
- Project structure overview
- Key concepts
- Technology stack
- Development workflow
### API Integration Guides
All API integration guides are located in the [API](./API/) subdirectory.
- **[HEALTH_API.md](./API/HEALTH_API.md)** - Health Check
- Backend service health verification
- Backend API integration (`GET /health`)
- HealthService implementation
- Monitoring and availability checks
- Application startup verification
- Load balancer health probes
- **[REGISTRATION_API.md](./API/REGISTRATION_API.md)** - User Registration
- Complete registration flow implementation
- Backend API integration (`POST /api/v1/register`)
- RegisterService implementation
- Form validation and error handling
- Token storage and session management
- Testing procedures
- **[LOGIN_API.md](./API/LOGIN_API.md)** - User Login
- Complete login flow implementation
- Backend API integration (`POST /api/v1/login`)
- LoginService implementation
- Rate limiting and account lockout
- Token management
- Error handling and recovery
- **[REFRESH_TOKEN_API.md](./API/REFRESH_TOKEN_API.md)** - Token Refresh
- Complete token refresh implementation
- Backend API integration (`POST /api/v1/refresh`)
- RefreshTokenService implementation
- Automatic token refresh (proactive and reactive)
- Token rotation and security
- Session persistence and validation
- **[HELLO_API.md](./API/HELLO_API.md)** - Hello (Authenticated Endpoint)
- Simple authenticated endpoint for testing
- Backend API integration (`POST /api/v1/hello`)
- HelloService implementation
- Input validation and XSS prevention
- Authentication verification
- Use cases for testing tokens
- **[ME_API.md](./API/ME_API.md)** - User Profile (Me Endpoint)
- Get authenticated user's profile
- Backend API integration (`GET /api/v1/me`)
- MeService implementation
- Role-based access control helpers
- Tenant context verification
- Profile display and validation
- **[TENANT_API.md](./API/TENANT_API.md)** - Tenant Management
- Create and manage tenants (organizations)
- Backend API integration (`POST /api/v1/tenants`, `GET /api/v1/tenants/{id}`, `GET /api/v1/tenants/slug/{slug}`)
- TenantService implementation
- Slug generation and validation helpers
- Multi-tenant architecture support
- Organization management
- **[USER_API.md](./API/USER_API.md)** - User Management
- Create and manage users within tenants
- Backend API integration (`POST /api/v1/users`, `GET /api/v1/users/{id}`)
- UserService implementation
- Tenant context and multi-tenant isolation
- Email and name validation helpers
- Team member management
- **[SITE_API.md](./API/SITE_API.md)** - WordPress Site Management
- Create and manage WordPress sites
- Backend API integration (`POST /api/v1/sites`, `GET /api/v1/sites`, `GET /api/v1/sites/{id}`, `DELETE /api/v1/sites/{id}`, `POST /api/v1/sites/{id}/rotate-api-key`)
- SiteService implementation
- API key management (shown only once, rotation with grace period)
- Site usage statistics and storage tracking
- Pagination support for large site lists
- Hard delete with immediate key invalidation
- **[ADMIN_API.md](./API/ADMIN_API.md)** - Admin Account Management
- Check account lock status and unlock accounts
- Backend API integration (`GET /api/v1/admin/account-status`, `POST /api/v1/admin/unlock-account`)
- AdminService implementation
- CWE-307 protection (excessive authentication attempts)
- Security event logging for audit trail
- Admin role enforcement
- Account lockout management
## Getting Started
### For Developers
If you're new to the project, start here:
1. **Read [FRONTEND_ARCHITECTURE.md](./FRONTEND_ARCHITECTURE.md)** - Understand the overall architecture
2. **Check [HEALTH_API.md](./API/HEALTH_API.md)** - Verify backend connectivity and health
3. **Review [REGISTRATION_API.md](./API/REGISTRATION_API.md)** - See a complete feature implementation
4. **Check [LOGIN_API.md](./API/LOGIN_API.md)** - Understand authentication flow
5. **Read [REFRESH_TOKEN_API.md](./API/REFRESH_TOKEN_API.md)** - Learn about automatic session maintenance
6. **Try [HELLO_API.md](./API/HELLO_API.md)** - Test authentication with a simple endpoint
7. **Use [ME_API.md](./API/ME_API.md)** - Get user profile and implement role-based access
8. **Explore [TENANT_API.md](./API/TENANT_API.md)** - Manage tenants for multi-tenant architecture
9. **Implement [SITE_API.md](./API/SITE_API.md)** - Manage WordPress sites with API key management
10. **Admin [ADMIN_API.md](./API/ADMIN_API.md)** - Admin operations for account lockout management
### For Contributors
When adding new features:
1. Follow the three-layer service pattern (API → Manager → Storage)
2. Use dependency injection via Services.jsx
3. Create comprehensive documentation similar to existing API guides
4. Include testing procedures and troubleshooting
## Project Structure
```
maplepress-frontend/
├── docs/ # This directory
│ ├── README.md # This file
│ ├── FRONTEND_ARCHITECTURE.md # Complete architecture guide
│ ├── ARCHITECTURE_SIMPLE.md # Quick reference
│ ├── ACCESS_REFRESH_TOKEN_IMPLEMENTATION.md # Token refresh guide
│ ├── TOKEN_REFRESH_ANALYSIS.md # Token refresh analysis
│ └── API/ # API integration guides
│ ├── REGISTRATION_API.md
│ ├── LOGIN_API.md
│ ├── REFRESH_TOKEN_API.md
│ ├── HELLO_API.md
│ ├── ME_API.md
│ ├── TENANT_API.md
│ ├── USER_API.md
│ ├── SITE_API.md
│ └── ADMIN_API.md
├── src/
│ ├── services/ # Service layer
│ │ ├── API/ # API services (HTTP)
│ │ ├── Manager/ # Manager services (orchestration)
│ │ └── Services.jsx # Service registry + DI
│ │
│ ├── pages/ # Page components
│ │ ├── Home/ # Landing page
│ │ ├── Auth/ # Login/Register
│ │ └── Dashboard/ # User dashboard
│ │
│ ├── hooks/ # Custom React hooks
│ ├── App.jsx # Main app with routing
│ └── main.jsx # Entry point
├── README.md # Project overview
└── package.json # Dependencies
```
## Architecture Overview
### Three-Layer Service Pattern
```
┌─────────────────────────────────────────┐
│ React Components │
│ (UI Layer) │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ Manager Layer (Orchestration) │
│ - AuthManager │
│ - Business logic │
│ - State management │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ API Layer (HTTP Communication) │
│ - RegisterService │
│ - LoginService │
│ - ApiClient │
└─────────────────┬───────────────────────┘
┌─────────────────────────────────────────┐
│ Backend API │
│ POST /api/v1/register │
│ POST /api/v1/login │
└─────────────────────────────────────────┘
```
### Service Registration (Dependency Injection)
All services are registered in `Services.jsx`:
```javascript
// Create services with dependencies
const authManager = new AuthManager();
const apiClient = ApiClient;
// Register in context
const services = {
authManager,
apiClient,
};
// Use in components
const { authManager } = useAuth();
```
## Implementation Status
### ✅ Completed Features
- **Authentication System**
- User registration with full form validation
- User login with rate limiting support
- Token storage and management
- Session persistence
- Token expiry detection
- **Service Layer**
- Dependency injection system
- AuthManager (token storage, session management, automatic refresh)
- HealthService (backend health checks and monitoring)
- RegisterService (registration API)
- LoginService (login API)
- RefreshTokenService (token refresh API)
- HelloService (authenticated test endpoint)
- MeService (user profile and role checking)
- TenantService (tenant/organization management)
- UserService (user management with tenant context)
- SiteService (WordPress site management with API key handling)
- AdminService (admin account management and lockout handling)
- ApiClient (HTTP client with automatic token refresh)
- **User Interface**
- Home/landing page
- Registration page (complete)
- Login page (complete)
- Dashboard page (stub)
### 🚧 In Progress / Planned
- Protected route guards
- Logout API integration
- Password recovery flow
- User profile management
- Site management UI
- API key management
- Search configuration
## Testing
### Running Tests
```bash
# Build test
npm run build
# Development server
npm run dev
# Linting
npm run lint
```
### Manual Testing
See individual API guides for testing procedures:
- [Registration Testing](./API/REGISTRATION_API.md#testing-the-registration-flow)
- [Login Testing](./API/LOGIN_API.md#testing-the-login-flow)
## Backend Integration
### API Endpoints
The frontend integrates with these backend endpoints:
- `GET /health` - Backend health check (no auth)
- `POST /api/v1/register` - User registration
- `POST /api/v1/login` - User login
- `POST /api/v1/refresh` - Token refresh
- `POST /api/v1/hello` - Authenticated test endpoint
- `GET /api/v1/me` - Get user profile
- `POST /api/v1/tenants` - Create tenant
- `GET /api/v1/tenants/{id}` - Get tenant by ID
- `GET /api/v1/tenants/slug/{slug}` - Get tenant by slug
- `POST /api/v1/users` - Create user (requires tenant context)
- `GET /api/v1/users/{id}` - Get user by ID (requires tenant context)
- `POST /api/v1/sites` - Create WordPress site
- `GET /api/v1/sites` - List WordPress sites (paginated)
- `GET /api/v1/sites/{id}` - Get WordPress site by ID
- `DELETE /api/v1/sites/{id}` - Delete WordPress site
- `POST /api/v1/sites/{id}/rotate-api-key` - Rotate site API key
- `GET /api/v1/admin/account-status` - Check account lock status (admin)
- `POST /api/v1/admin/unlock-account` - Unlock locked account (admin)
### Backend Documentation
For complete backend API documentation, see:
- [Backend API Documentation](../../../cloud/maplepress-backend/docs/API.md)
## Contributing
When adding new documentation:
1. **Create detailed guides** for new features (see existing API docs)
2. **Update this README** to include new documentation
3. **Follow existing patterns** for consistency
4. **Include examples** and testing procedures
5. **Add troubleshooting** sections
### Documentation Standards
- Use clear, descriptive headings
- Include code examples
- Provide testing procedures
- Add troubleshooting sections
- Keep table of contents updated
- Use diagrams where helpful
## Support & Resources
### Internal Resources
- [Main README](../README.md) - Project overview and setup
- [Backend API Docs](../../../cloud/mapleopentech-backend/docs/API.md) - Complete API reference
- [CLAUDE.md](../../../CLAUDE.md) - Repository structure and conventions
### External Resources
- [React 19 Documentation](https://react.dev)
- [Vite Documentation](https://vite.dev)
- [TailwindCSS Documentation](https://tailwindcss.com)
- [React Router Documentation](https://reactrouter.com)
## Questions & Issues
For questions or issues:
1. Check the relevant documentation first
2. Review troubleshooting sections in API guides
3. Check backend logs for API errors
4. Create an issue with detailed information
---
**Last Updated**: October 30, 2024
**Frontend Version**: 0.0.0
**Documentation Version**: 1.0.0

View file

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MaplePress</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3397
web/maplepress-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
{
"name": "maplepress-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.5",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"vite": "^7.1.7"
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
@import "tailwindcss";

View file

@ -0,0 +1,50 @@
// File: src/App.jsx
import React from "react";
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
import { ServiceProvider } from "./services/Services";
// Pages
import IndexPage from "./pages/Home/IndexPage";
import Login from "./pages/Auth/Login";
import Register from "./pages/Auth/Register";
import Dashboard from "./pages/Dashboard/Dashboard";
import AddSite from "./pages/Sites/AddSite";
import AddSiteSuccess from "./pages/Sites/AddSiteSuccess";
import SiteDetail from "./pages/Sites/SiteDetail";
import DeleteSite from "./pages/Sites/DeleteSite";
import RotateApiKey from "./pages/Sites/RotateApiKey";
/**
* App - Main application component
*
* Sets up routing and service provider
*/
function App() {
return (
<ServiceProvider>
<Router>
<div className="min-h-screen">
<Routes>
{/* Public routes */}
<Route path="/" element={<IndexPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Protected routes */}
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/sites/add" element={<AddSite />} />
<Route path="/sites/add-success" element={<AddSiteSuccess />} />
<Route path="/sites/:id" element={<SiteDetail />} />
<Route path="/sites/:id/delete" element={<DeleteSite />} />
<Route path="/sites/:id/rotate-key" element={<RotateApiKey />} />
{/* Redirect unknown routes to home */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</div>
</Router>
</ServiceProvider>
);
}
export default App;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,28 @@
/* Tailwind CSS 4 with Vite Plugin */
@import "tailwindcss";
/* Minimal base styles - Let Tailwind handle resets */
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}

View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,183 @@
// File: src/pages/Auth/Login.jsx
import React, { useState } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "../../services/Services";
/**
* Login - User login page
*
* Complete implementation with MaplePress backend integration
*/
function Login() {
const navigate = useNavigate();
const { authManager } = useAuth();
const [formData, setFormData] = useState({
email: "",
password: "",
});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
setError("");
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
// Login via AuthManager
await authManager.login(formData.email, formData.password);
// Navigate to dashboard on success
navigate("/dashboard");
} catch (err) {
setError(err.message || "Login failed. Please try again.");
} finally {
setLoading(false);
}
};
const handleRegisterClick = () => {
navigate("/register");
};
const handleBackClick = () => {
navigate("/");
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="max-w-md w-full">
{/* Logo/Brand */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 bg-white px-6 py-3 rounded-full shadow-lg mb-4">
<span className="text-3xl">🍁</span>
<h1 className="text-2xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</div>
</div>
{/* Login Card */}
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
{/* Header */}
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Welcome back
</h2>
<p className="text-gray-600">Sign in to your account to continue</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email Field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-2"
>
Email Address
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2
focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="you@example.com"
/>
</div>
{/* Password Field */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-2"
>
Password
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2
focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="••••••••"
/>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white font-semibold rounded-xl
hover:shadow-lg transform hover:-translate-y-0.5 transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
Signing in...
</span>
) : (
"Sign In"
)}
</button>
</form>
{/* Register Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Don't have an account?{" "}
<button
onClick={handleRegisterClick}
className="text-indigo-600 font-semibold hover:text-indigo-700 hover:underline"
>
Create one now
</button>
</p>
</div>
{/* Back to Home Link */}
<div className="mt-4 pt-4 border-t border-gray-100 text-center">
<button
onClick={handleBackClick}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors inline-flex items-center gap-1"
>
<span></span>
<span>Back to Home</span>
</button>
</div>
</div>
</div>
</div>
);
}
export default Login;

View file

@ -0,0 +1,529 @@
// File: src/pages/Auth/Register.jsx
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "../../services/Services";
import RegisterService from "../../services/API/RegisterService";
/**
* Register - User registration page
*
* Complete implementation with all required fields for MaplePress backend
*/
function Register() {
const navigate = useNavigate();
const { authManager } = useAuth();
const [formData, setFormData] = useState({
// User fields
first_name: "",
last_name: "",
email: "",
password: "",
confirmPassword: "",
// Tenant/Organization fields
tenant_name: "",
// Consent fields
agree_terms_of_service: false,
agree_promotions: false,
agree_to_tracking_across_third_party_apps_and_services: false,
});
const [errors, setErrors] = useState({}); // Field-specific errors
const [generalError, setGeneralError] = useState(""); // General error message
const [loading, setLoading] = useState(false);
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
// Clear errors when user types
setErrors((prev) => ({ ...prev, [name]: null }));
setGeneralError("");
};
/**
* Format field name for display in error messages
* Converts snake_case to Title Case
*/
const formatFieldName = (fieldName) => {
const fieldLabels = {
first_name: "First Name",
last_name: "Last Name",
email: "Email",
password: "Password",
confirm_password: "Confirm Password",
confirmPassword: "Confirm Password",
tenant_name: "Organization Name",
agree_terms_of_service: "Terms of Service",
agree_promotions: "Promotions",
agree_to_tracking_across_third_party_apps_and_services: "Third-Party Tracking",
};
return fieldLabels[fieldName] || fieldName;
};
/**
* Parse backend error (RFC 9457 format)
* Backend returns structured validation errors in format:
* {
* type: "about:blank",
* title: "Validation Error",
* status: 400,
* detail: "One or more validation errors occurred",
* errors: {
* email: ["Invalid email format"],
* password: ["Password is required", "Password must be at least 8 characters"]
* }
* }
*/
const parseBackendError = (error) => {
console.log('[Register] Parsing error:', error);
console.log('[Register] error.validationErrors:', error.validationErrors);
console.log('[Register] error.message:', error.message);
const fieldErrors = {};
let generalError = null;
// Map backend field names (snake_case) to frontend field names (camelCase)
const fieldNameMap = {
'confirm_password': 'confirmPassword',
'first_name': 'first_name',
'last_name': 'last_name',
'tenant_name': 'tenant_name',
'agree_terms_of_service': 'agree_terms_of_service',
'agree_promotions': 'agree_promotions',
'agree_to_tracking_across_third_party_apps_and_services': 'agree_to_tracking_across_third_party_apps_and_services',
};
// Check if error has RFC 9457 validation errors structure
if (error.validationErrors && typeof error.validationErrors === 'object') {
console.log('[Register] Found RFC 9457 validation errors');
// Process each field's errors
Object.entries(error.validationErrors).forEach(([backendFieldName, errorMessages]) => {
if (Array.isArray(errorMessages) && errorMessages.length > 0) {
// Map backend field name to frontend field name
const frontendFieldName = fieldNameMap[backendFieldName] || backendFieldName;
// Join multiple error messages for the same field
fieldErrors[frontendFieldName] = errorMessages.join('; ');
console.log(`[Register] Field error: ${backendFieldName} -> ${frontendFieldName} = ${fieldErrors[frontendFieldName]}`);
}
});
// Use the detail as general error if available
if (error.message) {
generalError = error.message;
}
} else {
console.log('[Register] No RFC 9457 errors found, using fallback');
// Fallback for non-RFC 9457 errors or legacy format
generalError = error.message || "An error occurred. Please try again.";
}
console.log('[Register] Parsed result:', { fieldErrors, generalError });
return {
fieldErrors,
generalError
};
};
const handleSubmit = async (e) => {
e.preventDefault();
setErrors({});
setGeneralError("");
setLoading(true);
try {
// Prepare registration data
// All validation (including password matching) is now handled by backend
const registrationData = {
email: formData.email,
password: formData.password,
confirmPassword: formData.confirmPassword,
first_name: formData.first_name,
last_name: formData.last_name,
tenant_name: formData.tenant_name,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
agree_terms_of_service: formData.agree_terms_of_service,
agree_promotions: formData.agree_promotions,
agree_to_tracking_across_third_party_apps_and_services: formData.agree_to_tracking_across_third_party_apps_and_services,
};
// Register via AuthManager
await authManager.register(registrationData);
// Navigate to dashboard on success
navigate("/dashboard");
} catch (err) {
console.log('[Register] Caught error in handleSubmit:', err);
console.log('[Register] Error type:', typeof err);
console.log('[Register] Error properties:', Object.keys(err));
// Parse RFC 9457 error response
const { fieldErrors, generalError } = parseBackendError(err);
console.log('[Register] Setting errors:', fieldErrors);
console.log('[Register] Setting generalError:', generalError);
setErrors(fieldErrors);
if (generalError) {
setGeneralError(generalError);
}
} finally {
setLoading(false);
}
};
const handleLoginClick = () => {
navigate("/login");
};
const handleBackClick = () => {
navigate("/");
};
/**
* Get className for input field with error highlighting
*/
const getInputClassName = (fieldName, baseClassName) => {
const hasError = errors[fieldName];
if (hasError) {
return baseClassName.replace('border-gray-300', 'border-red-500') + ' ring-1 ring-red-500';
}
return baseClassName;
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 flex items-center justify-center p-4 py-12">
<div className="max-w-2xl w-full">
{/* Logo/Brand */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 bg-white px-6 py-3 rounded-full shadow-lg mb-4">
<span className="text-3xl">🍁</span>
<h1 className="text-2xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</div>
</div>
{/* Register Card */}
<div className="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
{/* Header */}
<div className="mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Create your account
</h2>
<p className="text-gray-600">Get started with MaplePress in minutes</p>
</div>
{/* Error Summary Box */}
{(generalError || Object.entries(errors).filter(([_, msg]) => msg).length > 0) && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 text-red-600 text-xl"></div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-800 mb-2">
Please correct the following errors:
</h3>
<ul className="space-y-1 text-sm text-red-600">
{generalError && (
<li> {generalError}</li>
)}
{Object.entries(errors).filter(([_, message]) => message).map(([field, message]) => (
<li key={field}>
<span className="font-medium">{formatFieldName(field)}:</span> {message}
</li>
))}
</ul>
</div>
</div>
</div>
)}
{/* Registration Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Personal Information Section */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">
Personal Information
</h3>
{/* First Name */}
<div>
<label
htmlFor="first_name"
className="block text-sm font-medium text-gray-700 mb-1"
>
First Name *
</label>
<input
type="text"
id="first_name"
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className={getInputClassName("first_name", "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors")}
placeholder="John"
/>
{errors.first_name && (
<p className="mt-1 text-sm text-red-600">{errors.first_name}</p>
)}
</div>
{/* Last Name */}
<div>
<label
htmlFor="last_name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Last Name *
</label>
<input
type="text"
id="last_name"
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className={getInputClassName("last_name", "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors")}
placeholder="Doe"
/>
{errors.last_name && (
<p className="mt-1 text-sm text-red-600">{errors.last_name}</p>
)}
</div>
{/* Email */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email Address *
</label>
<input
type="text"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className={getInputClassName("email", "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors")}
placeholder="you@example.com"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
{/* Password */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password *
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
className={getInputClassName("password", "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors")}
placeholder="••••••••"
/>
{errors.password ? (
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
) : (
<p className="mt-1 text-xs text-gray-500">
Minimum 8 characters
</p>
)}
</div>
{/* Confirm Password */}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
Confirm Password *
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className={getInputClassName("confirmPassword", "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors")}
placeholder="••••••••"
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
)}
</div>
</div>
{/* Organization Information Section */}
<div className="space-y-4 pt-4 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">
Organization Information
</h3>
{/* Organization Name */}
<div>
<label
htmlFor="tenant_name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Organization Name *
</label>
<input
type="text"
id="tenant_name"
name="tenant_name"
value={formData.tenant_name}
onChange={handleInputChange}
className={getInputClassName("tenant_name", "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors")}
placeholder="Acme Corporation"
/>
{errors.tenant_name && (
<p className="mt-1 text-sm text-red-600">{errors.tenant_name}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Your organization's URL will be automatically generated
</p>
</div>
</div>
{/* Terms and Consent Section */}
<div className="space-y-3 pt-4 border-t border-gray-200">
{/* Terms of Service */}
<div>
<div className="flex items-start">
<input
type="checkbox"
id="agree_terms_of_service"
name="agree_terms_of_service"
checked={formData.agree_terms_of_service}
onChange={handleInputChange}
className={`mt-1 h-4 w-4 text-indigo-600 rounded focus:ring-indigo-500 ${errors.agree_terms_of_service ? 'border-red-500' : 'border-gray-300'}`}
/>
<label
htmlFor="agree_terms_of_service"
className="ml-2 text-sm text-gray-700"
>
I agree to the{" "}
<a
href="#"
className="text-indigo-600 hover:text-indigo-700 font-medium"
>
Terms of Service
</a>{" "}
*
</label>
</div>
{errors.agree_terms_of_service && (
<p className="mt-1 ml-6 text-sm text-red-600">{errors.agree_terms_of_service}</p>
)}
</div>
{/* Promotional Emails (Optional) */}
<div className="flex items-start">
<input
type="checkbox"
id="agree_promotions"
name="agree_promotions"
checked={formData.agree_promotions}
onChange={handleInputChange}
className="mt-1 h-4 w-4 text-indigo-600 border-gray-300 rounded
focus:ring-indigo-500"
/>
<label
htmlFor="agree_promotions"
className="ml-2 text-sm text-gray-700"
>
Send me promotional emails and updates (optional)
</label>
</div>
{/* Third-Party Tracking (Optional) */}
<div className="flex items-start">
<input
type="checkbox"
id="agree_to_tracking_across_third_party_apps_and_services"
name="agree_to_tracking_across_third_party_apps_and_services"
checked={formData.agree_to_tracking_across_third_party_apps_and_services}
onChange={handleInputChange}
className="mt-1 h-4 w-4 text-indigo-600 border-gray-300 rounded
focus:ring-indigo-500"
/>
<label
htmlFor="agree_to_tracking_across_third_party_apps_and_services"
className="ml-2 text-sm text-gray-700"
>
Allow tracking across third-party apps and services for analytics (optional)
</label>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white font-semibold rounded-xl
hover:shadow-lg transform hover:-translate-y-0.5 transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none mt-6"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
Creating account...
</span>
) : (
"Create Account"
)}
</button>
</form>
{/* Login Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Already have an account?{" "}
<button
onClick={handleLoginClick}
className="text-indigo-600 font-semibold hover:text-indigo-700 hover:underline"
>
Sign in instead
</button>
</p>
</div>
{/* Back to Home Link */}
<div className="mt-4 pt-4 border-t border-gray-100 text-center">
<button
onClick={handleBackClick}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors inline-flex items-center gap-1"
>
<span></span>
<span>Back to Home</span>
</button>
</div>
</div>
</div>
</div>
);
}
export default Register;

View file

@ -0,0 +1,368 @@
// File: src/pages/Dashboard/Dashboard.jsx
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "../../services/Services";
import SiteService from "../../services/API/SiteService";
/**
* Dashboard - Launch page for managing WordPress sites
*/
function Dashboard() {
const navigate = useNavigate();
const { authManager } = useAuth();
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [sites, setSites] = useState([]);
const [sitesLoading, setSitesLoading] = useState(false);
const [sitesError, setSitesError] = useState(null);
useEffect(() => {
console.log("[Dashboard] 🔒 Checking authentication...");
// Wait for AuthManager to initialize before checking authentication
const checkAuth = async () => {
const isInitialized = authManager.getIsInitialized();
console.log("[Dashboard] 🔧 AuthManager initialization status:", {
isInitialized,
});
if (!isInitialized) {
console.log("[Dashboard] ⏳ Waiting for AuthManager to initialize...");
// Check again in 50ms
setTimeout(checkAuth, 50);
return;
}
// Now safe to check authentication
const isAuth = authManager.isAuthenticated();
console.log("[Dashboard] 🔍 Authentication check result:", {
isAuthenticated: isAuth,
hasAccessToken: !!authManager.getAccessToken(),
hasUser: !!authManager.getUser(),
});
if (!isAuth) {
console.log("[Dashboard] ⚠️ Not authenticated, redirecting to login");
setIsLoading(false);
navigate("/login");
return;
}
// Proactively refresh token if needed BEFORE showing dashboard
try {
console.log("[Dashboard] 🔄 Ensuring token is valid...");
await authManager.ensureValidToken();
console.log("[Dashboard] ✅ Token is valid and ready");
} catch (error) {
console.error("[Dashboard] ❌ Token refresh failed on mount:", error);
// Token refresh failed, redirect to login
setIsLoading(false);
navigate("/login");
return;
}
// Get user data
const userData = authManager.getUser();
console.log("[Dashboard] ✅ User authenticated, loading user data:", {
email: userData?.email,
role: userData?.role,
});
setUser(userData);
setIsLoading(false);
};
// Start the authentication check
checkAuth();
}, [authManager, navigate]);
// Load sites after authentication is complete
useEffect(() => {
if (!user || isLoading) {
return;
}
const loadSites = async () => {
setSitesLoading(true);
setSitesError(null);
console.log("[Dashboard] 📋 Loading sites...");
try {
const response = await SiteService.listSites({ pageSize: 50 });
console.log("[Dashboard] ✅ Sites loaded:", response.sites.length);
setSites(response.sites);
} catch (error) {
console.error("[Dashboard] ❌ Failed to load sites:", error);
setSitesError(error.message || "Failed to load sites");
} finally {
setSitesLoading(false);
}
};
loadSites();
}, [user, isLoading]);
// Background token refresh - check every 60 seconds
// The AuthManager will only refresh if token expires within 1 minute
useEffect(() => {
console.log("[Dashboard] 🔁 Setting up background token refresh (every 60s)");
const refreshInterval = setInterval(async () => {
if (!authManager.getIsInitialized()) {
return;
}
// Check if we're still authenticated (refresh token still valid)
if (!authManager.isAuthenticated()) {
console.warn("[Dashboard] ⚠️ Refresh token expired, redirecting to login");
clearInterval(refreshInterval);
navigate("/login");
return;
}
try {
// This will refresh the token if it's expiring soon (within 1 minute)
await authManager.ensureValidToken();
} catch (error) {
console.error("[Dashboard] ❌ Background token refresh failed:", error);
// Token refresh failed, redirect to login
clearInterval(refreshInterval);
navigate("/login");
}
}, 60000); // Check every 60 seconds (access token expires in 15 minutes)
return () => {
console.log("[Dashboard] 🛑 Cleaning up background token refresh");
clearInterval(refreshInterval);
};
}, [authManager, navigate]);
const handleLogout = async () => {
await authManager.logout();
navigate("/");
};
// Show loading state while waiting for authentication check
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
{/* Navigation Bar */}
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<div className="flex items-center gap-2">
<span className="text-2xl">🍁</span>
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</div>
{/* User Menu */}
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-lg">
<div className="w-8 h-8 bg-gradient-to-r from-indigo-600 to-blue-600 rounded-full
flex items-center justify-center text-white text-sm font-semibold">
{user?.email?.[0]?.toUpperCase() || "U"}
</div>
<span className="text-sm font-medium text-gray-700">
{user?.email || "User"}
</span>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900
border border-gray-300 rounded-lg hover:bg-gray-50 transition-all"
>
Sign Out
</button>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Welcome Section */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Welcome to MaplePress
</h1>
<p className="text-xl text-gray-600">
Cloud services platform for your WordPress sites
</p>
</div>
{/* Sites Section */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden mb-8">
{/* Section Header */}
<div className="bg-gradient-to-r from-indigo-600 to-blue-600 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">🌐</span>
<h2 className="text-xl font-bold text-white">Your WordPress Sites</h2>
</div>
<button
onClick={() => navigate("/sites/add")}
className="px-4 py-2 bg-white text-indigo-600 rounded-lg hover:bg-indigo-50
transition-all font-medium text-sm flex items-center gap-2"
>
<span></span>
<span>Add Site</span>
</button>
</div>
{/* Loading State */}
{sitesLoading && (
<div className="p-12 text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading your sites...</p>
</div>
)}
{/* Error State */}
{!sitesLoading && sitesError && (
<div className="p-12 text-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<span className="text-4xl"></span>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">
Failed to Load Sites
</h3>
<p className="text-gray-600 mb-6 max-w-md mx-auto">{sitesError}</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white
rounded-lg hover:from-indigo-700 hover:to-blue-700 transition-all
font-medium shadow-lg hover:shadow-xl"
>
Retry
</button>
</div>
)}
{/* Empty State */}
{!sitesLoading && !sitesError && sites.length === 0 && (
<div className="p-12 text-center">
<div className="w-20 h-20 bg-gradient-to-br from-indigo-100 to-blue-100 rounded-full
flex items-center justify-center mx-auto mb-6">
<span className="text-4xl">🚀</span>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">
No sites connected yet
</h3>
<p className="text-gray-600 mb-6 max-w-md mx-auto">
Get started by connecting your first WordPress site to unlock cloud-powered search,
analytics, and more.
</p>
<button
onClick={() => navigate("/sites/add")}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white
rounded-lg hover:from-indigo-700 hover:to-blue-700 transition-all
font-medium shadow-lg hover:shadow-xl"
>
Connect Your First Site
</button>
</div>
)}
{/* Site List */}
{!sitesLoading && !sitesError && sites.length > 0 && (
<div className="divide-y divide-gray-100">
{sites.map((site) => (
<div
key={site.id}
className="p-6 hover:bg-gray-50 transition-all flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500 to-blue-500 rounded-lg
flex items-center justify-center text-white font-bold text-lg">
{site.domain[0]?.toUpperCase() || "W"}
</div>
<div>
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
{site.domain}
{site.isVerified && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Verified
</span>
)}
{!site.isVerified && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
Pending
</span>
)}
</h3>
<p className="text-sm text-gray-600">
Status: {site.status} Added {site.createdAt.toLocaleDateString()}
</p>
</div>
</div>
<button
onClick={() => navigate(`/sites/${site.id}`)}
className="px-4 py-2 text-sm font-medium text-indigo-600 hover:text-indigo-700
border border-indigo-200 rounded-lg hover:bg-indigo-50 transition-all"
>
Manage
</button>
</div>
))}
</div>
)}
</div>
{/* Getting Started Guide */}
<div className="bg-gradient-to-r from-indigo-50 to-blue-50 rounded-2xl p-8
border border-indigo-100">
<div className="flex items-start gap-4">
<div className="text-4xl">💡</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-gray-900 mb-3">
Getting Started
</h3>
<p className="text-gray-700 mb-4">
To connect your WordPress site to MaplePress:
</p>
<ol className="space-y-2 text-gray-700 mb-6">
<li className="flex items-start gap-2">
<span className="font-semibold text-indigo-600">1.</span>
<span>Install the MaplePress plugin on your WordPress site</span>
</li>
<li className="flex items-start gap-2">
<span className="font-semibold text-indigo-600">2.</span>
<span>Enter your API credentials from the plugin settings</span>
</li>
<li className="flex items-start gap-2">
<span className="font-semibold text-indigo-600">3.</span>
<span>Your site will appear here once connected</span>
</li>
</ol>
<div className="flex gap-3">
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg
hover:bg-indigo-700 transition-all font-medium">
Download Plugin
</button>
<button className="px-4 py-2 text-indigo-600 border border-indigo-200 rounded-lg
hover:bg-white transition-all font-medium">
View Documentation
</button>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
export default Dashboard;

View file

@ -0,0 +1,279 @@
// File: src/pages/Home/IndexPage.jsx
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router";
import HealthService from "../../services/API/HealthService";
/**
* IndexPage - Home page for MaplePress
*
* Modern landing page with improved design and health status monitoring
*/
function IndexPage() {
const navigate = useNavigate();
const [healthStatus, setHealthStatus] = useState({
checking: true,
healthy: false,
responseTime: null,
});
// Check backend health on mount
useEffect(() => {
let isMounted = true;
const checkBackendHealth = async () => {
try {
const status = await HealthService.getDetailedStatus();
if (isMounted) {
setHealthStatus({
checking: false,
healthy: status.healthy,
responseTime: status.responseTimeMs,
});
}
} catch (error) {
if (isMounted) {
setHealthStatus({
checking: false,
healthy: false,
responseTime: null,
});
}
}
};
// Initial check
checkBackendHealth();
// Re-check every 30 seconds
const intervalId = setInterval(checkBackendHealth, 30000);
return () => {
isMounted = false;
clearInterval(intervalId);
};
}, []);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
{/* Health Status Badge - Fixed position top right */}
<div className="fixed top-4 right-4 z-50">
{healthStatus.checking ? (
<div className="bg-white rounded-full px-4 py-2 shadow-lg flex items-center gap-2 border border-gray-200">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-gray-600">
Checking status...
</span>
</div>
) : healthStatus.healthy ? (
<div className="bg-white rounded-full px-4 py-2 shadow-lg flex items-center gap-2 border border-green-200">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-gray-700">
All systems operational
</span>
{healthStatus.responseTime && (
<span className="text-xs text-gray-500">
({healthStatus.responseTime}ms)
</span>
)}
</div>
) : (
<div className="bg-white rounded-full px-4 py-2 shadow-lg flex items-center gap-2 border border-red-200">
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
<span className="text-sm font-medium text-gray-700">
Service unavailable
</span>
</div>
)}
</div>
{/* Hero Section */}
<div className="container mx-auto px-4 py-16 md:py-24">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="text-center mb-16">
<div className="inline-block mb-6">
<div className="flex items-center gap-3 bg-white px-6 py-3 rounded-full shadow-lg">
<span className="text-4xl">🍁</span>
<h1 className="text-3xl md:text-4xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</div>
</div>
<h2 className="text-4xl md:text-6xl font-bold text-gray-900 mb-6 leading-tight">
Cloud Services for
<br />
<span className="bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
WordPress Sites
</span>
</h2>
<p className="text-xl md:text-2xl text-gray-600 mb-8 max-w-3xl mx-auto">
Supercharge your WordPress with cloud-powered search, analytics,
and processing. Keep your site fast while adding powerful
features.
</p>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-12">
<button
onClick={() => navigate("/register")}
className="group relative w-full sm:w-auto px-8 py-4 bg-gradient-to-r from-indigo-600 to-blue-600
text-white font-semibold rounded-xl shadow-lg hover:shadow-xl
transform hover:-translate-y-0.5 transition-all duration-200"
>
<span className="relative z-10">Get Started Free</span>
<div
className="absolute inset-0 bg-gradient-to-r from-indigo-700 to-blue-700 rounded-xl opacity-0
group-hover:opacity-100 transition-opacity"
></div>
</button>
<button
onClick={() => navigate("/login")}
className="w-full sm:w-auto px-8 py-4 bg-white text-gray-700 font-semibold rounded-xl
border-2 border-gray-200 hover:border-indigo-300 hover:bg-gray-50
shadow-md hover:shadow-lg transform hover:-translate-y-0.5 transition-all duration-200"
>
Sign In
</button>
</div>
{/* Trust Badge */}
<p className="text-sm text-gray-500">
Open source No credit card required Free tier available
</p>
</div>
{/* Features Grid */}
<div className="grid md:grid-cols-3 gap-8 mb-16">
{/* Feature 1 */}
<div
className="group bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl
transform hover:-translate-y-1 transition-all duration-200"
>
<div
className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-blue-500 rounded-2xl
flex items-center justify-center text-3xl mb-6 shadow-lg
group-hover:scale-110 transition-transform"
>
🔍
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">
Cloud-Powered Search
</h3>
<p className="text-gray-600 leading-relaxed">
Lightning-fast search with advanced filtering, powered by cloud
infrastructure. Offload processing from your WordPress server.
</p>
</div>
{/* Feature 2 */}
<div
className="group bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl
transform hover:-translate-y-1 transition-all duration-200"
>
<div
className="w-16 h-16 bg-gradient-to-br from-purple-500 to-pink-500 rounded-2xl
flex items-center justify-center text-3xl mb-6 shadow-lg
group-hover:scale-110 transition-transform"
>
📊
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">
Analytics & Insights
</h3>
<p className="text-gray-600 leading-relaxed">
Deep insights into content performance, search patterns, and
user behavior. Make data-driven decisions.
</p>
</div>
{/* Feature 3 */}
<div
className="group bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl
transform hover:-translate-y-1 transition-all duration-200"
>
<div
className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-500 rounded-2xl
flex items-center justify-center text-3xl mb-6 shadow-lg
group-hover:scale-110 transition-transform"
>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">
Peak Performance
</h3>
<p className="text-gray-600 leading-relaxed">
Keep your WordPress site blazing fast by offloading heavy tasks
to the cloud. Better experience for your users.
</p>
</div>
</div>
{/* How it Works */}
<div className="bg-white rounded-3xl p-8 md:p-12 shadow-xl mb-16">
<h3 className="text-3xl font-bold text-gray-900 text-center mb-12">
Simple Setup in 3 Steps
</h3>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center">
<div
className="w-16 h-16 bg-indigo-100 text-indigo-600 rounded-full
flex items-center justify-center text-2xl font-bold mx-auto mb-4"
>
1
</div>
<h4 className="font-semibold text-gray-900 mb-2">
Create Account
</h4>
<p className="text-sm text-gray-600">
Sign up in seconds and create your organization
</p>
</div>
<div className="text-center">
<div
className="w-16 h-16 bg-indigo-100 text-indigo-600 rounded-full
flex items-center justify-center text-2xl font-bold mx-auto mb-4"
>
2
</div>
<h4 className="font-semibold text-gray-900 mb-2">
Install Plugin
</h4>
<p className="text-sm text-gray-600">
Add our WordPress plugin and connect your site
</p>
</div>
<div className="text-center">
<div
className="w-16 h-16 bg-indigo-100 text-indigo-600 rounded-full
flex items-center justify-center text-2xl font-bold mx-auto mb-4"
>
3
</div>
<h4 className="font-semibold text-gray-900 mb-2">Go Live</h4>
<p className="text-sm text-gray-600">
Enable cloud features and enjoy better performance
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="text-center">
<p className="text-gray-500 mb-2">
Part of the{" "}
<span className="font-semibold">Maple Open Technologies</span>{" "}
open-source software suite
</p>
<p className="text-sm text-gray-400">
Built with for the WordPress community
</p>
</div>
</div>
</div>
</div>
);
}
export default IndexPage;

View file

@ -0,0 +1,383 @@
// File: src/pages/Sites/AddSite.jsx
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "../../services/Services";
import SiteService from "../../services/API/SiteService";
/**
* AddSite - Page for connecting a new WordPress site to MaplePress
*/
function AddSite() {
const navigate = useNavigate();
const { authManager } = useAuth();
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [formData, setFormData] = useState({
site_url: "",
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [generalError, setGeneralError] = useState("");
useEffect(() => {
// Wait for AuthManager to initialize before checking authentication
const checkAuth = () => {
const isInitialized = authManager.getIsInitialized();
if (!isInitialized) {
setTimeout(checkAuth, 50);
return;
}
const isAuth = authManager.isAuthenticated();
if (!isAuth) {
navigate("/login");
return;
}
const userData = authManager.getUser();
setUser(userData);
setIsLoading(false);
};
checkAuth();
}, [authManager, navigate]);
// Background token refresh - check every 30 seconds
useEffect(() => {
const refreshInterval = setInterval(async () => {
if (!authManager.getIsInitialized()) {
return;
}
try {
// This will refresh the token if it's expiring soon (within 1 minute)
await authManager.ensureValidToken();
} catch (error) {
console.error("[AddSite] Background token refresh failed:", error);
// Token refresh failed, redirect to login
navigate("/login");
}
}, 30000); // Check every 30 seconds
return () => clearInterval(refreshInterval);
}, [authManager, navigate]);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Clear error for this field when user starts typing
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: null }));
}
if (generalError) {
setGeneralError("");
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
setErrors({});
setGeneralError("");
console.log("[AddSite] Form submitted:", { site_url: formData.site_url });
try {
// Call SiteService to create site
// Backend will extract domain from siteUrl
const response = await SiteService.createSite({
siteUrl: formData.site_url,
});
console.log("[AddSite] Site created successfully:", response);
// Navigate to success page with site data
navigate("/sites/add-success", {
state: {
siteData: {
id: response.id,
domain: response.domain,
site_url: response.siteUrl,
api_key: response.apiKey,
verification_token: response.verificationToken,
verification_instructions: response.verificationInstructions,
status: response.status,
search_index_name: response.searchIndexName,
}
}
});
} catch (error) {
console.error("[AddSite] Site creation failed:", error);
setIsSubmitting(false);
// Parse RFC 9457 validation errors
const { fieldErrors, generalError: parsedGeneralError } = parseBackendError(error);
if (Object.keys(fieldErrors).length > 0) {
setErrors(fieldErrors);
}
setGeneralError(parsedGeneralError || "Failed to create site. Please try again.");
}
};
/**
* Format field names for display
*/
const formatFieldName = (fieldName) => {
const fieldLabels = {
site_url: "Site URL",
};
return fieldLabels[fieldName] || fieldName;
};
/**
* Parse backend RFC 9457 error format
*/
const parseBackendError = (error) => {
console.log("[AddSite] 🔍 Parsing backend error:", error);
console.log("[AddSite] 🔍 Error has validationErrors:", !!error.validationErrors);
console.log("[AddSite] 🔍 validationErrors content:", error.validationErrors);
const fieldErrors = {};
let generalError = null;
// Check if error has RFC 9457 validation errors structure
if (error.validationErrors && typeof error.validationErrors === 'object') {
console.log("[AddSite] ✅ Processing RFC 9457 validation errors");
// Process each field's errors
Object.entries(error.validationErrors).forEach(([fieldName, errorMessages]) => {
if (Array.isArray(errorMessages) && errorMessages.length > 0) {
// Join multiple error messages for the same field with semicolon
fieldErrors[fieldName] = errorMessages.join('; ');
console.log(`[AddSite] 📋 Field "${fieldName}": ${fieldErrors[fieldName]}`);
}
});
// Use the detail/message as general error if available
if (error.message) {
generalError = error.message;
}
} else {
// Fallback for non-RFC 9457 errors
console.log("[AddSite] ⚠️ Non-RFC 9457 error, using fallback");
generalError = error.message || "An error occurred. Please try again.";
}
console.log("[AddSite] 📊 Parsed errors:", { fieldErrors, generalError });
return { fieldErrors, generalError };
};
const handleCancel = () => {
navigate("/dashboard");
};
// Show loading state while waiting for authentication check
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
// Show form to create site
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
{/* Navigation Bar */}
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<button
onClick={() => navigate("/dashboard")}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-2xl">🍁</span>
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</button>
{/* User Menu */}
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-lg">
<div className="w-8 h-8 bg-gradient-to-r from-indigo-600 to-blue-600 rounded-full
flex items-center justify-center text-white text-sm font-semibold">
{user?.email?.[0]?.toUpperCase() || "U"}
</div>
<span className="text-sm font-medium text-gray-700">
{user?.email || "User"}
</span>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="mb-8">
<button
onClick={handleCancel}
className="text-indigo-600 hover:text-indigo-700 font-medium mb-4 flex items-center gap-2"
>
<span></span>
<span>Back to Dashboard</span>
</button>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Add WordPress Site
</h1>
<p className="text-gray-600">
Register your WordPress site to generate API credentials
</p>
</div>
{/* Form Card */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="p-8">
{/* Error Summary Box */}
{(generalError || Object.entries(errors).filter(([_, msg]) => msg).length > 0) && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 text-red-600 text-xl"></div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-800 mb-2">
Please correct the following errors:
</h3>
<ul className="space-y-1 text-sm text-red-600">
{generalError && (
<li> {generalError}</li>
)}
{/* Merge domain and site_url errors since they're about the same field */}
{(() => {
const fieldErrors = Object.entries(errors).filter(([_, message]) => message);
const mergedErrors = new Map();
fieldErrors.forEach(([field, message]) => {
const displayName = formatFieldName(field);
if (mergedErrors.has(displayName)) {
// Combine messages if they're different
const existing = mergedErrors.get(displayName);
if (existing !== message) {
mergedErrors.set(displayName, `${existing}; ${message}`);
}
} else {
mergedErrors.set(displayName, message);
}
});
return Array.from(mergedErrors.entries()).map(([displayName, message]) => (
<li key={displayName}>
<span className="font-medium">{displayName}:</span> {message}
</li>
));
})()}
</ul>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit}>
{/* WordPress Site URL */}
<div className="mb-6">
<label
htmlFor="site_url"
className="block text-sm font-medium text-gray-700 mb-2"
>
WordPress Site URL
</label>
<input
type="text"
id="site_url"
name="site_url"
value={formData.site_url}
onChange={handleInputChange}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent
${errors.site_url ? "border-red-500" : "border-gray-300"}`}
placeholder="https://example.com"
disabled={isSubmitting}
autoFocus
/>
{errors.site_url && (
<p className="mt-1 text-sm text-red-600">{errors.site_url}</p>
)}
<p className="mt-1 text-sm text-gray-500">
Enter the full URL of your WordPress site (e.g., https://example.com or https://www.example.com/blog)
</p>
</div>
{/* Info Box */}
<div className="mb-6 p-4 bg-indigo-50 border border-indigo-100 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-2xl">💡</span>
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-2">
What Happens Next?
</h4>
<ul className="space-y-1 text-sm text-gray-700">
<li> MaplePress will generate an API key for your site</li>
<li> You'll receive the API key <strong>only once</strong> - save it immediately!</li>
<li> Use the API key in your WordPress plugin settings to connect</li>
</ul>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-6 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white
rounded-lg hover:from-indigo-700 hover:to-blue-700 transition-all
font-medium shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Creating Site..." : "Create Site"}
</button>
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="px-6 py-3 text-gray-700 border border-gray-300 rounded-lg
hover:bg-gray-50 transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</form>
</div>
</div>
{/* Help Section */}
<div className="mt-8 bg-gradient-to-r from-indigo-50 to-blue-50 rounded-2xl p-6 border border-indigo-100">
<div className="flex items-start gap-3">
<span className="text-2xl">📚</span>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-2">
Need the WordPress Plugin?
</h3>
<p className="text-sm text-gray-700 mb-3">
Make sure you have the MaplePress plugin installed on your WordPress site before connecting.
</p>
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700
transition-all font-medium text-sm">
Download Plugin
</button>
</div>
</div>
</div>
</main>
</div>
);
}
export default AddSite;

View file

@ -0,0 +1,543 @@
// File: src/pages/Sites/AddSite.jsx
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "../../services/Services";
import SiteService from "../../services/API/SiteService";
/**
* AddSite - Page for connecting a new WordPress site to MaplePress
*/
function AddSite() {
const navigate = useNavigate();
const { authManager } = useAuth();
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [formData, setFormData] = useState({
site_url: "",
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [generalError, setGeneralError] = useState("");
useEffect(() => {
// Wait for AuthManager to initialize before checking authentication
const checkAuth = () => {
const isInitialized = authManager.getIsInitialized();
if (!isInitialized) {
setTimeout(checkAuth, 50);
return;
}
const isAuth = authManager.isAuthenticated();
if (!isAuth) {
navigate("/login");
return;
}
const userData = authManager.getUser();
setUser(userData);
setIsLoading(false);
};
checkAuth();
}, [authManager, navigate]);
// Background token refresh - check every 30 seconds
useEffect(() => {
const refreshInterval = setInterval(async () => {
if (!authManager.getIsInitialized()) {
return;
}
try {
// This will refresh the token if it's expiring soon (within 1 minute)
await authManager.ensureValidToken();
} catch (error) {
console.error("[AddSite] Background token refresh failed:", error);
// Token refresh failed, redirect to login
navigate("/login");
}
}, 30000); // Check every 30 seconds
return () => clearInterval(refreshInterval);
}, [authManager, navigate]);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Clear error for this field when user starts typing
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: null }));
}
if (generalError) {
setGeneralError("");
}
};
/**
* Extract domain from URL
*/
const extractDomainFromUrl = (url) => {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch (error) {
// If URL is invalid, return it as-is and let backend validation handle it
return url;
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
setErrors({});
setGeneralError("");
// Extract domain from the site URL
const domain = extractDomainFromUrl(formData.site_url);
console.log("[AddSite] Form submitted:", { site_url: formData.site_url, extracted_domain: domain });
try {
// Call SiteService to create site
// Send data as-is, let backend validate
const response = await SiteService.createSite({
domain: domain,
siteUrl: formData.site_url,
});
console.log("[AddSite] Site created successfully:", response);
// Navigate to success page with site data
navigate("/sites/add-success", {
state: {
siteData: {
id: response.id,
domain: response.domain,
site_url: response.siteUrl,
api_key: response.apiKey,
verification_token: response.verificationToken,
status: response.status,
search_index_name: response.searchIndexName,
}
}
});
} catch (error) {
console.error("[AddSite] Site creation failed:", error);
setIsSubmitting(false);
// Parse RFC 9457 validation errors
const { fieldErrors, generalError: parsedGeneralError } = parseBackendError(error);
if (Object.keys(fieldErrors).length > 0) {
setErrors(fieldErrors);
}
setGeneralError(parsedGeneralError || "Failed to create site. Please try again.");
}
};
/**
* Format field names for display
*/
const formatFieldName = (fieldName) => {
const fieldLabels = {
domain: "Domain",
site_url: "Site URL",
};
return fieldLabels[fieldName] || fieldName;
};
/**
* Parse backend RFC 9457 error format
*/
const parseBackendError = (error) => {
console.log("[AddSite] 🔍 Parsing backend error:", error);
console.log("[AddSite] 🔍 Error has validationErrors:", !!error.validationErrors);
console.log("[AddSite] 🔍 validationErrors content:", error.validationErrors);
const fieldErrors = {};
let generalError = null;
// Check if error has RFC 9457 validation errors structure
if (error.validationErrors && typeof error.validationErrors === 'object') {
console.log("[AddSite] ✅ Processing RFC 9457 validation errors");
// Process each field's errors
Object.entries(error.validationErrors).forEach(([fieldName, errorMessages]) => {
if (Array.isArray(errorMessages) && errorMessages.length > 0) {
// Join multiple error messages for the same field with semicolon
fieldErrors[fieldName] = errorMessages.join('; ');
console.log(`[AddSite] 📋 Field "${fieldName}": ${fieldErrors[fieldName]}`);
}
});
// Use the detail/message as general error if available
if (error.message) {
generalError = error.message;
}
} else {
// Fallback for non-RFC 9457 errors
console.log("[AddSite] ⚠️ Non-RFC 9457 error, using fallback");
generalError = error.message || "An error occurred. Please try again.";
}
console.log("[AddSite] 📊 Parsed errors:", { fieldErrors, generalError });
return { fieldErrors, generalError };
};
const handleCancel = () => {
navigate("/dashboard");
};
// Show loading state while waiting for authentication check
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
// Show success page with API key if site was created
if (createdSite) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
{/* Navigation Bar */}
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<button
onClick={() => navigate("/dashboard")}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-2xl">🍁</span>
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</button>
{/* User Menu */}
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-lg">
<div className="w-8 h-8 bg-gradient-to-r from-indigo-600 to-blue-600 rounded-full
flex items-center justify-center text-white text-sm font-semibold">
{user?.email?.[0]?.toUpperCase() || "U"}
</div>
<span className="text-sm font-medium text-gray-700">
{user?.email || "User"}
</span>
</div>
</div>
</div>
</nav>
{/* Success Content */}
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-600 to-emerald-600 px-8 py-6 text-white">
<div className="flex items-center gap-3 mb-2">
<span className="text-3xl"></span>
<h1 className="text-2xl font-bold">Site Created Successfully!</h1>
</div>
<p className="text-green-50">
Your WordPress site has been registered with MaplePress
</p>
</div>
<div className="p-8">
{/* Site Details */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Site Details</h3>
<div className="space-y-3 bg-gray-50 rounded-lg p-4">
<div>
<span className="text-sm text-gray-600">Domain:</span>
<p className="font-medium text-gray-900">{createdSite.domain}</p>
</div>
<div>
<span className="text-sm text-gray-600">Site URL:</span>
<p className="font-medium text-gray-900">{createdSite.site_url}</p>
</div>
<div>
<span className="text-sm text-gray-600">Status:</span>
<p className="font-medium text-yellow-600 capitalize">{createdSite.status}</p>
</div>
</div>
</div>
{/* API Key Section - IMPORTANT */}
<div className="mb-8 p-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg">
<div className="flex items-start gap-3 mb-4">
<span className="text-2xl"></span>
<div>
<h3 className="text-lg font-bold text-gray-900 mb-1">
Save Your API Key Now!
</h3>
<p className="text-sm text-gray-700">
This API key will <strong>only be shown once</strong>. Copy it now and add it to your WordPress plugin settings.
</p>
</div>
</div>
<div className="bg-white rounded-lg p-4 border border-yellow-300">
<label className="block text-sm font-medium text-gray-700 mb-2">
API Key
</label>
<div className="flex gap-2">
<input
type="text"
value={createdSite.api_key}
readOnly
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 font-mono text-sm"
/>
<button
onClick={handleCopyApiKey}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700
transition-all font-medium"
>
Copy
</button>
</div>
</div>
</div>
{/* Next Steps */}
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Next Steps</h3>
<ol className="space-y-3">
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-600 text-white rounded-full
flex items-center justify-center text-sm font-bold">
1
</span>
<div>
<p className="font-medium text-gray-900">Install the MaplePress WordPress Plugin</p>
<p className="text-sm text-gray-600">Download and activate the plugin on your WordPress site</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-600 text-white rounded-full
flex items-center justify-center text-sm font-bold">
2
</span>
<div>
<p className="font-medium text-gray-900">Enter Your API Key</p>
<p className="text-sm text-gray-600">
Go to Settings MaplePress and paste your API key (copied above)
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-600 text-white rounded-full
flex items-center justify-center text-sm font-bold">
3
</span>
<div>
<p className="font-medium text-gray-900">Verify Your Site</p>
<p className="text-sm text-gray-600">
The plugin will automatically verify your site and activate cloud services
</p>
</div>
</li>
</ol>
</div>
{/* Action Button */}
<button
onClick={handleDone}
className="w-full px-6 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white
rounded-lg hover:from-indigo-700 hover:to-blue-700 transition-all
font-medium shadow-lg hover:shadow-xl"
>
Go to Dashboard
</button>
</div>
</div>
</main>
</div>
);
}
// Show form to create site
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
{/* Navigation Bar */}
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<button
onClick={() => navigate("/dashboard")}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-2xl">🍁</span>
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</button>
{/* User Menu */}
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-lg">
<div className="w-8 h-8 bg-gradient-to-r from-indigo-600 to-blue-600 rounded-full
flex items-center justify-center text-white text-sm font-semibold">
{user?.email?.[0]?.toUpperCase() || "U"}
</div>
<span className="text-sm font-medium text-gray-700">
{user?.email || "User"}
</span>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="mb-8">
<button
onClick={handleCancel}
className="text-indigo-600 hover:text-indigo-700 font-medium mb-4 flex items-center gap-2"
>
<span></span>
<span>Back to Dashboard</span>
</button>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Add WordPress Site
</h1>
<p className="text-gray-600">
Register your WordPress site to generate API credentials
</p>
</div>
{/* Form Card */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="p-8">
{/* Error Summary Box */}
{(generalError || Object.entries(errors).filter(([_, msg]) => msg).length > 0) && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 text-red-600 text-xl"></div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-800 mb-2">
Please correct the following errors:
</h3>
<ul className="space-y-1 text-sm text-red-600">
{generalError && (
<li> {generalError}</li>
)}
{Object.entries(errors).filter(([_, message]) => message).map(([field, message]) => (
<li key={field}>
<span className="font-medium">{formatFieldName(field)}:</span> {message}
</li>
))}
</ul>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit}>
{/* WordPress Site URL */}
<div className="mb-6">
<label
htmlFor="site_url"
className="block text-sm font-medium text-gray-700 mb-2"
>
WordPress Site URL
</label>
<input
type="text"
id="site_url"
name="site_url"
value={formData.site_url}
onChange={handleInputChange}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent
${errors.site_url ? "border-red-500" : "border-gray-300"}`}
placeholder="https://example.com"
disabled={isSubmitting}
autoFocus
/>
{errors.site_url && (
<p className="mt-1 text-sm text-red-600">{errors.site_url}</p>
)}
{errors.domain && (
<p className="mt-1 text-sm text-red-600">{errors.domain}</p>
)}
<p className="mt-1 text-sm text-gray-500">
Enter the full URL of your WordPress site (e.g., https://example.com or https://www.example.com/blog)
</p>
</div>
{/* Info Box */}
<div className="mb-6 p-4 bg-indigo-50 border border-indigo-100 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-2xl">💡</span>
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-2">
What Happens Next?
</h4>
<ul className="space-y-1 text-sm text-gray-700">
<li> MaplePress will generate an API key for your site</li>
<li> You'll receive the API key <strong>only once</strong> - save it immediately!</li>
<li> Use the API key in your WordPress plugin settings to connect</li>
</ul>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-6 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white
rounded-lg hover:from-indigo-700 hover:to-blue-700 transition-all
font-medium shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? "Creating Site..." : "Create Site"}
</button>
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="px-6 py-3 text-gray-700 border border-gray-300 rounded-lg
hover:bg-gray-50 transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</form>
</div>
</div>
{/* Help Section */}
<div className="mt-8 bg-gradient-to-r from-indigo-50 to-blue-50 rounded-2xl p-6 border border-indigo-100">
<div className="flex items-start gap-3">
<span className="text-2xl">📚</span>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-2">
Need the WordPress Plugin?
</h3>
<p className="text-sm text-gray-700 mb-3">
Make sure you have the MaplePress plugin installed on your WordPress site before connecting.
</p>
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700
transition-all font-medium text-sm">
Download Plugin
</button>
</div>
</div>
</div>
</main>
</div>
);
}
export default AddSite;

View file

@ -0,0 +1,336 @@
// File: src/pages/Sites/AddSiteSuccess.jsx
import React, { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router";
import { useAuth } from "../../services/Services";
/**
* AddSiteSuccess - Success page after creating a WordPress site
* Shows the API key (ONLY ONCE) with prominent warning to save it
*/
function AddSiteSuccess() {
const navigate = useNavigate();
const location = useLocation();
const { authManager } = useAuth();
const [copied, setCopied] = useState(false);
const [copiedToken, setCopiedToken] = useState(false);
// Get site data from navigation state
const siteData = location.state?.siteData;
useEffect(() => {
// If no site data, redirect back to add site page
if (!siteData) {
navigate("/sites/add");
return;
}
// Check authentication
const checkAuth = () => {
const isInitialized = authManager.getIsInitialized();
if (!isInitialized) {
setTimeout(checkAuth, 50);
return;
}
const isAuth = authManager.isAuthenticated();
if (!isAuth) {
navigate("/login");
}
};
checkAuth();
}, [authManager, navigate, siteData]);
const handleCopyApiKey = () => {
navigator.clipboard.writeText(siteData.api_key);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
};
const handleCopyVerificationToken = () => {
const txtRecord = `maplepress-verify=${siteData.verification_token}`;
navigator.clipboard.writeText(txtRecord);
setCopiedToken(true);
setTimeout(() => setCopiedToken(false), 3000);
};
const handleDone = () => {
navigate("/dashboard");
};
if (!siteData) {
return null;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
{/* Navigation Bar */}
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<button
onClick={() => navigate("/dashboard")}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-2xl">🍁</span>
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</button>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Success Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
<span className="text-3xl"></span>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Site Created Successfully!
</h1>
<p className="text-gray-600">
Your WordPress site has been registered with MaplePress
</p>
</div>
{/* Critical Warning Box */}
<div className="mb-8 p-6 bg-red-50 border-2 border-red-300 rounded-lg">
<div className="flex items-start gap-4">
<span className="text-4xl flex-shrink-0"></span>
<div className="flex-1">
<h2 className="text-xl font-bold text-red-900 mb-2">
IMPORTANT: Save Your API Key Now!
</h2>
<div className="space-y-2 text-red-800">
<p className="font-semibold">
This API key will <span className="underline">ONLY be shown once</span> and cannot be retrieved later.
</p>
<p>
If you lose this key, you will need to generate a new one, which will invalidate the current key.
</p>
</div>
</div>
</div>
</div>
{/* Site Information Card */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden mb-6">
<div className="p-8">
{/* Site Details */}
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Site Details</h3>
<div className="space-y-3">
<div>
<span className="text-sm text-gray-600">Domain:</span>
<p className="font-medium text-gray-900">{siteData.domain}</p>
</div>
<div>
<span className="text-sm text-gray-600">Site URL:</span>
<p className="font-medium text-gray-900">{siteData.site_url}</p>
</div>
<div>
<span className="text-sm text-gray-600">Status:</span>
<p className="font-medium text-yellow-600 capitalize">{siteData.status}</p>
</div>
</div>
</div>
{/* API Key Section */}
<div className="mb-6 p-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg">
<h3 className="text-lg font-bold text-gray-900 mb-4">
Your API Key
</h3>
<div className="bg-white rounded-lg p-4 border border-yellow-300 mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
API Key (copy this now!)
</label>
<div className="flex gap-2">
<input
type="text"
value={siteData.api_key}
readOnly
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg bg-gray-50 font-mono text-sm"
/>
<button
onClick={handleCopyApiKey}
className={`px-6 py-3 rounded-lg font-medium transition-all
${copied
? "bg-green-600 text-white"
: "bg-indigo-600 text-white hover:bg-indigo-700"
}`}
>
{copied ? "✓ Copied!" : "Copy"}
</button>
</div>
</div>
<p className="text-sm text-gray-700">
<strong>Remember:</strong> Store this API key in a secure location like a password manager.
You'll need it to configure the MaplePress WordPress plugin.
</p>
</div>
</div>
</div>
{/* DNS Verification Section */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden mb-6">
<div className="p-8">
<div className="flex items-start gap-4 mb-6">
<span className="text-4xl flex-shrink-0">🔐</span>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Verify Domain Ownership
</h3>
<p className="text-gray-600 text-sm">
To activate your site, you need to prove you own the domain by adding a DNS TXT record.
This is the same method used by Google Search Console and other major services.
</p>
</div>
</div>
{/* DNS TXT Record */}
<div className="mb-6 p-6 bg-blue-50 border-2 border-blue-200 rounded-lg">
<h4 className="text-md font-bold text-gray-900 mb-3">
Add this DNS TXT record to {siteData.domain}
</h4>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Host/Name:
</label>
<div className="bg-white rounded-lg p-3 border border-blue-300 font-mono text-sm">
{siteData.domain}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Type:
</label>
<div className="bg-white rounded-lg p-3 border border-blue-300 font-mono text-sm">
TXT
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Value:
</label>
<div className="flex gap-2">
<input
type="text"
value={`maplepress-verify=${siteData.verification_token}`}
readOnly
className="flex-1 px-4 py-3 border border-blue-300 rounded-lg bg-white font-mono text-sm"
/>
<button
onClick={handleCopyVerificationToken}
className={`px-6 py-3 rounded-lg font-medium transition-all
${copiedToken
? "bg-green-600 text-white"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}
>
{copiedToken ? "✓ Copied!" : "Copy"}
</button>
</div>
</div>
</div>
<div className="mt-6 p-4 bg-blue-100 rounded-lg">
<h5 className="font-semibold text-gray-900 mb-2 text-sm">
📝 Instructions:
</h5>
<ol className="text-sm text-gray-700 space-y-1 list-decimal list-inside">
<li>Log in to your domain registrar (GoDaddy, Namecheap, Cloudflare, etc.)</li>
<li>Find DNS settings or DNS management</li>
<li>Add a new TXT record with the values above</li>
<li>Wait 5-10 minutes for DNS propagation</li>
<li>Click "Verify Domain" in the MaplePress WordPress plugin</li>
</ol>
<p className="text-xs text-gray-600 mt-3">
<strong>Note:</strong> DNS changes can take up to 48 hours to propagate globally, but usually complete within 10 minutes.
</p>
</div>
</div>
</div>
</div>
{/* Next Steps */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden mb-6">
<div className="p-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Next Steps</h3>
<ol className="space-y-4">
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-8 h-8 bg-indigo-600 text-white rounded-full
flex items-center justify-center text-sm font-bold">
1
</span>
<div>
<h4 className="font-semibold text-gray-900">Add DNS TXT Record</h4>
<p className="text-sm text-gray-600 mt-1">
Add the DNS TXT record shown above to your domain registrar. This proves you own the domain.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-8 h-8 bg-indigo-600 text-white rounded-full
flex items-center justify-center text-sm font-bold">
2
</span>
<div>
<h4 className="font-semibold text-gray-900">Install the MaplePress WordPress Plugin</h4>
<p className="text-sm text-gray-600 mt-1">
Download and install the MaplePress plugin on your WordPress site.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-8 h-8 bg-indigo-600 text-white rounded-full
flex items-center justify-center text-sm font-bold">
3
</span>
<div>
<h4 className="font-semibold text-gray-900">Configure the Plugin with Your API Key</h4>
<p className="text-sm text-gray-600 mt-1">
Go to your WordPress admin panel Settings MaplePress and enter the API key you saved earlier.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-8 h-8 bg-indigo-600 text-white rounded-full
flex items-center justify-center text-sm font-bold">
4
</span>
<div>
<h4 className="font-semibold text-gray-900">Verify Your Domain</h4>
<p className="text-sm text-gray-600 mt-1">
After DNS propagation (5-10 minutes), click "Verify Domain" in the plugin to activate cloud-powered features.
</p>
</div>
</li>
</ol>
</div>
</div>
{/* Action Button */}
<div className="flex justify-center">
<button
onClick={handleDone}
className="px-8 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700
transition-all font-medium text-lg shadow-lg"
>
Go to Dashboard
</button>
</div>
</main>
</div>
);
}
export default AddSiteSuccess;

View file

@ -0,0 +1,404 @@
// File: src/pages/Sites/DeleteSite.jsx
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { useAuth } from "../../services/Services";
import SiteService from "../../services/API/SiteService";
/**
* DeleteSite - Confirmation page for deleting a WordPress site
*/
function DeleteSite() {
const navigate = useNavigate();
const { id } = useParams();
const { authManager } = useAuth();
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [site, setSite] = useState(null);
const [siteLoading, setSiteLoading] = useState(false);
const [siteError, setSiteError] = useState(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState(null);
const [confirmationText, setConfirmationText] = useState("");
useEffect(() => {
// Wait for AuthManager to initialize before checking authentication
const checkAuth = async () => {
const isInitialized = authManager.getIsInitialized();
if (!isInitialized) {
setTimeout(checkAuth, 50);
return;
}
const isAuth = authManager.isAuthenticated();
if (!isAuth) {
navigate("/login");
return;
}
// Proactively refresh token if needed
try {
await authManager.ensureValidToken();
} catch (error) {
console.error("[DeleteSite] Token refresh failed:", error);
navigate("/login");
return;
}
const userData = authManager.getUser();
setUser(userData);
setIsLoading(false);
};
checkAuth();
}, [authManager, navigate]);
// Background token refresh - check every 30 seconds
useEffect(() => {
const refreshInterval = setInterval(async () => {
if (!authManager.getIsInitialized()) {
return;
}
try {
await authManager.ensureValidToken();
} catch (error) {
console.error("[DeleteSite] Background token refresh failed:", error);
navigate("/login");
}
}, 30000);
return () => clearInterval(refreshInterval);
}, [authManager, navigate]);
// Load site details
useEffect(() => {
if (!user || isLoading || !id) {
return;
}
const loadSite = async () => {
setSiteLoading(true);
setSiteError(null);
console.log("[DeleteSite] Loading site:", id);
try {
const siteData = await SiteService.getSiteById(id);
console.log("[DeleteSite] Site loaded:", siteData);
setSite(siteData);
} catch (error) {
console.error("[DeleteSite] Failed to load site:", error);
setSiteError(error.message || "Failed to load site details");
} finally {
setSiteLoading(false);
}
};
loadSite();
}, [user, isLoading, id]);
const handleDeleteSite = async (e) => {
e.preventDefault();
// Validate confirmation text
if (confirmationText !== site.domain) {
setDeleteError(`Please type "${site.domain}" to confirm deletion`);
return;
}
setIsDeleting(true);
setDeleteError(null);
console.log("[DeleteSite] Deleting site:", id);
try {
await SiteService.deleteSite(id);
console.log("[DeleteSite] Site deleted successfully");
// Show success message and redirect to dashboard
navigate("/dashboard", {
state: {
message: `Site "${site.domain}" has been deleted successfully`,
type: "success"
}
});
} catch (error) {
console.error("[DeleteSite] Failed to delete site:", error);
setDeleteError(error.message || "Failed to delete site. Please try again.");
setIsDeleting(false);
}
};
const handleCancel = () => {
navigate(`/sites/${id}`);
};
// Show loading state while waiting for authentication check
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
{/* Navigation Bar */}
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<button
onClick={() => navigate("/dashboard")}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-2xl">🍁</span>
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</button>
{/* User Menu */}
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-lg">
<div className="w-8 h-8 bg-gradient-to-r from-indigo-600 to-blue-600 rounded-full
flex items-center justify-center text-white text-sm font-semibold">
{user?.email?.[0]?.toUpperCase() || "U"}
</div>
<span className="text-sm font-medium text-gray-700">
{user?.email || "User"}
</span>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Back Button */}
<button
onClick={handleCancel}
className="text-indigo-600 hover:text-indigo-700 font-medium mb-6 flex items-center gap-2"
>
<span></span>
<span>Back to Site Details</span>
</button>
{/* Loading State */}
{siteLoading && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-12 text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading site details...</p>
</div>
)}
{/* Error State */}
{!siteLoading && siteError && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-12 text-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<span className="text-4xl"></span>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">
Failed to Load Site
</h2>
<p className="text-gray-600 mb-6">{siteError}</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white
rounded-lg hover:from-indigo-700 hover:to-blue-700 transition-all font-medium"
>
Retry
</button>
<button
onClick={() => navigate("/dashboard")}
className="px-6 py-3 text-gray-700 border border-gray-300 rounded-lg
hover:bg-gray-50 transition-all font-medium"
>
Back to Dashboard
</button>
</div>
</div>
)}
{/* Delete Confirmation Form */}
{!siteLoading && !siteError && site && (
<div className="bg-white rounded-2xl shadow-lg border border-red-200 overflow-hidden">
{/* Warning Header */}
<div className="bg-gradient-to-r from-red-600 to-red-700 px-6 py-8">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-white rounded-lg flex items-center justify-center text-4xl">
</div>
<div>
<h1 className="text-3xl font-bold text-white">
Delete Site
</h1>
<p className="text-red-100 mt-1">
This action cannot be undone
</p>
</div>
</div>
</div>
{/* Delete Form */}
<div className="p-8">
{/* Site Information */}
<div className="mb-8 p-6 bg-gray-50 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Site to be deleted:
</h2>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500 to-blue-500 rounded-lg
flex items-center justify-center text-white font-bold text-lg">
{site.domain[0]?.toUpperCase() || "W"}
</div>
<div>
<div className="font-semibold text-gray-900 flex items-center gap-2">
{site.domain}
{site.isVerified && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Verified
</span>
)}
</div>
<div className="text-sm text-gray-600">{site.siteUrl}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4 text-sm">
<div>
<span className="text-gray-600">Pages Indexed:</span>
<span className="ml-2 font-semibold text-gray-900">{site.totalPagesIndexed}</span>
</div>
<div>
<span className="text-gray-600">Search Requests:</span>
<span className="ml-2 font-semibold text-gray-900">{site.searchRequestsCount}</span>
</div>
<div>
<span className="text-gray-600">Storage Used:</span>
<span className="ml-2 font-semibold text-gray-900">
{SiteService.formatStorage(site.storageUsedBytes)}
</span>
</div>
<div>
<span className="text-gray-600">Created:</span>
<span className="ml-2 font-semibold text-gray-900">
{site.createdAt.toLocaleDateString()}
</span>
</div>
</div>
</div>
</div>
{/* Warning Box */}
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-2xl">🚨</span>
<div className="flex-1">
<h3 className="font-semibold text-red-900 mb-2">
Warning: This action is permanent
</h3>
<ul className="space-y-1 text-sm text-red-800">
<li> All indexed pages will be permanently deleted</li>
<li> Search index will be destroyed</li>
<li> All usage statistics will be lost</li>
<li> API key will be revoked immediately</li>
<li> This action cannot be undone</li>
</ul>
</div>
</div>
</div>
{/* Delete Error */}
{deleteError && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-xl"></span>
<div className="flex-1">
<p className="text-sm text-red-800">{deleteError}</p>
</div>
</div>
</div>
)}
{/* Confirmation Form */}
<form onSubmit={handleDeleteSite}>
<div className="mb-6">
<label
htmlFor="confirmationText"
className="block text-sm font-medium text-gray-700 mb-2"
>
Type <span className="font-mono font-bold text-red-600">{site.domain}</span> to confirm deletion:
</label>
<input
type="text"
id="confirmationText"
value={confirmationText}
onChange={(e) => {
setConfirmationText(e.target.value);
setDeleteError(null);
}}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
placeholder={site.domain}
disabled={isDeleting}
autoFocus
/>
<p className="mt-1 text-sm text-gray-500">
Please type the domain name exactly as shown above to confirm
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3">
<button
type="submit"
disabled={isDeleting || confirmationText !== site.domain}
className="flex-1 px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700
transition-all font-medium shadow-lg hover:shadow-xl
disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeleting ? "Deleting Site..." : "Delete Site Permanently"}
</button>
<button
type="button"
onClick={handleCancel}
disabled={isDeleting}
className="px-6 py-3 text-gray-700 border border-gray-300 rounded-lg
hover:bg-gray-50 transition-all font-medium
disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</form>
{/* Additional Information */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-xl">💡</span>
<div className="flex-1">
<h4 className="font-semibold text-blue-900 mb-1">
Need to reconnect later?
</h4>
<p className="text-sm text-blue-800">
If you delete this site and want to reconnect it later, you'll need to register
it again and configure a new API key in your WordPress plugin.
</p>
</div>
</div>
</div>
</div>
</div>
)}
</main>
</div>
);
}
export default DeleteSite;

View file

@ -0,0 +1,528 @@
// File: src/pages/Sites/RotateApiKey.jsx
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { useAuth } from "../../services/Services";
import SiteService from "../../services/API/SiteService";
/**
* RotateApiKey - Page for rotating a site's API key
*/
function RotateApiKey() {
const navigate = useNavigate();
const { id } = useParams();
const { authManager } = useAuth();
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [site, setSite] = useState(null);
const [siteLoading, setSiteLoading] = useState(false);
const [siteError, setSiteError] = useState(null);
const [isRotating, setIsRotating] = useState(false);
const [rotateError, setRotateError] = useState(null);
const [rotationResult, setRotationResult] = useState(null);
const [confirmationText, setConfirmationText] = useState("");
const [apiKeyCopied, setApiKeyCopied] = useState(false);
useEffect(() => {
// Wait for AuthManager to initialize before checking authentication
const checkAuth = async () => {
const isInitialized = authManager.getIsInitialized();
if (!isInitialized) {
setTimeout(checkAuth, 50);
return;
}
const isAuth = authManager.isAuthenticated();
if (!isAuth) {
navigate("/login");
return;
}
// Proactively refresh token if needed
try {
await authManager.ensureValidToken();
} catch (error) {
console.error("[RotateApiKey] Token refresh failed:", error);
navigate("/login");
return;
}
const userData = authManager.getUser();
setUser(userData);
setIsLoading(false);
};
checkAuth();
}, [authManager, navigate]);
// Background token refresh - check every 30 seconds
useEffect(() => {
const refreshInterval = setInterval(async () => {
if (!authManager.getIsInitialized()) {
return;
}
try {
await authManager.ensureValidToken();
} catch (error) {
console.error("[RotateApiKey] Background token refresh failed:", error);
navigate("/login");
}
}, 30000);
return () => clearInterval(refreshInterval);
}, [authManager, navigate]);
// Load site details
useEffect(() => {
if (!user || isLoading || !id) {
return;
}
const loadSite = async () => {
setSiteLoading(true);
setSiteError(null);
console.log("[RotateApiKey] Loading site:", id);
try {
const siteData = await SiteService.getSiteById(id);
console.log("[RotateApiKey] Site loaded:", siteData);
setSite(siteData);
} catch (error) {
console.error("[RotateApiKey] Failed to load site:", error);
setSiteError(error.message || "Failed to load site details");
} finally {
setSiteLoading(false);
}
};
loadSite();
}, [user, isLoading, id]);
const handleRotateApiKey = async (e) => {
e.preventDefault();
// Validate confirmation text
if (confirmationText.toLowerCase() !== "rotate") {
setRotateError('Please type "ROTATE" to confirm');
return;
}
setIsRotating(true);
setRotateError(null);
console.log("[RotateApiKey] Rotating API key for site:", id);
try {
const result = await SiteService.rotateApiKey(id);
console.log("[RotateApiKey] API key rotated successfully");
setRotationResult(result);
} catch (error) {
console.error("[RotateApiKey] Failed to rotate API key:", error);
setRotateError(error.message || "Failed to rotate API key. Please try again.");
setIsRotating(false);
}
};
const handleCopyApiKey = () => {
if (rotationResult?.newApiKey) {
navigator.clipboard.writeText(rotationResult.newApiKey);
setApiKeyCopied(true);
setTimeout(() => setApiKeyCopied(false), 3000);
}
};
const handleDone = () => {
navigate(`/sites/${id}`);
};
const handleCancel = () => {
navigate(`/sites/${id}`);
};
// Show loading state while waiting for authentication check
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
{/* Navigation Bar */}
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<button
onClick={() => navigate("/dashboard")}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-2xl">🍁</span>
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</button>
{/* User Menu */}
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-lg">
<div className="w-8 h-8 bg-gradient-to-r from-indigo-600 to-blue-600 rounded-full
flex items-center justify-center text-white text-sm font-semibold">
{user?.email?.[0]?.toUpperCase() || "U"}
</div>
<span className="text-sm font-medium text-gray-700">
{user?.email || "User"}
</span>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Back Button */}
<button
onClick={handleCancel}
className="text-indigo-600 hover:text-indigo-700 font-medium mb-6 flex items-center gap-2"
>
<span></span>
<span>Back to Site Details</span>
</button>
{/* Loading State */}
{siteLoading && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-12 text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading site details...</p>
</div>
)}
{/* Error State */}
{!siteLoading && siteError && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-12 text-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<span className="text-4xl"></span>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">
Failed to Load Site
</h2>
<p className="text-gray-600 mb-6">{siteError}</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white
rounded-lg hover:from-indigo-700 hover:to-blue-700 transition-all font-medium"
>
Retry
</button>
<button
onClick={() => navigate("/dashboard")}
className="px-6 py-3 text-gray-700 border border-gray-300 rounded-lg
hover:bg-gray-50 transition-all font-medium"
>
Back to Dashboard
</button>
</div>
</div>
)}
{/* Success State - Show New API Key */}
{!siteLoading && !siteError && site && rotationResult && (
<div className="bg-white rounded-2xl shadow-lg border border-green-200 overflow-hidden">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-600 to-emerald-600 px-6 py-8">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-white rounded-lg flex items-center justify-center text-4xl">
</div>
<div>
<h1 className="text-3xl font-bold text-white">
API Key Rotated Successfully
</h1>
<p className="text-green-100 mt-1">
Your new API key has been generated
</p>
</div>
</div>
</div>
{/* New API Key Display */}
<div className="p-8">
<div className="mb-6 p-6 bg-yellow-50 border-2 border-yellow-400 rounded-lg">
<div className="flex items-start gap-3 mb-4">
<span className="text-3xl"></span>
<div className="flex-1">
<h3 className="font-bold text-yellow-900 text-lg mb-2">
Save Your New API Key Now!
</h3>
<p className="text-sm text-yellow-800 mb-2">
This is the <strong>only time</strong> you'll be able to see this API key.
Copy it now and update your WordPress plugin settings immediately.
</p>
</div>
</div>
{/* API Key Display with Copy Button */}
<div className="bg-white border border-yellow-300 rounded-lg p-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
New API Key:
</label>
<div className="flex gap-2">
<input
type="text"
value={rotationResult.newApiKey}
readOnly
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg bg-gray-50 font-mono text-sm"
/>
<button
onClick={handleCopyApiKey}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700
transition-all font-medium whitespace-nowrap"
>
{apiKeyCopied ? "Copied!" : "Copy"}
</button>
</div>
</div>
</div>
{/* Immediate Action Required */}
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-2xl">🚨</span>
<div className="flex-1">
<h4 className="font-semibold text-red-900 mb-2">
Old API Key Immediately Invalidated
</h4>
<p className="text-sm text-red-800 mb-2">
Your old API key (ending in <span className="font-mono font-bold">{rotationResult.oldKeyLastFour}</span>)
has been <strong>immediately deactivated</strong> and will no longer work.
</p>
<p className="text-sm text-red-800 mt-2">
You must update your WordPress plugin with the new API key <strong>right now</strong> to restore functionality.
</p>
</div>
</div>
</div>
{/* Site Information */}
<div className="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h4 className="font-semibold text-gray-900 mb-3">Site Details:</h4>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-600">Domain:</span>
<span className="font-semibold text-gray-900">{site.domain}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-600">Site URL:</span>
<span className="font-semibold text-gray-900">{site.siteUrl}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-600">Rotated At:</span>
<span className="font-semibold text-gray-900">{rotationResult.rotatedAt.toLocaleString()}</span>
</div>
</div>
</div>
{/* Next Steps */}
<div className="mb-6 p-4 bg-indigo-50 border border-indigo-200 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-2xl">📝</span>
<div className="flex-1">
<h4 className="font-semibold text-indigo-900 mb-2">
Next Steps
</h4>
<ol className="space-y-2 text-sm text-indigo-800">
<li>1. Copy the new API key using the button above</li>
<li>2. Log in to your WordPress admin panel</li>
<li>3. Go to Settings MaplePress</li>
<li>4. Paste the new API key in the settings</li>
<li>5. Save the settings to activate the new key</li>
</ol>
</div>
</div>
</div>
{/* Done Button */}
<div className="flex gap-3">
<button
onClick={handleDone}
className="flex-1 px-6 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white
rounded-lg hover:from-indigo-700 hover:to-blue-700 transition-all
font-medium shadow-lg hover:shadow-xl"
>
Done - Return to Site Details
</button>
</div>
</div>
</div>
)}
{/* Rotation Confirmation Form */}
{!siteLoading && !siteError && site && !rotationResult && (
<div className="bg-white rounded-2xl shadow-lg border border-yellow-200 overflow-hidden">
{/* Warning Header */}
<div className="bg-gradient-to-r from-yellow-600 to-amber-600 px-6 py-8">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-white rounded-lg flex items-center justify-center text-4xl">
🔑
</div>
<div>
<h1 className="text-3xl font-bold text-white">
Rotate API Key
</h1>
<p className="text-yellow-100 mt-1">
Generate a new API key for your site
</p>
</div>
</div>
</div>
{/* Rotation Form */}
<div className="p-8">
{/* Site Information */}
<div className="mb-6 p-6 bg-gray-50 rounded-lg border border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Site Information:
</h2>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500 to-blue-500 rounded-lg
flex items-center justify-center text-white font-bold text-lg">
{site.domain[0]?.toUpperCase() || "W"}
</div>
<div>
<div className="font-semibold text-gray-900 flex items-center gap-2">
{site.domain}
{site.isVerified && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Verified
</span>
)}
</div>
<div className="text-sm text-gray-600">{site.siteUrl}</div>
</div>
</div>
<div className="mt-4 text-sm">
<span className="text-gray-600">Current API Key:</span>
<span className="ml-2 font-mono font-semibold text-gray-900">
{site.apiKeyPrefix}{site.apiKeyLastFour}
</span>
</div>
</div>
</div>
{/* Warning Box */}
<div className="mb-6 p-4 bg-red-50 border border-red-300 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-2xl">🚨</span>
<div className="flex-1">
<h3 className="font-semibold text-red-900 mb-2">
Critical: What Happens When You Rotate
</h3>
<ul className="space-y-1 text-sm text-red-800">
<li> A new API key will be generated <strong>immediately</strong></li>
<li> The old API key will be <strong>invalidated instantly</strong> - no grace period!</li>
<li> Your WordPress site functionality will stop working until you update the key</li>
<li> You must update your WordPress plugin settings with the new key right away</li>
<li> The new key will be shown only once - save it immediately!</li>
</ul>
</div>
</div>
</div>
{/* When to Rotate */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-2xl">💡</span>
<div className="flex-1">
<h4 className="font-semibold text-blue-900 mb-2">
When Should You Rotate Your API Key?
</h4>
<ul className="space-y-1 text-sm text-blue-800">
<li> Your API key has been compromised or exposed</li>
<li> You suspect unauthorized access to your site</li>
<li> As part of regular security maintenance</li>
<li> When removing access for a third party</li>
</ul>
</div>
</div>
</div>
{/* Rotation Error */}
{rotateError && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-xl"></span>
<div className="flex-1">
<p className="text-sm text-red-800">{rotateError}</p>
</div>
</div>
</div>
)}
{/* Confirmation Form */}
<form onSubmit={handleRotateApiKey}>
<div className="mb-6">
<label
htmlFor="confirmationText"
className="block text-sm font-medium text-gray-700 mb-2"
>
Type <span className="font-mono font-bold text-yellow-600">ROTATE</span> to confirm:
</label>
<input
type="text"
id="confirmationText"
value={confirmationText}
onChange={(e) => {
setConfirmationText(e.target.value);
setRotateError(null);
}}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
placeholder="ROTATE"
disabled={isRotating}
autoFocus
/>
<p className="mt-1 text-sm text-gray-500">
Type "ROTATE" in capital letters to confirm the rotation
</p>
</div>
{/* Action Buttons */}
<div className="flex gap-3">
<button
type="submit"
disabled={isRotating || confirmationText.toLowerCase() !== "rotate"}
className="flex-1 px-6 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700
transition-all font-medium shadow-lg hover:shadow-xl
disabled:opacity-50 disabled:cursor-not-allowed"
>
{isRotating ? "Rotating API Key..." : "Rotate API Key"}
</button>
<button
type="button"
onClick={handleCancel}
disabled={isRotating}
className="px-6 py-3 text-gray-700 border border-gray-300 rounded-lg
hover:bg-gray-50 transition-all font-medium
disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</main>
</div>
);
}
export default RotateApiKey;

View file

@ -0,0 +1,348 @@
// File: src/pages/Sites/SiteDetail.jsx
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { useAuth } from "../../services/Services";
import SiteService from "../../services/API/SiteService";
/**
* SiteDetail - Detailed view of a WordPress site with management options
*/
function SiteDetail() {
const navigate = useNavigate();
const { id } = useParams();
const { authManager } = useAuth();
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [site, setSite] = useState(null);
const [siteLoading, setSiteLoading] = useState(false);
const [siteError, setSiteError] = useState(null);
useEffect(() => {
// Wait for AuthManager to initialize before checking authentication
const checkAuth = async () => {
const isInitialized = authManager.getIsInitialized();
if (!isInitialized) {
setTimeout(checkAuth, 50);
return;
}
const isAuth = authManager.isAuthenticated();
if (!isAuth) {
navigate("/login");
return;
}
// Proactively refresh token if needed
try {
await authManager.ensureValidToken();
} catch (error) {
console.error("[SiteDetail] Token refresh failed:", error);
navigate("/login");
return;
}
const userData = authManager.getUser();
setUser(userData);
setIsLoading(false);
};
checkAuth();
}, [authManager, navigate]);
// Background token refresh - check every 30 seconds
useEffect(() => {
const refreshInterval = setInterval(async () => {
if (!authManager.getIsInitialized()) {
return;
}
try {
await authManager.ensureValidToken();
} catch (error) {
console.error("[SiteDetail] Background token refresh failed:", error);
navigate("/login");
}
}, 30000);
return () => clearInterval(refreshInterval);
}, [authManager, navigate]);
// Load site details
useEffect(() => {
if (!user || isLoading || !id) {
return;
}
const loadSite = async () => {
setSiteLoading(true);
setSiteError(null);
console.log("[SiteDetail] Loading site:", id);
try {
const siteData = await SiteService.getSiteById(id);
console.log("[SiteDetail] Site loaded:", siteData);
setSite(siteData);
} catch (error) {
console.error("[SiteDetail] Failed to load site:", error);
setSiteError(error.message || "Failed to load site details");
} finally {
setSiteLoading(false);
}
};
loadSite();
}, [user, isLoading, id]);
const handleNavigateToDelete = () => {
navigate(`/sites/${id}/delete`);
};
const handleNavigateToRotateKey = () => {
navigate(`/sites/${id}/rotate-key`);
};
// Show loading state while waiting for authentication check
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100">
{/* Navigation Bar */}
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<button
onClick={() => navigate("/dashboard")}
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-2xl">🍁</span>
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-600 bg-clip-text text-transparent">
MaplePress
</h1>
</button>
{/* User Menu */}
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-lg">
<div className="w-8 h-8 bg-gradient-to-r from-indigo-600 to-blue-600 rounded-full
flex items-center justify-center text-white text-sm font-semibold">
{user?.email?.[0]?.toUpperCase() || "U"}
</div>
<span className="text-sm font-medium text-gray-700">
{user?.email || "User"}
</span>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Back Button */}
<button
onClick={() => navigate("/dashboard")}
className="text-indigo-600 hover:text-indigo-700 font-medium mb-6 flex items-center gap-2"
>
<span></span>
<span>Back to Dashboard</span>
</button>
{/* Loading State */}
{siteLoading && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-12 text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-indigo-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600">Loading site details...</p>
</div>
)}
{/* Error State */}
{!siteLoading && siteError && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-12 text-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<span className="text-4xl"></span>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">
Failed to Load Site
</h2>
<p className="text-gray-600 mb-6">{siteError}</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-blue-600 text-white
rounded-lg hover:from-indigo-700 hover:to-blue-700 transition-all font-medium"
>
Retry
</button>
<button
onClick={() => navigate("/dashboard")}
className="px-6 py-3 text-gray-700 border border-gray-300 rounded-lg
hover:bg-gray-50 transition-all font-medium"
>
Back to Dashboard
</button>
</div>
</div>
)}
{/* Site Details */}
{!siteLoading && !siteError && site && (
<div className="space-y-6">
{/* Site Header */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="bg-gradient-to-r from-indigo-600 to-blue-600 px-6 py-8">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-white rounded-lg flex items-center justify-center text-indigo-600 font-bold text-2xl">
{site.domain[0]?.toUpperCase() || "W"}
</div>
<div>
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
{site.domain}
{site.isVerified && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-400 text-green-900">
Verified
</span>
)}
{!site.isVerified && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-400 text-yellow-900">
Pending Verification
</span>
)}
</h1>
<p className="text-indigo-100 mt-1">{site.siteUrl}</p>
</div>
</div>
</div>
</div>
{/* Site Information */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Site Information</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Site ID</label>
<p className="text-gray-900 font-mono text-sm">{site.id}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Status</label>
<p className="text-gray-900">{site.status}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">API Key</label>
<p className="text-gray-900 font-mono text-sm">
{site.apiKeyPrefix}{site.apiKeyLastFour}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Search Index</label>
<p className="text-gray-900 font-mono text-sm">{site.searchIndexName}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Created</label>
<p className="text-gray-900">{site.createdAt.toLocaleString()}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Last Updated</label>
<p className="text-gray-900">{site.updatedAt.toLocaleString()}</p>
</div>
</div>
</div>
</div>
{/* Usage Statistics */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Usage Statistics</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center p-4 bg-gradient-to-br from-indigo-50 to-blue-50 rounded-lg">
<div className="text-3xl font-bold text-indigo-600">{site.totalPagesIndexed}</div>
<div className="text-sm text-gray-600 mt-1">Total Pages Indexed</div>
</div>
<div className="text-center p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg">
<div className="text-3xl font-bold text-green-600">{site.searchRequestsCount}</div>
<div className="text-sm text-gray-600 mt-1">Search Requests</div>
</div>
<div className="text-center p-4 bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg">
<div className="text-3xl font-bold text-purple-600">
{SiteService.formatStorage(site.storageUsedBytes)}
</div>
<div className="text-sm text-gray-600 mt-1">Storage Used</div>
</div>
</div>
{site.lastIndexedAt && (
<div className="mt-4 text-center text-sm text-gray-600">
Last indexed: {site.lastIndexedAt.toLocaleString()}
</div>
)}
{site.pluginVersion && (
<div className="mt-2 text-center text-sm text-gray-600">
Plugin version: {site.pluginVersion}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Site Management</h2>
</div>
<div className="p-6 space-y-4">
{/* Rotate API Key */}
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div>
<h3 className="font-semibold text-gray-900">Rotate API Key</h3>
<p className="text-sm text-gray-600">
Generate a new API key if the current one is compromised
</p>
</div>
<button
onClick={handleNavigateToRotateKey}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700
transition-all font-medium"
>
Rotate Key
</button>
</div>
{/* Delete Site */}
<div className="flex items-center justify-between p-4 border border-red-200 rounded-lg bg-red-50">
<div>
<h3 className="font-semibold text-gray-900">Delete Site</h3>
<p className="text-sm text-gray-600">
Permanently delete this site and all associated data
</p>
</div>
<button
onClick={handleNavigateToDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700
transition-all font-medium"
>
Delete Site
</button>
</div>
</div>
</div>
</div>
)}
</main>
</div>
);
}
export default SiteDetail;

View file

@ -0,0 +1,266 @@
// File: src/services/API/AdminService.js
import ApiClient from "./ApiClient";
/**
* AdminService - Handles Admin API requests
*
* Backend API:
* - GET /api/v1/admin/account-status (Check Account Lock Status)
* - POST /api/v1/admin/unlock-account (Unlock Locked Account)
*
* Source Files:
* - cloud/maplepress-backend/internal/interface/http/handler/admin/account_status_handler.go
* - cloud/maplepress-backend/internal/interface/http/handler/admin/unlock_account_handler.go
*
* Purpose: Admin operations for account management, specifically handling
* account lockouts due to failed login attempts (CWE-307 protection).
*
* IMPORTANT: These endpoints require admin authentication.
* Only users with admin/root roles should have access to these operations.
*/
/**
* Check if a user account is locked due to failed login attempts
*
* @param {string} email - User's email address to check
* @returns {Promise<Object>} Account lock status and details
* @throws {Error} If check fails or validation errors occur
*
* Response format (transformed to camelCase):
* {
* email: string, // User's email
* isLocked: boolean, // Whether account is locked
* failedAttempts: number, // Number of failed login attempts
* remainingTime: string, // Human-readable time until unlock (e.g., "5 minutes 30 seconds")
* remainingSeconds: number // Seconds until automatic unlock
* }
*/
async function getAccountStatus(email) {
// Validate email
if (!email || typeof email !== "string") {
throw new Error("Email is required");
}
const trimmedEmail = email.trim();
if (trimmedEmail.length === 0) {
throw new Error("Email cannot be empty");
}
// Basic email format validation
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(trimmedEmail)) {
throw new Error("Invalid email format");
}
try {
// Make authenticated GET request with email as query parameter
const response = await ApiClient.get("/api/v1/admin/account-status", {
params: { email: trimmedEmail },
});
// Transform response to camelCase for frontend
return {
email: response.email,
isLocked: response.is_locked,
failedAttempts: response.failed_attempts,
remainingTime: response.remaining_time || null,
remainingSeconds: response.remaining_seconds || 0,
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Admin authentication required. Please log in with admin credentials."
);
}
if (message.includes("forbidden") || message.includes("403")) {
throw new Error(
"Access denied. Admin privileges required for this operation."
);
}
if (message.includes("email")) {
throw new Error("Invalid email address provided.");
}
// Generic error
throw new Error(
error.message || "Failed to check account status. Please try again."
);
}
}
/**
* Unlock a user account that has been locked due to failed login attempts
*
* @param {string} email - User's email address to unlock
* @returns {Promise<Object>} Unlock operation result
* @throws {Error} If unlock fails or validation errors occur
*
* Response format (transformed to camelCase):
* {
* success: boolean,
* message: string,
* email: string
* }
*
* Security Event: This operation logs a security event (ACCOUNT_UNLOCKED)
* with the admin user ID who performed the unlock operation.
*/
async function unlockAccount(email) {
// Validate email
if (!email || typeof email !== "string") {
throw new Error("Email is required");
}
const trimmedEmail = email.trim();
if (trimmedEmail.length === 0) {
throw new Error("Email cannot be empty");
}
// Basic email format validation
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(trimmedEmail)) {
throw new Error("Invalid email format");
}
// Prepare request body
const requestBody = {
email: trimmedEmail,
};
try {
// Make authenticated POST request
const response = await ApiClient.post(
"/api/v1/admin/unlock-account",
requestBody
);
// Transform response to camelCase for frontend
return {
success: response.success,
message: response.message,
email: response.email,
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Admin authentication required. Please log in with admin credentials."
);
}
if (message.includes("forbidden") || message.includes("403")) {
throw new Error(
"Access denied. Admin privileges required for this operation."
);
}
if (message.includes("email")) {
throw new Error("Invalid email address provided.");
}
if (message.includes("not locked")) {
throw new Error("Account is not currently locked.");
}
// Generic error
throw new Error(
error.message || "Failed to unlock account. Please try again."
);
}
}
/**
* Check if an account needs unlocking (is locked with remaining time)
*
* @param {string} email - User's email address to check
* @returns {Promise<boolean>} True if account is locked and needs admin unlock
* @throws {Error} If check fails
*/
async function needsUnlock(email) {
try {
const status = await getAccountStatus(email);
return status.isLocked && status.remainingSeconds > 0;
} catch (error) {
// If we can't check status, assume no unlock needed
console.error("Failed to check if account needs unlock:", error);
return false;
}
}
/**
* Validate email format
*
* @param {string} email - Email to validate
* @returns {Object} { valid: boolean, error: string|null }
*/
function validateEmail(email) {
if (!email || typeof email !== "string") {
return { valid: false, error: "Email is required" };
}
const trimmedEmail = email.trim();
if (trimmedEmail.length === 0) {
return { valid: false, error: "Email cannot be empty" };
}
// Check email format
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(trimmedEmail)) {
return { valid: false, error: "Invalid email format" };
}
if (trimmedEmail.length > 255) {
return { valid: false, error: "Email must be 255 characters or less" };
}
return { valid: true, error: null };
}
/**
* Format remaining time for display
*
* @param {number} seconds - Seconds remaining
* @returns {string} Formatted time string (e.g., "5 minutes 30 seconds")
*/
function formatRemainingTime(seconds) {
if (seconds <= 0) {
return "0 seconds";
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const parts = [];
if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
}
if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
}
if (secs > 0 || parts.length === 0) {
parts.push(`${secs} ${secs === 1 ? "second" : "seconds"}`);
}
return parts.join(" ");
}
// Export service
const AdminService = {
getAccountStatus,
unlockAccount,
needsUnlock,
validateEmail,
formatRemainingTime,
};
export default AdminService;

View file

@ -0,0 +1,236 @@
// File: src/services/API/ApiClient.js
/**
* ApiClient
*
* Central HTTP client for making API requests to the MaplePress backend.
* Handles authentication, error handling, and request/response transformation.
*/
// Auth manager getter function (will be set during service initialization)
// This is a function that returns the current authManager instance
let getAuthManager = null;
/**
* Set the auth manager getter for API client to use
* @param {Function} getter - Function that returns the current AuthManager instance
*/
export function setApiClientAuthManager(getter) {
getAuthManager = getter;
console.log("[ApiClient] AuthManager getter set");
}
/**
* API Configuration
*/
const API_CONFIG = {
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8000",
timeout: 30000,
};
/**
* Make an authenticated API request
* Automatically refreshes access token if needed before making the request
*/
async function makeRequest(method, endpoint, options = {}) {
const { body, headers = {}, params, skipTokenRefresh = false } = options;
// Automatically refresh token if needed (unless explicitly skipped)
// Skip for refresh endpoint itself to avoid infinite loops
if (!skipTokenRefresh && endpoint !== "/api/v1/refresh") {
if (getAuthManager) {
const authManager = getAuthManager();
console.log("[ApiClient] 🔍 Retrieved AuthManager via getter:", {
isInitialized: authManager?.getIsInitialized(),
isAuthenticated: authManager?.isAuthenticated(),
});
// Wait for initialization if needed
if (!authManager.getIsInitialized()) {
console.log("[ApiClient] ⏳ Waiting for AuthManager initialization before token check...");
// Wait up to 2 seconds for initialization
for (let i = 0; i < 40; i++) {
if (authManager.getIsInitialized()) break;
await new Promise(resolve => setTimeout(resolve, 50));
}
console.log("[ApiClient] ✅ AuthManager initialized after waiting");
}
if (authManager.isAuthenticated()) {
try {
await authManager.ensureValidToken();
} catch (error) {
console.error("[ApiClient] Token refresh failed:", error);
// Don't throw here - let the request proceed and handle 401 if it occurs
}
}
}
}
// Build URL with query parameters
let url = `${API_CONFIG.baseURL}${endpoint}`;
if (params) {
const queryString = new URLSearchParams(params).toString();
url += `?${queryString}`;
}
// Build request headers
const requestHeaders = {
"Content-Type": "application/json",
...headers,
};
// Add authentication token if available
// Use JWT prefix for access token (backend requirement)
if (getAuthManager) {
const authManager = getAuthManager();
// Wait for initialization if needed
if (!authManager.getIsInitialized()) {
console.log("[ApiClient] Waiting for AuthManager initialization...");
// Wait up to 2 seconds for initialization
for (let i = 0; i < 40; i++) {
if (authManager.getIsInitialized()) break;
await new Promise(resolve => setTimeout(resolve, 50));
}
}
if (authManager.isAuthenticated()) {
const token = authManager.getAccessToken();
requestHeaders["Authorization"] = `JWT ${token}`;
console.log("[ApiClient] Added JWT token to request");
} else {
console.log("[ApiClient] Not authenticated, skipping token");
}
} else {
console.log("[ApiClient] No authManager getter available");
}
// Build request options
const requestOptions = {
method,
headers: requestHeaders,
};
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
requestOptions.body = JSON.stringify(body);
}
try {
console.log(`[ApiClient] ${method} ${endpoint}`);
const response = await fetch(url, requestOptions);
// Handle non-OK responses
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
// Handle 401 Unauthorized - token may have expired despite refresh attempt
if (response.status === 401 && getAuthManager && !skipTokenRefresh && endpoint !== "/api/v1/refresh") {
console.log("[ApiClient] ⚠️ Received 401, attempting token refresh and retry");
const authManager = getAuthManager();
// Check if authManager is initialized
if (!authManager.getIsInitialized()) {
console.error("[ApiClient] ❌ AuthManager not initialized, cannot refresh");
throw new Error("Session expired. Please log in again.");
}
console.log("[ApiClient] 🔍 AuthManager state before refresh:", {
isAuthenticated: authManager.isAuthenticated(),
hasAccessToken: !!authManager.getAccessToken(),
});
try {
// Force token refresh
await authManager.refreshAccessToken();
// Retry the request once with the new token
console.log("[ApiClient] ✅ Retrying request with refreshed token");
return makeRequest(method, endpoint, { ...options, skipTokenRefresh: true });
} catch (refreshError) {
console.error("[ApiClient] ❌ Token refresh on 401 failed:", refreshError);
throw new Error("Session expired. Please log in again.");
}
}
// Create error with RFC 9457 data if available
const error = new Error(
errorData.detail || errorData.message || `HTTP ${response.status}: ${response.statusText}`
);
// Attach RFC 9457 fields to error for parsing
if (errorData.errors) {
error.validationErrors = errorData.errors; // RFC 9457 validation errors
}
if (errorData.title) {
error.title = errorData.title;
}
if (errorData.status) {
error.status = errorData.status;
}
throw error;
}
// Parse response
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
return await response.json();
}
return await response.text();
} catch (error) {
console.error(`[ApiClient] Request failed:`, error);
throw error;
}
}
/**
* API Client Methods
*/
const ApiClient = {
/**
* GET request
*/
get(endpoint, options = {}) {
return makeRequest("GET", endpoint, options);
},
/**
* POST request
*/
post(endpoint, body, options = {}) {
return makeRequest("POST", endpoint, { ...options, body });
},
/**
* PUT request
*/
put(endpoint, body, options = {}) {
return makeRequest("PUT", endpoint, { ...options, body });
},
/**
* PATCH request
*/
patch(endpoint, body, options = {}) {
return makeRequest("PATCH", endpoint, { ...options, body });
},
/**
* DELETE request
*/
delete(endpoint, options = {}) {
return makeRequest("DELETE", endpoint, options);
},
/**
* Get API configuration
*/
getConfig() {
return API_CONFIG;
},
};
export default ApiClient;

View file

@ -0,0 +1,205 @@
// File: src/services/API/HealthService.js
import ApiClient from "./ApiClient";
/**
* HealthService - Handles Health Check API requests
*
* Backend API: GET /health
* Documentation: cloud/maplepress-backend/docs/API.md (lines 38-60)
*
* Backend Handler: cloud/maplepress-backend/internal/interface/http/handler/healthcheck/healthcheck_handler.go
*
* Purpose: Check if the MaplePress backend service is running and healthy.
* This is a simple endpoint that requires no authentication and can be used for:
* - Monitoring service availability
* - Load balancer health checks
* - Application startup verification
* - API connectivity testing
*/
/**
* Check if the backend service is healthy
*
* @returns {Promise<Object>} Health status response
* @throws {Error} If the service is unreachable or unhealthy
*
* Response format:
* {
* status: string // "healthy" if service is running
* }
*
* Usage Example:
* ```javascript
* try {
* const health = await HealthService.checkHealth();
* if (health.status === 'healthy') {
* console.log('Backend is healthy');
* }
* } catch (error) {
* console.error('Backend is down:', error.message);
* }
* ```
*/
async function checkHealth() {
try {
// Make unauthenticated GET request to health endpoint
const response = await ApiClient.get("/health");
// Return status
return {
status: response.status,
};
} catch (error) {
// Map errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
// Network or connection errors
if (
message.includes("network") ||
message.includes("fetch") ||
message.includes("failed to fetch")
) {
throw new Error(
"Unable to connect to backend service. Please check your network connection."
);
}
// Service unavailable
if (message.includes("503") || message.includes("unavailable")) {
throw new Error("Backend service is temporarily unavailable.");
}
// Generic error
throw new Error(
error.message || "Failed to check backend health status."
);
}
}
/**
* Check if the backend is healthy (simple boolean check)
*
* @returns {Promise<boolean>} True if healthy, false otherwise
*
* Usage Example:
* ```javascript
* const isHealthy = await HealthService.isHealthy();
* if (isHealthy) {
* console.log('Backend is ready');
* }
* ```
*/
async function isHealthy() {
try {
const health = await checkHealth();
return health.status === "healthy";
} catch (error) {
// If we can't reach the service, it's not healthy
console.error("Health check failed:", error);
return false;
}
}
/**
* Wait for the backend to become healthy (useful for startup)
*
* @param {Object} options - Wait options
* @param {number} options.maxAttempts - Maximum number of attempts (default: 30)
* @param {number} options.retryDelayMs - Delay between attempts in ms (default: 1000)
* @returns {Promise<boolean>} True if backend became healthy, false if timeout
*
* Usage Example:
* ```javascript
* const ready = await HealthService.waitUntilHealthy({ maxAttempts: 10 });
* if (ready) {
* console.log('Backend is ready!');
* } else {
* console.error('Backend did not become ready in time');
* }
* ```
*/
async function waitUntilHealthy(options = {}) {
const { maxAttempts = 30, retryDelayMs = 1000 } = options;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const healthy = await isHealthy();
if (healthy) {
console.log(`[HealthService] Backend is healthy (attempt ${attempt})`);
return true;
}
} catch (error) {
console.log(
`[HealthService] Health check attempt ${attempt}/${maxAttempts} failed:`,
error.message
);
}
// Wait before next attempt (except on last attempt)
if (attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
console.error(
`[HealthService] Backend did not become healthy after ${maxAttempts} attempts`
);
return false;
}
/**
* Get detailed backend status with timing information
*
* @returns {Promise<Object>} Detailed health status
*
* Response format:
* {
* healthy: boolean, // Whether backend is healthy
* status: string, // Status message
* responseTimeMs: number, // Response time in milliseconds
* timestamp: Date // When check was performed
* }
*
* Usage Example:
* ```javascript
* const status = await HealthService.getDetailedStatus();
* console.log(`Backend responded in ${status.responseTimeMs}ms`);
* ```
*/
async function getDetailedStatus() {
const startTime = Date.now();
const timestamp = new Date();
try {
const health = await checkHealth();
const responseTimeMs = Date.now() - startTime;
return {
healthy: health.status === "healthy",
status: health.status,
responseTimeMs,
timestamp,
};
} catch (error) {
const responseTimeMs = Date.now() - startTime;
return {
healthy: false,
status: "unhealthy",
error: error.message,
responseTimeMs,
timestamp,
};
}
}
// Export service
const HealthService = {
checkHealth,
isHealthy,
waitUntilHealthy,
getDetailedStatus,
};
export default HealthService;

View file

@ -0,0 +1,124 @@
// File: src/services/API/HelloService.js
import ApiClient from "./ApiClient";
/**
* HelloService - Handles Hello API requests
*
* Backend API: POST /api/v1/hello
* Documentation: cloud/maplepress-backend/docs/API.md (lines 326-372)
*
* Purpose: A simple authenticated endpoint that returns a personalized greeting message.
* This endpoint demonstrates JWT authentication and can be used to verify that your
* access token is working correctly.
*/
/**
* Send a hello request with a name
*
* @param {string} name - Name to include in greeting (1-100 characters, printable only)
* @returns {Promise<Object>} Greeting message response
* @throws {Error} If name is invalid or authentication fails
*
* Response format:
* {
* message: string // e.g., "Hello, Alice! Welcome to MaplePress Backend."
* }
*/
async function hello(name) {
// Validate input
if (!name || typeof name !== "string") {
throw new Error("Name is required");
}
const trimmedName = name.trim();
if (trimmedName.length === 0) {
throw new Error("Name cannot be empty");
}
if (trimmedName.length > 100) {
throw new Error("Name must be 100 characters or less");
}
// Basic validation for printable characters (no HTML tags)
if (/<[^>]*>/.test(trimmedName)) {
throw new Error("Name cannot contain HTML tags");
}
// Prepare request body (snake_case for backend)
const requestBody = {
name: trimmedName,
};
try {
// Make API request (authenticated)
const response = await ApiClient.post("/api/v1/hello", requestBody);
// Response is already in the correct format (message field)
return {
message: response.message,
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("name")) {
throw new Error("Invalid name provided. Please check your input.");
}
// Generic error
throw new Error(
error.message || "Failed to send hello request. Please try again."
);
}
}
/**
* Validate a name before sending (client-side validation)
*
* @param {string} name - Name to validate
* @returns {Object} { valid: boolean, error: string|null }
*/
function validateName(name) {
if (!name || typeof name !== "string") {
return { valid: false, error: "Name is required" };
}
const trimmedName = name.trim();
if (trimmedName.length === 0) {
return { valid: false, error: "Name cannot be empty" };
}
if (trimmedName.length > 100) {
return { valid: false, error: "Name must be 100 characters or less" };
}
// Check for HTML tags
if (/<[^>]*>/.test(trimmedName)) {
return { valid: false, error: "Name cannot contain HTML tags" };
}
// Check for non-printable characters
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1F\x7F-\x9F]/.test(trimmedName)) {
return { valid: false, error: "Name contains invalid characters" };
}
return { valid: true, error: null };
}
// Export service
const HelloService = {
hello,
validateName,
};
export default HelloService;

View file

@ -0,0 +1,107 @@
// File: src/services/API/LoginService.js
/**
* LoginService
*
* Handles user login API calls to the MaplePress backend.
* Based on backend API: POST /api/v1/login
*/
import ApiClient from "./ApiClient.js";
/**
* Login with email and password
*
* @param {Object} credentials - Login credentials
* @param {string} credentials.email - User's email address
* @param {string} credentials.password - User's password
*
* @returns {Promise<Object>} Login response with tokens
* @throws {Error} Login error with message
*/
async function login(credentials) {
try {
console.log("[LoginService] Login attempt for:", credentials.email);
// Validate required fields
if (!credentials.email) {
throw new Error("Email is required");
}
if (!credentials.password) {
throw new Error("Password is required");
}
// Prepare request body matching backend LoginRequestDTO structure
const requestBody = {
email: credentials.email,
password: credentials.password,
};
// Call backend API
const response = await ApiClient.post("/api/v1/login", requestBody);
console.log("[LoginService] Login successful:", response.user_id);
// Return response matching backend structure
return {
// User details
userId: response.user_id,
userEmail: response.user_email,
userName: response.user_name,
userRole: response.user_role,
// Tenant details
tenantId: response.tenant_id,
// Authentication tokens
sessionId: response.session_id,
accessToken: response.access_token,
accessExpiry: new Date(response.access_expiry),
refreshToken: response.refresh_token,
refreshExpiry: new Date(response.refresh_expiry),
loginAt: new Date(response.login_at),
};
} catch (error) {
console.error("[LoginService] Login failed:", error);
// Parse and re-throw with user-friendly message
let errorMessage = "Login failed. Please try again.";
if (error.message) {
// Map backend error messages to user-friendly messages
if (
error.message.includes("Invalid email or password") ||
error.message.includes("Unauthorized") ||
error.message.includes("invalid credentials")
) {
// Check if there's information about remaining attempts
if (error.message.includes("attempts remaining")) {
errorMessage = error.message; // Use the detailed message from backend
} else {
errorMessage = "Invalid email or password. Please try again.";
}
} else if (
error.message.includes("locked") ||
error.message.includes("Too many")
) {
errorMessage =
"Account temporarily locked due to too many failed attempts. Please try again later.";
} else if (error.message.includes("email")) {
errorMessage = "Invalid email address.";
} else if (error.message.includes("required")) {
errorMessage = error.message;
} else {
errorMessage = error.message;
}
}
throw new Error(errorMessage);
}
}
const LoginService = {
login,
};
export default LoginService;

View file

@ -0,0 +1,124 @@
// File: src/services/API/MeService.js
import ApiClient from "./ApiClient";
/**
* MeService - Handles User Profile API requests
*
* Backend API: GET /api/v1/me
* Documentation: cloud/maplepress-backend/docs/API.md (lines 374-413)
*
* Purpose: Get the authenticated user's profile information from the JWT token.
* This endpoint extracts user data directly from the JWT claims (no database query).
* Useful for displaying user information in the dashboard and verifying the current
* authenticated user's identity.
*/
/**
* Get the current authenticated user's profile
*
* @returns {Promise<Object>} User profile data
* @throws {Error} If authentication fails or token is invalid
*
* Response format (transformed to camelCase):
* {
* userId: string, // User's unique identifier (UUID)
* email: string, // User's email address
* name: string, // User's full name
* role: string, // User's role (e.g., "owner", "admin", "user")
* tenantId: string // User's tenant/organization ID (UUID)
* }
*/
async function getMe() {
try {
// Make authenticated GET request
// No request body needed - all data comes from JWT token
const response = await ApiClient.get("/api/v1/me");
// Transform response to camelCase for frontend
return {
userId: response.user_id,
email: response.email,
name: response.name,
role: response.role,
tenantId: response.tenant_id,
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("token")) {
throw new Error(
"Invalid or expired authentication token. Please log in again."
);
}
// Generic error
throw new Error(
error.message || "Failed to fetch user profile. Please try again."
);
}
}
/**
* Check if the current user has a specific role
*
* @param {string} requiredRole - Role to check for (e.g., "owner", "admin", "user")
* @returns {Promise<boolean>} True if user has the required role
*/
async function hasRole(requiredRole) {
try {
const profile = await getMe();
return profile.role === requiredRole;
} catch (error) {
console.error("[MeService] Failed to check role:", error);
return false;
}
}
/**
* Get the current user's tenant ID
*
* @returns {Promise<string|null>} Tenant ID or null if not available
*/
async function getTenantId() {
try {
const profile = await getMe();
return profile.tenantId;
} catch (error) {
console.error("[MeService] Failed to get tenant ID:", error);
return null;
}
}
/**
* Verify that the current user matches a specific user ID
*
* @param {string} userId - User ID to verify against
* @returns {Promise<boolean>} True if the current user matches the provided ID
*/
async function isCurrentUser(userId) {
try {
const profile = await getMe();
return profile.userId === userId;
} catch (error) {
console.error("[MeService] Failed to verify user:", error);
return false;
}
}
// Export service
const MeService = {
getMe,
hasRole,
getTenantId,
isCurrentUser,
};
export default MeService;

View file

@ -0,0 +1,138 @@
// File: src/services/API/RefreshTokenService.js
import ApiClient from "./ApiClient";
/**
* RefreshTokenService - Handles token refresh API requests
*
* Backend API: POST /api/v1/refresh
* Documentation: cloud/maplepress-backend/docs/API.md (lines 230-324)
*
* Purpose: Obtain new access and refresh tokens using an existing valid refresh token.
* This should be called when the access token expires (after 15 minutes) to maintain
* the user's session without requiring them to log in again.
*
* Token Rotation: Both tokens are regenerated on refresh. Old tokens become invalid.
*/
/**
* Refresh the access token using a valid refresh token
*
* @param {string} refreshToken - Valid refresh token from login or previous refresh
* @returns {Promise<Object>} New authentication tokens and user data
* @throws {Error} If refresh token is invalid, expired, or session is invalid
*
* Response format (transformed to camelCase):
* {
* userId: string,
* userEmail: string,
* userName: string,
* userRole: string,
* tenantId: string,
* sessionId: string,
* accessToken: string,
* accessExpiry: Date,
* refreshToken: string,
* refreshExpiry: Date,
* refreshedAt: Date
* }
*/
async function refreshToken(refreshToken) {
// Validate input
if (!refreshToken || typeof refreshToken !== "string") {
throw new Error("Refresh token is required");
}
// Prepare request body (snake_case for backend)
const requestBody = {
refresh_token: refreshToken.trim(),
};
try {
// Make API request
const response = await ApiClient.post("/api/v1/refresh", requestBody);
// Transform response to camelCase for frontend
return {
userId: response.user_id,
userEmail: response.user_email,
userName: response.user_name,
userRole: response.user_role,
tenantId: response.tenant_id,
sessionId: response.session_id,
accessToken: response.access_token,
accessExpiry: new Date(response.access_expiry),
refreshToken: response.refresh_token,
refreshExpiry: new Date(response.refresh_expiry),
refreshedAt: new Date(response.refreshed_at),
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("invalid") || message.includes("expired")) {
throw new Error(
"Your session has expired. Please log in again to continue."
);
}
if (message.includes("session")) {
throw new Error(
"Session has expired or been invalidated. Please log in again."
);
}
// Generic error
throw new Error(
"Unable to refresh your session. Please log in again to continue."
);
}
}
/**
* Check if a refresh token is needed based on access token expiry
*
* @param {Date|string} accessExpiry - Access token expiry date
* @param {number} bufferMinutes - Minutes before expiry to trigger refresh (default: 1)
* @returns {boolean} True if token should be refreshed
*/
function shouldRefreshToken(accessExpiry, bufferMinutes = 1) {
if (!accessExpiry) {
return true;
}
const expiryDate =
accessExpiry instanceof Date ? accessExpiry : new Date(accessExpiry);
const now = new Date();
const bufferMs = bufferMinutes * 60 * 1000;
// Refresh if token expires within buffer time
return expiryDate.getTime() - now.getTime() < bufferMs;
}
/**
* Check if a refresh token itself is still valid
*
* @param {Date|string} refreshExpiry - Refresh token expiry date
* @returns {boolean} True if refresh token is still valid
*/
function isRefreshTokenValid(refreshExpiry) {
if (!refreshExpiry) {
return false;
}
const expiryDate =
refreshExpiry instanceof Date ? refreshExpiry : new Date(refreshExpiry);
const now = new Date();
return expiryDate.getTime() > now.getTime();
}
// Export service
const RefreshTokenService = {
refreshToken,
shouldRefreshToken,
isRefreshTokenValid,
};
export default RefreshTokenService;

View file

@ -0,0 +1,92 @@
// File: src/services/API/RegisterService.js
/**
* RegisterService
*
* Handles user registration API calls to the MaplePress backend.
* Based on backend API: POST /api/v1/register
*/
import ApiClient from "./ApiClient.js";
/**
* Register a new user and create a tenant
*
* @param {Object} data - Registration data
* @param {string} data.email - User's email address
* @param {string} data.password - User's password (min 8 characters)
* @param {string} data.first_name - User's first name
* @param {string} data.last_name - User's last name
* @param {string} data.tenant_name - Organization/tenant name (slug auto-generated from this)
* @param {string} [data.timezone] - User's timezone (defaults to "UTC")
* @param {boolean} data.agree_terms_of_service - Must be true
* @param {boolean} [data.agree_promotions] - Optional (default: false)
* @param {boolean} [data.agree_to_tracking_across_third_party_apps_and_services] - Optional (default: false)
*
* @returns {Promise<Object>} Registration response with tokens
* @throws {Error} Registration error with message
*/
async function register(data) {
try {
console.log("[RegisterService] Registering user:", data.email);
// No frontend validation - all validation handled by backend
// This allows backend to return ALL validation errors at once
// Prepare request body matching backend RegisterRequest structure
const requestBody = {
email: data.email,
password: data.password,
confirm_password: data.confirmPassword,
first_name: data.first_name,
last_name: data.last_name,
tenant_name: data.tenant_name,
timezone: data.timezone || "UTC",
agree_terms_of_service: data.agree_terms_of_service,
agree_promotions: data.agree_promotions || false,
agree_to_tracking_across_third_party_apps_and_services:
data.agree_to_tracking_across_third_party_apps_and_services || false,
};
// Call backend API
const response = await ApiClient.post("/api/v1/register", requestBody);
console.log("[RegisterService] Registration successful:", response.user_id);
// Return response matching backend RegisterResponse structure
return {
// User details
userId: response.user_id,
userEmail: response.user_email,
userName: response.user_name,
userRole: response.user_role,
// Tenant details
tenantId: response.tenant_id,
tenantName: response.tenant_name,
tenantSlug: response.tenant_slug,
// Authentication tokens
sessionId: response.session_id,
accessToken: response.access_token,
accessExpiry: new Date(response.access_expiry),
refreshToken: response.refresh_token,
refreshExpiry: new Date(response.refresh_expiry),
createdAt: new Date(response.created_at),
};
} catch (error) {
console.error("[RegisterService] Registration failed:", error);
// Pass through the original error object to preserve RFC 9457 fields
// (validationErrors, title, status, etc.) that ApiClient attached
// The Register page component will parse and display field-specific errors
throw error;
}
}
const RegisterService = {
register,
};
export default RegisterService;

View file

@ -0,0 +1,453 @@
// File: src/services/API/SiteService.js
import ApiClient from "./ApiClient";
/**
* SiteService - Handles WordPress Site Management API requests
*
* Backend API:
* - POST /api/v1/sites (Create WordPress Site)
* - GET /api/v1/sites (List WordPress Sites)
* - GET /api/v1/sites/{id} (Get WordPress Site)
* - DELETE /api/v1/sites/{id} (Delete WordPress Site)
* - POST /api/v1/sites/{id}/rotate-api-key (Rotate Site API Key)
*
* Documentation: cloud/maplepress-backend/docs/API.md (lines 805-1085)
*
* Purpose: Manage WordPress sites and their API credentials for the MaplePress plugin.
* Sites are scoped to tenants (determined from JWT token).
*/
/**
* Create a new WordPress site and generate API credentials
*
* @param {Object} siteData - Site creation data
* @param {string} siteData.siteUrl - Full WordPress site URL (e.g., https://example.com)
* @returns {Promise<Object>} Created site data with API key
* @throws {Error} If creation fails or validation errors occur
*
* Note: Backend automatically extracts the domain from siteUrl
*
* Response format (transformed to camelCase):
* {
* id: string, // Site ID (UUID)
* domain: string, // Site domain (extracted by backend)
* siteUrl: string, // Full site URL
* apiKey: string, // API key (shown only once!)
* verificationToken: string, // Token for DNS verification
* verificationInstructions: string, // DNS setup instructions
* status: string, // Site status (e.g., "pending")
* searchIndexName: string // Meilisearch index name
* }
*/
async function createSite(siteData) {
// NO frontend validation - let backend handle all validation
// This allows backend to return RFC 9457 validation errors
// Backend will extract domain from site_url automatically
// Prepare request body (snake_case for backend)
// Send whatever the user provided, even if empty
const requestBody = {
site_url: siteData.siteUrl || "",
};
try {
// Make authenticated API request
const response = await ApiClient.post("/api/v1/sites", requestBody);
// Transform response to camelCase for frontend
return {
id: response.id,
domain: response.domain,
siteUrl: response.site_url,
apiKey: response.api_key,
verificationToken: response.verification_token,
verificationInstructions: response.verification_instructions,
status: response.status,
searchIndexName: response.search_index_name,
};
} catch (error) {
// If error has RFC 9457 validation errors, preserve them
if (error.validationErrors) {
console.log("[SiteService] 📋 Preserving RFC 9457 validation errors:", error.validationErrors);
// Re-throw the original error with validation data intact
throw error;
}
// Map other backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("conflict") || message.includes("409") || message.includes("already registered")) {
throw new Error(
"Domain already registered by another user."
);
}
// Generic error
throw new Error(
error.message || "Failed to create site. Please try again."
);
}
}
/**
* List all WordPress sites for the authenticated user's tenant
*
* @param {Object} options - List options
* @param {number} options.pageSize - Number of results per page (default: 20, max: 100)
* @param {string} options.pageState - Pagination token from previous response
* @returns {Promise<Object>} List of sites with pagination
* @throws {Error} If retrieval fails
*
* Response format (transformed to camelCase):
* {
* sites: Array<{
* id: string,
* domain: string,
* status: string,
* isVerified: boolean,
* createdAt: Date
* }>,
* pageState: string|null // Pagination token for next page
* }
*/
async function listSites(options = {}) {
const { pageSize = 20, pageState } = options;
// Validate page size
if (pageSize < 1 || pageSize > 100) {
throw new Error("Page size must be between 1 and 100");
}
try {
// Build query parameters
const params = {
page_size: pageSize,
};
if (pageState) {
params.page_state = pageState;
}
// Make authenticated GET request
const response = await ApiClient.get("/api/v1/sites", { params });
// Transform response to camelCase for frontend
return {
sites: response.sites.map(site => ({
id: site.id,
domain: site.domain,
status: site.status,
isVerified: site.is_verified,
createdAt: new Date(site.created_at),
})),
pageState: response.page_state || null,
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
// Generic error
throw new Error(
error.message || "Failed to retrieve sites. Please try again."
);
}
}
/**
* Get detailed information about a specific WordPress site
*
* @param {string} siteId - Site ID (UUID)
* @returns {Promise<Object>} Detailed site data
* @throws {Error} If retrieval fails or site not found
*
* Response format (transformed to camelCase):
* {
* id: string,
* tenantId: string,
* domain: string,
* siteUrl: string,
* apiKeyPrefix: string,
* apiKeyLastFour: string,
* status: string,
* isVerified: boolean,
* searchIndexName: string,
* totalPagesIndexed: number,
* lastIndexedAt: Date,
* pluginVersion: string,
* storageUsedBytes: number,
* searchRequestsCount: number,
* monthlyPagesIndexed: number,
* lastResetAt: Date,
* createdAt: Date,
* updatedAt: Date
* }
*/
async function getSiteById(siteId) {
// Validate input
if (!siteId || typeof siteId !== "string") {
throw new Error("Site ID is required");
}
// Basic UUID validation
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(siteId)) {
throw new Error("Invalid site ID format");
}
try {
// Make authenticated GET request
const response = await ApiClient.get(`/api/v1/sites/${siteId}`);
// Transform response to camelCase for frontend
return {
id: response.id,
tenantId: response.tenant_id,
domain: response.domain,
siteUrl: response.site_url,
apiKeyPrefix: response.api_key_prefix,
apiKeyLastFour: response.api_key_last_four,
status: response.status,
isVerified: response.is_verified,
searchIndexName: response.search_index_name,
totalPagesIndexed: response.total_pages_indexed,
lastIndexedAt: response.last_indexed_at ? new Date(response.last_indexed_at) : null,
pluginVersion: response.plugin_version,
storageUsedBytes: response.storage_used_bytes,
searchRequestsCount: response.search_requests_count,
monthlyPagesIndexed: response.monthly_pages_indexed,
lastResetAt: response.last_reset_at ? new Date(response.last_reset_at) : null,
createdAt: new Date(response.created_at),
updatedAt: new Date(response.updated_at),
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("not found") || message.includes("404")) {
throw new Error("Site not found or doesn't belong to your organization.");
}
// Generic error
throw new Error(
error.message || "Failed to retrieve site. Please try again."
);
}
}
/**
* Delete a WordPress site and all associated data
*
* @param {string} siteId - Site ID (UUID)
* @returns {Promise<Object>} Deletion confirmation
* @throws {Error} If deletion fails or site not found
*
* Response format:
* {
* success: boolean,
* message: string
* }
*/
async function deleteSite(siteId) {
// Validate input
if (!siteId || typeof siteId !== "string") {
throw new Error("Site ID is required");
}
// Basic UUID validation
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(siteId)) {
throw new Error("Invalid site ID format");
}
try {
// Make authenticated DELETE request
const response = await ApiClient.delete(`/api/v1/sites/${siteId}`);
return {
success: response.success,
message: response.message,
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("not found") || message.includes("404")) {
throw new Error("Site not found or doesn't belong to your organization.");
}
// Generic error
throw new Error(
error.message || "Failed to delete site. Please try again."
);
}
}
/**
* Rotate a site's API key (use when the key is compromised)
*
* @param {string} siteId - Site ID (UUID)
* @returns {Promise<Object>} New API key and rotation details
* @throws {Error} If rotation fails or site not found
*
* Response format (transformed to camelCase):
* {
* newApiKey: string, // New API key (shown only once!)
* oldKeyLastFour: string, // Last 4 chars of old key
* rotatedAt: Date // Rotation timestamp
* }
*/
async function rotateApiKey(siteId) {
// Validate input
if (!siteId || typeof siteId !== "string") {
throw new Error("Site ID is required");
}
// Basic UUID validation
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(siteId)) {
throw new Error("Invalid site ID format");
}
try {
// Make authenticated POST request
const response = await ApiClient.post(`/api/v1/sites/${siteId}/rotate-api-key`);
// Transform response to camelCase for frontend
return {
newApiKey: response.new_api_key,
oldKeyLastFour: response.old_key_last_four,
rotatedAt: new Date(response.rotated_at),
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("not found") || message.includes("404")) {
throw new Error("Site not found or doesn't belong to your organization.");
}
// Generic error
throw new Error(
error.message || "Failed to rotate API key. Please try again."
);
}
}
/**
* Validate domain format
*
* @param {string} domain - Domain to validate
* @returns {Object} { valid: boolean, error: string|null }
*/
function validateDomain(domain) {
if (!domain || typeof domain !== "string") {
return { valid: false, error: "Domain is required" };
}
const trimmedDomain = domain.trim();
if (trimmedDomain.length === 0) {
return { valid: false, error: "Domain cannot be empty" };
}
// Basic domain validation
const domainPattern = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i;
if (!domainPattern.test(trimmedDomain)) {
return { valid: false, error: "Invalid domain format" };
}
return { valid: true, error: null };
}
/**
* Validate site URL format
*
* @param {string} url - URL to validate
* @returns {Object} { valid: boolean, error: string|null }
*/
function validateSiteUrl(url) {
if (!url || typeof url !== "string") {
return { valid: false, error: "Site URL is required" };
}
const trimmedUrl = url.trim();
if (trimmedUrl.length === 0) {
return { valid: false, error: "Site URL cannot be empty" };
}
// Validate URL format
try {
const parsedUrl = new URL(trimmedUrl);
// Must be http or https
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return { valid: false, error: "Site URL must use http or https protocol" };
}
return { valid: true, error: null };
} catch (error) {
return { valid: false, error: "Invalid URL format" };
}
}
/**
* Format storage bytes to human-readable format
*
* @param {number} bytes - Storage in bytes
* @returns {string} Formatted string (e.g., "50 MB")
*/
function formatStorage(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
// Export service
const SiteService = {
createSite,
listSites,
getSiteById,
deleteSite,
rotateApiKey,
validateDomain,
validateSiteUrl,
formatStorage,
};
export default SiteService;

View file

@ -0,0 +1,342 @@
// File: src/services/API/TenantService.js
import ApiClient from "./ApiClient";
/**
* TenantService - Handles Tenant Management API requests
*
* Backend API:
* - POST /api/v1/tenants (Create Tenant)
* - GET /api/v1/tenants/{id} (Get Tenant by ID)
* - GET /api/v1/tenants/slug/{slug} (Get Tenant by Slug)
*
* Documentation: cloud/maplepress-backend/docs/API.md (lines 416-558)
*
* Purpose: Manage tenants (organizations) in the multi-tenant system.
* Each tenant represents an organization with its own users and resources.
*/
/**
* Create a new tenant (organization)
*
* @param {Object} tenantData - Tenant creation data
* @param {string} tenantData.name - Tenant/organization name
* @param {string} tenantData.slug - URL-friendly tenant identifier (lowercase, hyphens only)
* @returns {Promise<Object>} Created tenant data
* @throws {Error} If creation fails or validation errors occur
*
* Response format (transformed to camelCase):
* {
* id: string, // Tenant ID (UUID)
* name: string, // Tenant name
* slug: string, // Tenant slug
* status: string, // Tenant status (e.g., "active")
* createdAt: Date // Creation timestamp
* }
*/
async function createTenant(tenantData) {
// Validate required fields
if (!tenantData || typeof tenantData !== "object") {
throw new Error("Tenant data is required");
}
if (!tenantData.name || typeof tenantData.name !== "string") {
throw new Error("Tenant name is required");
}
if (!tenantData.slug || typeof tenantData.slug !== "string") {
throw new Error("Tenant slug is required");
}
// Validate slug format (lowercase, numbers, hyphens only)
const slugPattern = /^[a-z0-9-]+$/;
if (!slugPattern.test(tenantData.slug)) {
throw new Error(
"Tenant slug must contain only lowercase letters, numbers, and hyphens"
);
}
// Prepare request body (snake_case for backend)
const requestBody = {
name: tenantData.name.trim(),
slug: tenantData.slug.trim().toLowerCase(),
};
try {
// Make authenticated API request
const response = await ApiClient.post("/api/v1/tenants", requestBody);
// Transform response to camelCase for frontend
return {
id: response.id,
name: response.name,
slug: response.slug,
status: response.status,
createdAt: new Date(response.created_at),
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("conflict") || message.includes("409") || message.includes("already exists")) {
throw new Error(
"Tenant slug already exists. Please choose a different slug."
);
}
if (message.includes("slug")) {
throw new Error(
"Invalid tenant slug. Must contain only lowercase letters, numbers, and hyphens."
);
}
if (message.includes("name")) {
throw new Error("Invalid tenant name provided.");
}
// Generic error
throw new Error(
error.message || "Failed to create tenant. Please try again."
);
}
}
/**
* Get tenant by ID
*
* @param {string} tenantId - Tenant ID (UUID)
* @returns {Promise<Object>} Tenant data
* @throws {Error} If retrieval fails or tenant not found
*
* Response format (transformed to camelCase):
* {
* id: string, // Tenant ID (UUID)
* name: string, // Tenant name
* slug: string, // Tenant slug
* status: string, // Tenant status
* createdAt: Date, // Creation timestamp
* updatedAt: Date // Last update timestamp
* }
*/
async function getTenantById(tenantId) {
// Validate input
if (!tenantId || typeof tenantId !== "string") {
throw new Error("Tenant ID is required");
}
// Basic UUID validation
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(tenantId)) {
throw new Error("Invalid tenant ID format");
}
try {
// Make authenticated GET request
const response = await ApiClient.get(`/api/v1/tenants/${tenantId}`);
// Transform response to camelCase for frontend
return {
id: response.id,
name: response.name,
slug: response.slug,
status: response.status,
createdAt: new Date(response.created_at),
updatedAt: new Date(response.updated_at),
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("not found") || message.includes("404")) {
throw new Error("Tenant not found.");
}
// Generic error
throw new Error(
error.message || "Failed to retrieve tenant. Please try again."
);
}
}
/**
* Get tenant by slug
*
* @param {string} slug - Tenant slug (URL-friendly identifier)
* @returns {Promise<Object>} Tenant data
* @throws {Error} If retrieval fails or tenant not found
*
* Response format (transformed to camelCase):
* {
* id: string, // Tenant ID (UUID)
* name: string, // Tenant name
* slug: string, // Tenant slug
* status: string, // Tenant status
* createdAt: Date, // Creation timestamp
* updatedAt: Date // Last update timestamp
* }
*/
async function getTenantBySlug(slug) {
// Validate input
if (!slug || typeof slug !== "string") {
throw new Error("Tenant slug is required");
}
const normalizedSlug = slug.trim().toLowerCase();
if (normalizedSlug.length === 0) {
throw new Error("Tenant slug cannot be empty");
}
try {
// Make authenticated GET request
const response = await ApiClient.get(`/api/v1/tenants/slug/${normalizedSlug}`);
// Transform response to camelCase for frontend
return {
id: response.id,
name: response.name,
slug: response.slug,
status: response.status,
createdAt: new Date(response.created_at),
updatedAt: new Date(response.updated_at),
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("not found") || message.includes("404")) {
throw new Error("Tenant not found.");
}
// Generic error
throw new Error(
error.message || "Failed to retrieve tenant. Please try again."
);
}
}
/**
* Generate a URL-friendly slug from a tenant name
* Converts to lowercase, replaces spaces with hyphens, removes special characters
*
* @param {string} name - Tenant name
* @returns {string} Generated slug
*/
function generateSlug(name) {
if (!name || typeof name !== "string") {
return "";
}
return name
.trim()
.toLowerCase()
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens
.replace(/-+/g, "-") // Replace multiple hyphens with single
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
}
/**
* Validate a tenant slug format
*
* @param {string} slug - Slug to validate
* @returns {Object} { valid: boolean, error: string|null }
*/
function validateSlug(slug) {
if (!slug || typeof slug !== "string") {
return { valid: false, error: "Slug is required" };
}
const trimmedSlug = slug.trim();
if (trimmedSlug.length === 0) {
return { valid: false, error: "Slug cannot be empty" };
}
if (trimmedSlug.length < 2) {
return { valid: false, error: "Slug must be at least 2 characters" };
}
if (trimmedSlug.length > 50) {
return { valid: false, error: "Slug must be 50 characters or less" };
}
// Check format (lowercase, numbers, hyphens only)
const slugPattern = /^[a-z0-9-]+$/;
if (!slugPattern.test(trimmedSlug)) {
return {
valid: false,
error: "Slug must contain only lowercase letters, numbers, and hyphens",
};
}
// Check for leading/trailing hyphens
if (trimmedSlug.startsWith("-") || trimmedSlug.endsWith("-")) {
return { valid: false, error: "Slug cannot start or end with a hyphen" };
}
// Check for consecutive hyphens
if (trimmedSlug.includes("--")) {
return { valid: false, error: "Slug cannot contain consecutive hyphens" };
}
return { valid: true, error: null };
}
/**
* Validate tenant name
*
* @param {string} name - Name to validate
* @returns {Object} { valid: boolean, error: string|null }
*/
function validateName(name) {
if (!name || typeof name !== "string") {
return { valid: false, error: "Name is required" };
}
const trimmedName = name.trim();
if (trimmedName.length === 0) {
return { valid: false, error: "Name cannot be empty" };
}
if (trimmedName.length < 2) {
return { valid: false, error: "Name must be at least 2 characters" };
}
if (trimmedName.length > 100) {
return { valid: false, error: "Name must be 100 characters or less" };
}
return { valid: true, error: null };
}
// Export service
const TenantService = {
createTenant,
getTenantById,
getTenantBySlug,
generateSlug,
validateSlug,
validateName,
};
export default TenantService;

View file

@ -0,0 +1,280 @@
// File: src/services/API/UserService.js
import ApiClient from "./ApiClient";
/**
* UserService - Handles User Management API requests
*
* Backend API:
* - POST /api/v1/users (Create User)
* - GET /api/v1/users/{id} (Get User by ID)
*
* Documentation: cloud/maplepress-backend/docs/API.md (lines 560-660)
*
* Purpose: Manage users within a tenant (organization).
* All user operations require tenant context via X-Tenant-ID header.
*
* IMPORTANT: These endpoints require tenant context (X-Tenant-ID header).
* The ApiClient should be enhanced to automatically add this header based on
* the current user's tenant from AuthManager.
*/
/**
* Create a new user within a tenant
*
* @param {Object} userData - User creation data
* @param {string} userData.email - User's email address
* @param {string} userData.name - User's full name
* @param {string} tenantId - Tenant ID for context (optional if using current tenant)
* @returns {Promise<Object>} Created user data
* @throws {Error} If creation fails or validation errors occur
*
* Response format (transformed to camelCase):
* {
* id: string, // User ID (UUID)
* email: string, // User email
* name: string, // User name
* createdAt: Date // Creation timestamp
* }
*/
async function createUser(userData, tenantId = null) {
// Validate required fields
if (!userData || typeof userData !== "object") {
throw new Error("User data is required");
}
if (!userData.email || typeof userData.email !== "string") {
throw new Error("User email is required");
}
if (!userData.name || typeof userData.name !== "string") {
throw new Error("User name is required");
}
// Validate email format
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(userData.email)) {
throw new Error("Invalid email format");
}
// Prepare request body (snake_case for backend)
const requestBody = {
email: userData.email.trim().toLowerCase(),
name: userData.name.trim(),
};
try {
// Prepare options with tenant context if provided
const options = {};
if (tenantId) {
options.headers = {
"X-Tenant-ID": tenantId,
};
}
// Make authenticated API request with tenant context
const response = await ApiClient.post("/api/v1/users", requestBody, options);
// Transform response to camelCase for frontend
return {
id: response.id,
email: response.email,
name: response.name,
createdAt: new Date(response.created_at),
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("conflict") || message.includes("409") || message.includes("already exists")) {
throw new Error(
"User email already exists in this tenant."
);
}
if (message.includes("tenant") && message.includes("400")) {
throw new Error(
"Tenant context required. Please provide X-Tenant-ID header."
);
}
if (message.includes("email")) {
throw new Error("Invalid email address provided.");
}
if (message.includes("name")) {
throw new Error("Invalid name provided.");
}
// Generic error
throw new Error(
error.message || "Failed to create user. Please try again."
);
}
}
/**
* Get user by ID within a tenant context
*
* @param {string} userId - User ID (UUID)
* @param {string} tenantId - Tenant ID for context (optional if using current tenant)
* @returns {Promise<Object>} User data
* @throws {Error} If retrieval fails or user not found
*
* Response format (transformed to camelCase):
* {
* id: string, // User ID (UUID)
* email: string, // User email
* name: string, // User name
* createdAt: Date, // Creation timestamp
* updatedAt: Date // Last update timestamp
* }
*/
async function getUserById(userId, tenantId = null) {
// Validate input
if (!userId || typeof userId !== "string") {
throw new Error("User ID is required");
}
// Basic UUID validation
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidPattern.test(userId)) {
throw new Error("Invalid user ID format");
}
try {
// Prepare options with tenant context if provided
const options = {};
if (tenantId) {
options.headers = {
"X-Tenant-ID": tenantId,
};
}
// Make authenticated GET request with tenant context
const response = await ApiClient.get(`/api/v1/users/${userId}`, options);
// Transform response to camelCase for frontend
return {
id: response.id,
email: response.email,
name: response.name,
createdAt: new Date(response.created_at),
updatedAt: new Date(response.updated_at),
};
} catch (error) {
// Map backend errors to user-friendly messages
const message = error.message?.toLowerCase() || "";
if (message.includes("unauthorized") || message.includes("401")) {
throw new Error(
"Authentication required. Please log in to continue."
);
}
if (message.includes("not found") || message.includes("404")) {
throw new Error("User not found in this tenant.");
}
if (message.includes("tenant") && message.includes("400")) {
throw new Error(
"Tenant context required. Please provide X-Tenant-ID header."
);
}
// Generic error
throw new Error(
error.message || "Failed to retrieve user. Please try again."
);
}
}
/**
* Validate email format
*
* @param {string} email - Email to validate
* @returns {Object} { valid: boolean, error: string|null }
*/
function validateEmail(email) {
if (!email || typeof email !== "string") {
return { valid: false, error: "Email is required" };
}
const trimmedEmail = email.trim();
if (trimmedEmail.length === 0) {
return { valid: false, error: "Email cannot be empty" };
}
// Check email format
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(trimmedEmail)) {
return { valid: false, error: "Invalid email format" };
}
if (trimmedEmail.length > 255) {
return { valid: false, error: "Email must be 255 characters or less" };
}
return { valid: true, error: null };
}
/**
* Validate user name
*
* @param {string} name - Name to validate
* @returns {Object} { valid: boolean, error: string|null }
*/
function validateName(name) {
if (!name || typeof name !== "string") {
return { valid: false, error: "Name is required" };
}
const trimmedName = name.trim();
if (trimmedName.length === 0) {
return { valid: false, error: "Name cannot be empty" };
}
if (trimmedName.length < 2) {
return { valid: false, error: "Name must be at least 2 characters" };
}
if (trimmedName.length > 100) {
return { valid: false, error: "Name must be 100 characters or less" };
}
return { valid: true, error: null };
}
/**
* Validate UUID format
*
* @param {string} uuid - UUID to validate
* @returns {boolean} True if valid UUID
*/
function isValidUUID(uuid) {
if (!uuid || typeof uuid !== "string") {
return false;
}
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidPattern.test(uuid);
}
// Export service
const UserService = {
createUser,
getUserById,
validateEmail,
validateName,
isValidUUID,
};
export default UserService;

View file

@ -0,0 +1,453 @@
// File: src/services/Manager/AuthManager.js
import RegisterService from "../API/RegisterService.js";
import LoginService from "../API/LoginService.js";
import RefreshTokenService from "../API/RefreshTokenService.js";
/**
* AuthManager
*
* Manages authentication state and operations for MaplePress.
* Handles token storage, user session, and authentication lifecycle.
*/
class AuthManager {
constructor() {
this.accessToken = null;
this.refreshToken = null;
this.accessExpiry = null;
this.refreshExpiry = null;
this.user = null;
this.tenant = null;
this.sessionId = null;
this.isInitialized = false;
this.refreshPromise = null; // Track ongoing refresh to prevent duplicates
// LocalStorage keys
this.STORAGE_KEYS = {
ACCESS_TOKEN: "maplepress_access_token",
REFRESH_TOKEN: "maplepress_refresh_token",
ACCESS_EXPIRY: "maplepress_access_expiry",
REFRESH_EXPIRY: "maplepress_refresh_expiry",
USER: "maplepress_user",
TENANT: "maplepress_tenant",
SESSION_ID: "maplepress_session_id",
};
}
/**
* Initialize the auth manager
* Loads stored auth state from localStorage
*/
async initialize() {
console.log("[AuthManager] 🚀 Initializing...");
try {
// Load stored auth state from localStorage
this.accessToken = localStorage.getItem(this.STORAGE_KEYS.ACCESS_TOKEN);
this.refreshToken = localStorage.getItem(
this.STORAGE_KEYS.REFRESH_TOKEN
);
this.sessionId = localStorage.getItem(this.STORAGE_KEYS.SESSION_ID);
console.log("[AuthManager] 📦 Loaded from localStorage:", {
hasAccessToken: !!this.accessToken,
hasRefreshToken: !!this.refreshToken,
hasSessionId: !!this.sessionId,
});
// Load expiry dates
const accessExpiryStr = localStorage.getItem(
this.STORAGE_KEYS.ACCESS_EXPIRY
);
const refreshExpiryStr = localStorage.getItem(
this.STORAGE_KEYS.REFRESH_EXPIRY
);
if (accessExpiryStr) {
this.accessExpiry = new Date(accessExpiryStr);
}
if (refreshExpiryStr) {
this.refreshExpiry = new Date(refreshExpiryStr);
}
console.log("[AuthManager] ⏰ Token expiry times:", {
accessExpiry: this.accessExpiry?.toISOString(),
refreshExpiry: this.refreshExpiry?.toISOString(),
currentTime: new Date().toISOString(),
accessExpired: this.isTokenExpired(this.accessExpiry),
refreshExpired: this.isTokenExpired(this.refreshExpiry),
});
// Load user and tenant data
const userStr = localStorage.getItem(this.STORAGE_KEYS.USER);
const tenantStr = localStorage.getItem(this.STORAGE_KEYS.TENANT);
if (userStr) {
this.user = JSON.parse(userStr);
}
if (tenantStr) {
this.tenant = JSON.parse(tenantStr);
}
console.log("[AuthManager] 👤 User and tenant loaded:", {
hasUser: !!this.user,
hasTenant: !!this.tenant,
userId: this.user?.id,
userEmail: this.user?.email,
});
// Check if access token is expired but refresh token is still valid
if (this.accessToken && this.isTokenExpired(this.accessExpiry)) {
console.log("[AuthManager] ⚠️ Access token expired");
// Try to refresh if refresh token is still valid
if (this.refreshToken && !this.isTokenExpired(this.refreshExpiry)) {
console.log("[AuthManager] 🔄 Attempting to refresh token on initialization");
try {
await this.refreshAccessToken();
} catch (error) {
console.log("[AuthManager] ❌ Token refresh failed, clearing session");
this.clearSession();
}
} else {
console.log("[AuthManager] ❌ Refresh token also expired, clearing session");
this.clearSession();
}
}
this.isInitialized = true;
const isAuth = this.isAuthenticated();
console.log("[AuthManager] ✅ Initialized", {
authenticated: isAuth,
hasAccessToken: !!this.accessToken,
hasUser: !!this.user,
accessExpired: this.isTokenExpired(this.accessExpiry),
});
} catch (error) {
console.error("[AuthManager] ❌ Initialization error:", error);
this.clearSession();
this.isInitialized = true;
}
}
/**
* Check if AuthManager is initialized
* Returns true if initialization has completed (even if not authenticated)
*/
getIsInitialized() {
return this.isInitialized;
}
/**
* Check if user is authenticated
* Returns false if not yet initialized to prevent premature redirects
*
* IMPORTANT: This checks if the REFRESH token is valid, not the access token.
* Access tokens expire after 15 minutes, but that's OK - they get auto-refreshed.
* We only care if the refresh token (7 days) is still valid.
*/
isAuthenticated() {
// Don't return authentication status until initialized
// This prevents race conditions where Dashboard checks before localStorage is loaded
if (!this.isInitialized) {
console.log("[AuthManager] ⏳ Not yet initialized, returning false");
return false;
}
// User is authenticated if:
// 1. We have tokens (both access and refresh)
// 2. We have user data
// 3. The REFRESH token is still valid (not the access token)
const hasTokens = this.accessToken !== null && this.refreshToken !== null;
const hasUser = this.user !== null;
const refreshTokenValid = !this.isTokenExpired(this.refreshExpiry);
const isAuth = hasTokens && hasUser && refreshTokenValid;
if (!isAuth && this.isInitialized) {
console.log("[AuthManager] 🔐 Authentication check failed:", {
hasTokens,
hasUser,
refreshTokenValid,
refreshExpiry: this.refreshExpiry?.toISOString(),
});
}
return isAuth;
}
/**
* Check if a token is expired
* Returns true if the current time is AFTER the expiry time
*/
isTokenExpired(expiry) {
if (!expiry) return true;
return new Date() > expiry;
}
/**
* Get the current access token
*/
getAccessToken() {
return this.accessToken;
}
/**
* Get the current user
*/
getUser() {
return this.user;
}
/**
* Get the current tenant
*/
getTenant() {
return this.tenant;
}
/**
* Store authentication data
*/
storeAuthData(authResponse) {
// Store tokens
this.accessToken = authResponse.accessToken;
this.refreshToken = authResponse.refreshToken;
this.accessExpiry = authResponse.accessExpiry;
this.refreshExpiry = authResponse.refreshExpiry;
this.sessionId = authResponse.sessionId;
// Store user data
this.user = {
id: authResponse.userId,
email: authResponse.userEmail,
name: authResponse.userName,
role: authResponse.userRole,
};
// Store tenant data (handle optional fields for login endpoint)
this.tenant = {
id: authResponse.tenantId,
name: authResponse.tenantName || null,
slug: authResponse.tenantSlug || null,
};
// Persist to localStorage
localStorage.setItem(this.STORAGE_KEYS.ACCESS_TOKEN, this.accessToken);
localStorage.setItem(this.STORAGE_KEYS.REFRESH_TOKEN, this.refreshToken);
localStorage.setItem(
this.STORAGE_KEYS.ACCESS_EXPIRY,
this.accessExpiry.toISOString()
);
localStorage.setItem(
this.STORAGE_KEYS.REFRESH_EXPIRY,
this.refreshExpiry.toISOString()
);
localStorage.setItem(this.STORAGE_KEYS.SESSION_ID, this.sessionId);
localStorage.setItem(this.STORAGE_KEYS.USER, JSON.stringify(this.user));
localStorage.setItem(
this.STORAGE_KEYS.TENANT,
JSON.stringify(this.tenant)
);
console.log("[AuthManager] Auth data stored successfully");
}
/**
* Clear session data
*/
clearSession() {
this.accessToken = null;
this.refreshToken = null;
this.accessExpiry = null;
this.refreshExpiry = null;
this.user = null;
this.tenant = null;
this.sessionId = null;
// Clear localStorage
Object.values(this.STORAGE_KEYS).forEach((key) => {
localStorage.removeItem(key);
});
console.log("[AuthManager] Session cleared");
}
/**
* Login with email and password
* @param {string} email - User's email address
* @param {string} password - User's password
* @returns {Promise<Object>} Authentication response
*/
async login(email, password) {
console.log("[AuthManager] Login attempt for:", email);
try {
// Call LoginService
const response = await LoginService.login({ email, password });
// Store authentication data
// Note: Login response doesn't include tenant name/slug, so we only store what we have
const authData = {
...response,
tenantName: null, // Not provided by login endpoint
tenantSlug: null, // Not provided by login endpoint
};
this.storeAuthData(authData);
console.log("[AuthManager] Login successful");
return response;
} catch (error) {
console.error("[AuthManager] Login failed:", error);
throw error;
}
}
/**
* Register a new user
* @param {Object} registrationData - Registration data
* @returns {Promise<Object>} Authentication response
*/
async register(registrationData) {
console.log("[AuthManager] Registration attempt for:", registrationData.email);
try {
// Call RegisterService
const response = await RegisterService.register(registrationData);
// Store authentication data
this.storeAuthData(response);
console.log("[AuthManager] Registration successful");
return response;
} catch (error) {
console.error("[AuthManager] Registration failed:", error);
throw error;
}
}
/**
* Logout the current user
*/
async logout() {
console.log("[AuthManager] Logging out");
// TODO: Call backend to invalidate token
// TODO: Implement actual logout API call
// Clear local session
this.clearSession();
console.log("[AuthManager] Logout successful");
}
/**
* Refresh the access token using the stored refresh token
* Implements token rotation - both access and refresh tokens are regenerated
*
* @returns {Promise<Object>} New authentication data
* @throws {Error} If refresh fails (expired, invalid, or session invalidated)
*/
async refreshAccessToken() {
// If a refresh is already in progress, return that promise
if (this.refreshPromise) {
console.log("[AuthManager] Refresh already in progress, waiting...");
return this.refreshPromise;
}
console.log("[AuthManager] 🔄 Refreshing access token");
console.log("[AuthManager] 🔍 Current state:", {
hasRefreshToken: !!this.refreshToken,
refreshTokenLength: this.refreshToken?.length,
hasAccessToken: !!this.accessToken,
isInitialized: this.isInitialized,
});
// Check if we have a refresh token
if (!this.refreshToken) {
console.error("[AuthManager] ❌ No refresh token available!");
console.error("[AuthManager] localStorage check:", {
storedRefreshToken: localStorage.getItem(this.STORAGE_KEYS.REFRESH_TOKEN),
});
const error = new Error("No refresh token available");
this.clearSession();
throw error;
}
// Check if refresh token is expired
if (this.isTokenExpired(this.refreshExpiry)) {
const error = new Error("Refresh token expired");
console.error("[AuthManager] Refresh failed:", error);
this.clearSession();
throw error;
}
// Create refresh promise and store it to prevent duplicate refreshes
this.refreshPromise = (async () => {
try {
// Call refresh token service
const response = await RefreshTokenService.refreshToken(this.refreshToken);
// Store the new authentication data
// Note: Refresh response has same format as login (no tenant name/slug)
const authData = {
...response,
tenantName: this.tenant?.name || null,
tenantSlug: this.tenant?.slug || null,
};
this.storeAuthData(authData);
console.log("[AuthManager] Token refresh successful");
return response;
} catch (error) {
console.error("[AuthManager] Token refresh failed:", error);
// Clear session on refresh failure
this.clearSession();
throw error;
} finally {
// Clear the refresh promise
this.refreshPromise = null;
}
})();
return this.refreshPromise;
}
/**
* Check if the access token needs to be refreshed soon
* Returns true if token expires within the buffer time (default: 1 minute)
*
* @param {number} bufferMinutes - Minutes before expiry to trigger refresh
* @returns {boolean} True if token should be refreshed
*/
shouldRefreshToken(bufferMinutes = 1) {
return RefreshTokenService.shouldRefreshToken(this.accessExpiry, bufferMinutes);
}
/**
* Automatically refresh the access token if needed
* This should be called before making API requests to ensure the token is valid
*
* @returns {Promise<void>}
*/
async ensureValidToken() {
// If not authenticated, nothing to refresh
if (!this.accessToken) {
return;
}
// If token needs refresh, do it
if (this.shouldRefreshToken()) {
console.log("[AuthManager] Token expiring soon, refreshing proactively");
try {
await this.refreshAccessToken();
} catch (error) {
console.error("[AuthManager] Proactive token refresh failed:", error);
throw error;
}
}
}
}
export default AuthManager;

View file

@ -0,0 +1,213 @@
// File: src/services/Services.jsx
// Service boundary interface with dependency injection
import React, { createContext, useContext, useEffect, useMemo } from "react";
// ========================================
// SERVICE CLASS IMPORTS
// ========================================
// Core Services
import AuthManager from "./Manager/AuthManager.js";
// API Services
import ApiClient, { setApiClientAuthManager } from "./API/ApiClient.js";
// ========================================
// MODULE-LEVEL REFERENCE (for ApiClient)
// ========================================
// Store the latest AuthManager instance at module level
// This ensures ApiClient always uses the most recent instance
// even when React StrictMode creates multiple instances
let latestAuthManager = null;
// ========================================
// SERVICE CREATION & DEPENDENCY INJECTION
// ========================================
function createServices() {
console.log(
"[Services] 🚀 Creating service instances with dependency injection..."
);
// ========================================
// 1. CORE SERVICES (No Dependencies)
// ========================================
const authManager = new AuthManager();
console.log("[Services] ✓ AuthManager created");
// Store this as the latest instance for ApiClient
latestAuthManager = authManager;
console.log("[Services] 📌 Latest AuthManager reference updated");
// ========================================
// 2. API CLIENT SETUP
// ========================================
// Pass a getter that returns the latest AuthManager instance
// This handles React StrictMode creating multiple instances
setApiClientAuthManager(() => latestAuthManager);
console.log("[Services] ✓ ApiClient configured with AuthManager getter");
// ========================================
// 3. SERVICE REGISTRY
// ========================================
const services = {
// Core services
authManager,
apiClient: ApiClient,
};
console.log(
"[Services] ✅ Service registry created with",
Object.keys(services).length,
"services"
);
return services;
}
// ========================================
// REACT CONTEXT & PROVIDER
// ========================================
const ServiceContext = createContext();
export function ServiceProvider({ children }) {
// useMemo ensures services are created only once, not on every render
const services = useMemo(() => {
console.log("[Services] 🔄 Creating services (should only happen once)");
return createServices();
}, []); // Empty dependency array = create only once
// ========================================
// SERVICE INITIALIZATION
// ========================================
useEffect(() => {
const initializeServices = async () => {
try {
console.log("[Services] 🚀 Starting service initialization...");
// CRITICAL: Update latestAuthManager to point to the services instance
// This ensures ApiClient uses the same instance that gets initialized
latestAuthManager = services.authManager;
console.log("[Services] 🔗 Synced latestAuthManager with services.authManager");
// Initialize AuthManager
try {
await services.authManager.initialize();
console.log("[Services] ✓ AuthManager initialized");
} catch (error) {
console.warn(
"[Services] ⚠️ AuthManager initialization failed:",
error
);
}
console.log("[Services] 🎉 All services initialized successfully!");
} catch (error) {
console.error(
"[Services] ❌ Critical service initialization failure:",
error
);
}
};
initializeServices();
}, [services]);
// ========================================
// ERROR HANDLING
// ========================================
useEffect(() => {
const handleUnhandledRejection = (event) => {
console.error("[Services] 🚨 Unhandled promise rejection:", event.reason);
};
const handleError = (event) => {
console.error("[Services] 🚨 Unhandled error:", event.error);
};
window.addEventListener("unhandledrejection", handleUnhandledRejection);
window.addEventListener("error", handleError);
return () => {
window.removeEventListener(
"unhandledrejection",
handleUnhandledRejection
);
window.removeEventListener("error", handleError);
};
}, []);
// ========================================
// DEVELOPMENT DEBUG INFO
// ========================================
useEffect(() => {
if (import.meta.env.DEV) {
console.log("[Services] 🔧 Development mode - adding debug info");
console.log("[Services] Available services:", Object.keys(services));
console.log(
"[Services] AuthManager authenticated:",
services.authManager.isAuthenticated()
);
console.log(
"[Services] 🏗️ Architecture: Single-file service boundary with full DI"
);
// Add services to window for debugging
window.maplePressServices = services;
console.log(
"[Services] 🪟 Services available at window.maplePressServices for debugging"
);
}
}, [services]);
return (
<ServiceContext.Provider value={services}>
{children}
</ServiceContext.Provider>
);
}
// ========================================
// SERVICE HOOKS - BOUNDARY INTERFACE
// ========================================
/**
* Main service hook - returns ALL services
* This is your primary interface to the service layer
*/
export function useServices() {
const context = useContext(ServiceContext);
if (!context) {
throw new Error("useServices must be used within a ServiceProvider");
}
return context;
}
/**
* Authentication Services
*/
export function useAuth() {
const { authManager } = useServices();
return {
authManager,
};
}
/**
* API Services
*/
export function useApi() {
const { apiClient } = useServices();
return {
apiClient,
};
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(),tailwindcss(),],
})