Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
7
web/maplepress-frontend/.env.example
Normal file
7
web/maplepress-frontend/.env.example
Normal 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
24
web/maplepress-frontend/.gitignore
vendored
Normal 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?
|
||||
237
web/maplepress-frontend/README.md
Normal file
237
web/maplepress-frontend/README.md
Normal 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).
|
||||
54
web/maplepress-frontend/Taskfile.yml
Normal file
54
web/maplepress-frontend/Taskfile.yml
Normal 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~
|
||||
1623
web/maplepress-frontend/docs/ACCESS_REFRESH_TOKEN_IMPLEMENTATION.md
Normal file
1623
web/maplepress-frontend/docs/ACCESS_REFRESH_TOKEN_IMPLEMENTATION.md
Normal file
File diff suppressed because it is too large
Load diff
827
web/maplepress-frontend/docs/API/ADMIN_API.md
Normal file
827
web/maplepress-frontend/docs/API/ADMIN_API.md
Normal 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
|
||||
719
web/maplepress-frontend/docs/API/HEALTH_API.md
Normal file
719
web/maplepress-frontend/docs/API/HEALTH_API.md
Normal 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
|
||||
580
web/maplepress-frontend/docs/API/HELLO_API.md
Normal file
580
web/maplepress-frontend/docs/API/HELLO_API.md
Normal 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
|
||||
487
web/maplepress-frontend/docs/API/LOGIN_API.md
Normal file
487
web/maplepress-frontend/docs/API/LOGIN_API.md
Normal 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)
|
||||
676
web/maplepress-frontend/docs/API/ME_API.md
Normal file
676
web/maplepress-frontend/docs/API/ME_API.md
Normal 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
|
||||
631
web/maplepress-frontend/docs/API/REFRESH_TOKEN_API.md
Normal file
631
web/maplepress-frontend/docs/API/REFRESH_TOKEN_API.md
Normal 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
|
||||
480
web/maplepress-frontend/docs/API/REGISTRATION_API.md
Normal file
480
web/maplepress-frontend/docs/API/REGISTRATION_API.md
Normal 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
|
||||
1016
web/maplepress-frontend/docs/API/SITE_API.md
Normal file
1016
web/maplepress-frontend/docs/API/SITE_API.md
Normal file
File diff suppressed because it is too large
Load diff
792
web/maplepress-frontend/docs/API/TENANT_API.md
Normal file
792
web/maplepress-frontend/docs/API/TENANT_API.md
Normal 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
|
||||
557
web/maplepress-frontend/docs/API/USER_API.md
Normal file
557
web/maplepress-frontend/docs/API/USER_API.md
Normal 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
|
||||
224
web/maplepress-frontend/docs/ARCHITECTURE_SIMPLE.md
Normal file
224
web/maplepress-frontend/docs/ARCHITECTURE_SIMPLE.md
Normal 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.
|
||||
2058
web/maplepress-frontend/docs/FRONTEND_ARCHITECTURE.md
Normal file
2058
web/maplepress-frontend/docs/FRONTEND_ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load diff
367
web/maplepress-frontend/docs/README.md
Normal file
367
web/maplepress-frontend/docs/README.md
Normal 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
|
||||
29
web/maplepress-frontend/eslint.config.js
Normal file
29
web/maplepress-frontend/eslint.config.js
Normal 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_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
web/maplepress-frontend/index.html
Normal file
13
web/maplepress-frontend/index.html
Normal 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
3397
web/maplepress-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
web/maplepress-frontend/package.json
Normal file
30
web/maplepress-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
web/maplepress-frontend/public/vite.svg
Normal file
1
web/maplepress-frontend/public/vite.svg
Normal 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 |
1
web/maplepress-frontend/src/App.css
Normal file
1
web/maplepress-frontend/src/App.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "tailwindcss";
|
||||
50
web/maplepress-frontend/src/App.jsx
Normal file
50
web/maplepress-frontend/src/App.jsx
Normal 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;
|
||||
1
web/maplepress-frontend/src/assets/react.svg
Normal file
1
web/maplepress-frontend/src/assets/react.svg
Normal 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 |
28
web/maplepress-frontend/src/index.css
Normal file
28
web/maplepress-frontend/src/index.css
Normal 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;
|
||||
}
|
||||
10
web/maplepress-frontend/src/main.jsx
Normal file
10
web/maplepress-frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
183
web/maplepress-frontend/src/pages/Auth/Login.jsx
Normal file
183
web/maplepress-frontend/src/pages/Auth/Login.jsx
Normal 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;
|
||||
529
web/maplepress-frontend/src/pages/Auth/Register.jsx
Normal file
529
web/maplepress-frontend/src/pages/Auth/Register.jsx
Normal 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;
|
||||
368
web/maplepress-frontend/src/pages/Dashboard/Dashboard.jsx
Normal file
368
web/maplepress-frontend/src/pages/Dashboard/Dashboard.jsx
Normal 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;
|
||||
279
web/maplepress-frontend/src/pages/Home/IndexPage.jsx
Normal file
279
web/maplepress-frontend/src/pages/Home/IndexPage.jsx
Normal 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;
|
||||
383
web/maplepress-frontend/src/pages/Sites/AddSite.jsx
Normal file
383
web/maplepress-frontend/src/pages/Sites/AddSite.jsx
Normal 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;
|
||||
543
web/maplepress-frontend/src/pages/Sites/AddSite.jsx.bak
Normal file
543
web/maplepress-frontend/src/pages/Sites/AddSite.jsx.bak
Normal 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;
|
||||
336
web/maplepress-frontend/src/pages/Sites/AddSiteSuccess.jsx
Normal file
336
web/maplepress-frontend/src/pages/Sites/AddSiteSuccess.jsx
Normal 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;
|
||||
404
web/maplepress-frontend/src/pages/Sites/DeleteSite.jsx
Normal file
404
web/maplepress-frontend/src/pages/Sites/DeleteSite.jsx
Normal 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;
|
||||
528
web/maplepress-frontend/src/pages/Sites/RotateApiKey.jsx
Normal file
528
web/maplepress-frontend/src/pages/Sites/RotateApiKey.jsx
Normal 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;
|
||||
348
web/maplepress-frontend/src/pages/Sites/SiteDetail.jsx
Normal file
348
web/maplepress-frontend/src/pages/Sites/SiteDetail.jsx
Normal 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;
|
||||
266
web/maplepress-frontend/src/services/API/AdminService.js
Normal file
266
web/maplepress-frontend/src/services/API/AdminService.js
Normal 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;
|
||||
236
web/maplepress-frontend/src/services/API/ApiClient.js
Normal file
236
web/maplepress-frontend/src/services/API/ApiClient.js
Normal 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;
|
||||
205
web/maplepress-frontend/src/services/API/HealthService.js
Normal file
205
web/maplepress-frontend/src/services/API/HealthService.js
Normal 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;
|
||||
124
web/maplepress-frontend/src/services/API/HelloService.js
Normal file
124
web/maplepress-frontend/src/services/API/HelloService.js
Normal 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;
|
||||
107
web/maplepress-frontend/src/services/API/LoginService.js
Normal file
107
web/maplepress-frontend/src/services/API/LoginService.js
Normal 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;
|
||||
124
web/maplepress-frontend/src/services/API/MeService.js
Normal file
124
web/maplepress-frontend/src/services/API/MeService.js
Normal 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;
|
||||
138
web/maplepress-frontend/src/services/API/RefreshTokenService.js
Normal file
138
web/maplepress-frontend/src/services/API/RefreshTokenService.js
Normal 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;
|
||||
92
web/maplepress-frontend/src/services/API/RegisterService.js
Normal file
92
web/maplepress-frontend/src/services/API/RegisterService.js
Normal 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;
|
||||
453
web/maplepress-frontend/src/services/API/SiteService.js
Normal file
453
web/maplepress-frontend/src/services/API/SiteService.js
Normal 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;
|
||||
342
web/maplepress-frontend/src/services/API/TenantService.js
Normal file
342
web/maplepress-frontend/src/services/API/TenantService.js
Normal 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;
|
||||
280
web/maplepress-frontend/src/services/API/UserService.js
Normal file
280
web/maplepress-frontend/src/services/API/UserService.js
Normal 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;
|
||||
453
web/maplepress-frontend/src/services/Manager/AuthManager.js
Normal file
453
web/maplepress-frontend/src/services/Manager/AuthManager.js
Normal 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;
|
||||
213
web/maplepress-frontend/src/services/Services.jsx
Normal file
213
web/maplepress-frontend/src/services/Services.jsx
Normal 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,
|
||||
};
|
||||
}
|
||||
8
web/maplepress-frontend/vite.config.js
Normal file
8
web/maplepress-frontend/vite.config.js
Normal 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(),],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue