feat(platform): build Platform Control Center UI, tenant provisioning modal, support impersonation bar & fix 401 login reload

This commit is contained in:
Inamul-hasan-tec
2026-08-08 10:55:01 +05:30
parent cb711f767e
commit 6b82bfea72
9 changed files with 553 additions and 9 deletions
+7 -4
View File
@@ -1,6 +1,6 @@
import axios, { type InternalAxiosRequestConfig } from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5002';
const axiosInstance = axios.create({
baseURL: API_BASE_URL,
@@ -29,10 +29,13 @@ axiosInstance.interceptors.request.use(
// Response interceptor
axiosInstance.interceptors.response.use(
(response) => response,
(error: { response?: { status?: number } }) => {
if (error.response?.status === 401) {
(error: { config?: { url?: string }; response?: { status?: number } }) => {
const isLoginEndpoint = error.config?.url?.includes('/auth/login');
if (error.response?.status === 401 && !isLoginEndpoint) {
localStorage.removeItem('accessToken');
window.location.href = '/login';
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
+3 -1
View File
@@ -5,6 +5,7 @@ import { setCredentials } from "../../store/slices/authSlice";
import { authService } from "../services/authService";
import { Eye, EyeOff, Lock, Mail, ArrowRight } from "lucide-react";
import { AuthLayout } from "../components/AuthLayout";
import { notify } from "../../services/toast";
export const Login = () => {
const [email, setEmail] = useState("");
@@ -50,8 +51,9 @@ export const Login = () => {
const msg =
err?.response?.data?.message ||
err?.message ||
'Login failed. Please check your credentials.';
'Invalid email or password. Please try again.';
setError(msg);
notify.error(msg);
} finally {
setIsLoading(false);
}
@@ -0,0 +1,210 @@
import { useEffect, useState } from "react";
import { Building2, Users, Package, Image, ShieldCheck, Activity, UserCheck, Play, StopCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { tenantService } from "../../tenants/services/tenant.service";
import { useTenant } from "../../tenants/hooks/useTenant";
import { notify } from "../../../services/toast";
export default function PlatformOverview() {
const navigate = useNavigate();
const { impersonateTenant, stopImpersonation } = useTenant();
const [metrics, setMetrics] = useState<any>(null);
const [tenants, setTenants] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
localStorage.getItem("impersonatedTenantId")
);
const loadData = async () => {
try {
setLoading(true);
const [metricRes, tenantRes] = await Promise.all([
tenantService.getPlatformMetrics(),
tenantService.getPlatformTenants()
]);
setMetrics(metricRes);
setTenants(tenantRes);
} catch (err) {
notify.error("Failed to load platform dashboard data");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleStartImpersonate = async (tenantId: string) => {
try {
await impersonateTenant(tenantId);
setImpersonatingTenantId(tenantId);
} catch (err) {
// Handled in hook
}
};
const handleStopImpersonate = () => {
stopImpersonation();
setImpersonatingTenantId(null);
};
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Platform Operator" }, { label: "SaaS Control Center Overview" }]}
actions={
<Button
variant="primary"
icon={<Building2 className="w-4 h-4" />}
onClick={() => navigate("/platform/tenants")}
>
Provision New Tenant
</Button>
}
/>
{/* Support Impersonation Banner */}
{impersonatingTenantId && (
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500 text-white font-bold">
<ShieldCheck className="w-5 h-5" />
</div>
<div>
<h4 className="font-semibold text-foreground text-sm">Support Impersonation Mode Active</h4>
<p className="text-xs text-muted-foreground">
Currently troubleshooting Tenant ID: <span className="font-mono font-bold text-amber-500">{impersonatingTenantId}</span>. Requests are safely scoped to this tenant context.
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
icon={<StopCircle className="w-4 h-4 text-danger" />}
onClick={handleStopImpersonate}
>
End Support Mode
</Button>
</div>
)}
{/* Metrics Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-primary/10 text-primary">
<Building2 className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total SaaS Tenants</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.tenants?.total || 0}</h3>
<span className="text-xs text-emerald-500 font-medium">{metrics?.tenants?.active || 0} Active</span>
</div>
</div>
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-blue-500/10 text-blue-500">
<Users className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Platform Accounts</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.users?.total || 0}</h3>
<span className="text-xs text-muted-foreground">Across all tenants</span>
</div>
</div>
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-violet-500/10 text-violet-500">
<Package className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Products</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.data?.total_products || 0}</h3>
<span className="text-xs text-muted-foreground">Catalog items</span>
</div>
</div>
<div className="bg-surface rounded-xl p-5 border border-border shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-amber-500/10 text-amber-500">
<Image className="w-6 h-6" />
</div>
<div>
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Cloudinary DAM Assets</p>
<h3 className="text-2xl font-bold text-foreground mt-0.5">{loading ? "..." : metrics?.data?.total_assets || 0}</h3>
<span className="text-xs text-muted-foreground">Images & Raw Docs</span>
</div>
</div>
</div>
{/* Tenants Table Preview */}
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden mb-8">
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-surface flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
<h3 className="font-semibold text-foreground text-sm">Tenant Provisioning Registry</h3>
</div>
<Button variant="ghost" size="sm" onClick={() => navigate("/platform/tenants")}>
Manage All Tenants
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-background/50 text-xs text-muted-foreground uppercase tracking-wider">
<th className="px-6 py-3">Tenant Code</th>
<th className="px-6 py-3">Organization Name</th>
<th className="px-6 py-3">Contact Email</th>
<th className="px-6 py-3">Products</th>
<th className="px-6 py-3">Assets</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3 text-right">Support Action</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">Loading SaaS platform tenants...</td>
</tr>
) : tenants.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-muted-foreground">No tenants provisioned yet.</td>
</tr>
) : (
tenants.map((t) => (
<tr key={t.id} className="hover:bg-primary/5 transition-colors">
<td className="px-6 py-4 font-mono text-xs font-semibold text-primary">{t.tenant_code}</td>
<td className="px-6 py-4 font-medium text-foreground">{t.tenant_name}</td>
<td className="px-6 py-4 text-muted-foreground">{t.contact_email || "N/A"}</td>
<td className="px-6 py-4 font-semibold text-foreground">{t.total_products || 0}</td>
<td className="px-6 py-4 font-semibold text-foreground">{t.total_assets || 0}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${t.status ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
{t.status ? "Active" : "Suspended"}
</span>
</td>
<td className="px-6 py-4 text-right">
{String(t.id) === String(impersonatingTenantId) ? (
<span className="text-xs text-amber-500 font-semibold">Active Session</span>
) : (
<button
onClick={() => handleStartImpersonate(String(t.id))}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
>
<Play className="w-3.5 h-3.5" /> Support Assist
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</PageWrapper>
);
}
@@ -0,0 +1,310 @@
import { useEffect, useState } from "react";
import { Plus, Building2, Search, Play, StopCircle, CheckCircle, XCircle } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
import { useTenant } from "../../tenants/hooks/useTenant";
import { useFormik } from "formik";
import * as Yup from "yup";
import { notify } from "../../../services/toast";
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-danger focus:ring-danger' : 'border-primary/10 focus:ring-primary-light'} rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 bg-surface text-foreground placeholder-muted-foreground`;
export default function PlatformTenantsPage() {
const { tenants, fetchPlatformTenants, provisionTenant, updatePlatformStatus, impersonateTenant, stopImpersonation } = useTenant();
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isProvisionModalOpen, setIsProvisionModalOpen] = useState(false);
const [impersonatingTenantId, setImpersonatingTenantId] = useState<string | null>(
localStorage.getItem("impersonatedTenantId")
);
const loadData = async () => {
try {
setLoading(true);
await fetchPlatformTenants();
} catch {
notify.error("Failed to load platform tenants");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const formik = useFormik({
initialValues: {
tenant_name: "",
domain: "",
contact_email: "",
admin_name: "",
admin_email: "",
admin_password: ""
},
validationSchema: Yup.object().shape({
tenant_name: Yup.string().required("Organization name is required"),
contact_email: Yup.string().email("Invalid email").required("Contact email is required"),
admin_email: Yup.string().email("Invalid admin email"),
admin_password: Yup.string().min(6, "Password must be at least 6 characters")
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
try {
await provisionTenant(values);
resetForm();
setIsProvisionModalOpen(false);
fetchPlatformTenants();
} catch (err) {
// Error handled in hook
} finally {
setSubmitting(false);
}
}
});
const handleToggleStatus = async (id: string, currentStatus: boolean) => {
try {
await updatePlatformStatus(id, !currentStatus);
} catch (err) {
// Handled in hook
}
};
const handleStartImpersonate = async (tenantId: string) => {
try {
await impersonateTenant(tenantId);
setImpersonatingTenantId(tenantId);
} catch (err) {
// Handled in hook
}
};
const filteredTenants = (tenants || []).filter(t =>
t.tenant_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.tenant_code?.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.contact_email?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Platform Operator" }, { label: "Tenant Provisioning & Management" }]}
actions={
<Button
variant="primary"
icon={<Plus className="w-4 h-4" />}
onClick={() => setIsProvisionModalOpen(true)}
>
Provision New Tenant
</Button>
}
/>
{/* Filter & Search Bar */}
<div className="mb-6 flex items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search by code, name, or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-border rounded-lg text-sm bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Tenants Table */}
<div className="bg-surface rounded-xl border border-border shadow-sm overflow-hidden mb-8">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-background/50 text-xs text-muted-foreground uppercase tracking-wider">
<th className="px-6 py-3">Tenant Code</th>
<th className="px-6 py-3">Organization</th>
<th className="px-6 py-3">Domain</th>
<th className="px-6 py-3">Contact Email</th>
<th className="px-6 py-3">Products</th>
<th className="px-6 py-3">Assets</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-muted-foreground">Loading tenants...</td>
</tr>
) : filteredTenants.length === 0 ? (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-muted-foreground">No matching tenants found.</td>
</tr>
) : (
filteredTenants.map((t: any) => (
<tr key={t.id} className="hover:bg-primary/5 transition-colors">
<td className="px-6 py-4 font-mono text-xs font-semibold text-primary">{t.tenant_code}</td>
<td className="px-6 py-4 font-medium text-foreground">{t.tenant_name}</td>
<td className="px-6 py-4 text-muted-foreground">{t.domain || "N/A"}</td>
<td className="px-6 py-4 text-muted-foreground">{t.contact_email}</td>
<td className="px-6 py-4 font-semibold">{t.total_products || 0}</td>
<td className="px-6 py-4 font-semibold">{t.total_assets || 0}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${t.status ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
{t.status ? "Active" : "Suspended"}
</span>
</td>
<td className="px-6 py-4 text-right flex items-center justify-end gap-2">
<button
onClick={() => handleToggleStatus(String(t.id), t.status)}
className={`p-1.5 rounded-lg text-xs font-medium transition-colors ${
t.status ? 'bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white' : 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white'
}`}
title={t.status ? "Suspend Tenant" : "Activate Tenant"}
>
{t.status ? <XCircle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</button>
{String(t.id) === String(impersonatingTenantId) ? (
<button
onClick={() => { stopImpersonation(); setImpersonatingTenantId(null); }}
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 transition-colors"
>
End Support
</button>
) : (
<button
onClick={() => handleStartImpersonate(String(t.id))}
className="px-2.5 py-1 rounded-lg text-xs font-semibold bg-primary/10 text-primary hover:bg-primary hover:text-white transition-colors"
>
Support Assist
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Provision Tenant Modal */}
{isProvisionModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-surface rounded-xl border border-border shadow-xl w-full max-w-lg overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-gradient-to-r from-primary/5 to-surface flex items-center justify-between">
<h3 className="font-semibold text-foreground text-sm flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary" /> Provision New Tenant Account
</h3>
<button
onClick={() => setIsProvisionModalOpen(false)}
className="text-muted-foreground hover:text-foreground text-sm font-semibold"
>
</button>
</div>
<form onSubmit={formik.handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Organization Name *</label>
<input
type="text"
name="tenant_name"
value={formik.values.tenant_name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.tenant_name && Boolean(formik.errors.tenant_name))}
placeholder="e.g. IKEA Global"
/>
{formik.touched.tenant_name && formik.errors.tenant_name && (
<p className="text-xs text-danger mt-1">{formik.errors.tenant_name}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Domain (Optional)</label>
<input
type="text"
name="domain"
value={formik.values.domain}
onChange={formik.handleChange}
className={inputClass()}
placeholder="ikea.com"
/>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-1">Contact Email *</label>
<input
type="email"
name="contact_email"
value={formik.values.contact_email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.contact_email && Boolean(formik.errors.contact_email))}
placeholder="support@ikea.com"
/>
{formik.touched.contact_email && formik.errors.contact_email && (
<p className="text-xs text-danger mt-1">{formik.errors.contact_email}</p>
)}
</div>
</div>
<div className="pt-2 border-t border-border">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">Initial Tenant Admin Credentials</h4>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Name</label>
<input
type="text"
name="admin_name"
value={formik.values.admin_name}
onChange={formik.handleChange}
className={inputClass()}
placeholder="John Admin"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Email</label>
<input
type="email"
name="admin_email"
value={formik.values.admin_email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
className={inputClass(formik.touched.admin_email && Boolean(formik.errors.admin_email))}
placeholder="admin@ikea.com"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">Admin Password</label>
<input
type="password"
name="admin_password"
value={formik.values.admin_password}
onChange={formik.handleChange}
className={inputClass(formik.touched.admin_password && Boolean(formik.errors.admin_password))}
placeholder="••••••••"
/>
</div>
</div>
</div>
<div className="pt-4 flex items-center justify-end gap-3 border-t border-border">
<Button variant="outline" type="button" onClick={() => setIsProvisionModalOpen(false)}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={formik.isSubmitting}>
Provision Tenant
</Button>
</div>
</form>
</div>
</div>
)}
</PageWrapper>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function getAssetUrl(url?: string): string {
return url;
}
const cleanUrl = url.startsWith('/') ? url : `/${url}`;
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || (import.meta as any).env?.VITE_API_URL || 'http://localhost:5001';
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || (import.meta as any).env?.VITE_API_URL || 'http://localhost:5002';
return `${baseUrl}${cleanUrl}`;
}
+7
View File
@@ -31,6 +31,8 @@ import { SettingRoutes } from '../features/settings/routes/settings.routes';
import { TenantRoutes } from '../features/tenants/routes/tenant.routes';
import { RoleRoutes } from '../features/roles/routes/role.routes';
import { NotificationRoutes } from '../features/notifications/routes/notifications.routes';
import PlatformOverview from '../features/platform/pages/PlatformOverview';
import PlatformTenantsPage from '../features/platform/pages/PlatformTenantsPage';
const AppRoutes = () => {
return (
@@ -47,6 +49,11 @@ const AppRoutes = () => {
{/* Protected Routes with Layout */}
<Route element={<AuthGuard><MainLayout /></AuthGuard>}>
{/* Platform Control Center */}
<Route path="/platform" element={<Navigate to="/platform/overview" replace />} />
<Route path="/platform/overview" element={<PlatformOverview />} />
<Route path="/platform/tenants" element={<PlatformTenantsPage />} />
<Route path="/dashboard" element={<DashboardRoutes />} />
{/* Catalog */}
+13 -1
View File
@@ -19,7 +19,10 @@ import {
Settings,
List,
Layers2,
Bell
Bell,
ShieldCheck,
Activity,
Building2
} from 'lucide-react';
import React from 'react';
@@ -40,6 +43,15 @@ export interface SidebarItem {
}
export const sidebarConfig: SidebarItem[] = [
{
label: 'Platform Control',
href: '/platform',
icon: ShieldCheck,
children: [
{ label: 'SaaS Overview & Metrics', href: '/platform/overview', icon: Activity },
{ label: 'Tenant Provisioning', href: '/platform/tenants', icon: Building2 },
]
},
{
label: 'Dashboard',
href: '/dashboard',
+1 -1
View File
@@ -1,6 +1,6 @@
import { io, Socket } from 'socket.io-client';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5002';
class SocketServiceClass {
private socket: Socket | null = null;
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineConfig({
server: {
proxy: {
'/uploads': {
target: 'http://localhost:5001',
target: 'http://localhost:5002',
changeOrigin: true,
},
},