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

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 4 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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