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
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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue