From 6b82bfea72665e3752cfab71d1f2d6b98098a479 Mon Sep 17 00:00:00 2001 From: Inamul-hasan-tec Date: Sat, 8 Aug 2026 10:55:01 +0530 Subject: [PATCH] feat(platform): build Platform Control Center UI, tenant provisioning modal, support impersonation bar & fix 401 login reload --- src/api/axiosInstance.ts | 11 +- src/authentication/pages/Login.tsx | 4 +- .../platform/pages/PlatformOverview.tsx | 210 ++++++++++++ .../platform/pages/PlatformTenantsPage.tsx | 310 ++++++++++++++++++ src/lib/utils.ts | 2 +- src/routes/AppRoutes.tsx | 7 + src/routes/sidebar.config.ts | 14 +- src/services/socket.service.ts | 2 +- vite.config.ts | 2 +- 9 files changed, 553 insertions(+), 9 deletions(-) create mode 100644 src/features/platform/pages/PlatformOverview.tsx create mode 100644 src/features/platform/pages/PlatformTenantsPage.tsx diff --git a/src/api/axiosInstance.ts b/src/api/axiosInstance.ts index f26c8a3..0cf61ee 100644 --- a/src/api/axiosInstance.ts +++ b/src/api/axiosInstance.ts @@ -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); } diff --git a/src/authentication/pages/Login.tsx b/src/authentication/pages/Login.tsx index cf3d35b..241d115 100644 --- a/src/authentication/pages/Login.tsx +++ b/src/authentication/pages/Login.tsx @@ -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); } diff --git a/src/features/platform/pages/PlatformOverview.tsx b/src/features/platform/pages/PlatformOverview.tsx new file mode 100644 index 0000000..b0bee71 --- /dev/null +++ b/src/features/platform/pages/PlatformOverview.tsx @@ -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(null); + const [tenants, setTenants] = useState([]); + const [loading, setLoading] = useState(true); + const [impersonatingTenantId, setImpersonatingTenantId] = useState( + 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 ( + + } + onClick={() => navigate("/platform/tenants")} + > + Provision New Tenant + + } + /> + + {/* Support Impersonation Banner */} + {impersonatingTenantId && ( +
+
+
+ +
+
+

Support Impersonation Mode Active

+

+ Currently troubleshooting Tenant ID: {impersonatingTenantId}. Requests are safely scoped to this tenant context. +

+
+
+ +
+ )} + + {/* Metrics Cards Grid */} +
+
+
+ +
+
+

Total SaaS Tenants

+

{loading ? "..." : metrics?.tenants?.total || 0}

+ {metrics?.tenants?.active || 0} Active +
+
+ +
+
+ +
+
+

Platform Accounts

+

{loading ? "..." : metrics?.users?.total || 0}

+ Across all tenants +
+
+ +
+
+ +
+
+

Total Products

+

{loading ? "..." : metrics?.data?.total_products || 0}

+ Catalog items +
+
+ +
+
+ +
+
+

Cloudinary DAM Assets

+

{loading ? "..." : metrics?.data?.total_assets || 0}

+ Images & Raw Docs +
+
+
+ + {/* Tenants Table Preview */} +
+
+
+ +

Tenant Provisioning Registry

+
+ +
+ +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : tenants.length === 0 ? ( + + + + ) : ( + tenants.map((t) => ( + + + + + + + + + + )) + )} + +
Tenant CodeOrganization NameContact EmailProductsAssetsStatusSupport Action
Loading SaaS platform tenants...
No tenants provisioned yet.
{t.tenant_code}{t.tenant_name}{t.contact_email || "N/A"}{t.total_products || 0}{t.total_assets || 0} + + {t.status ? "Active" : "Suspended"} + + + {String(t.id) === String(impersonatingTenantId) ? ( + Active Session + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/src/features/platform/pages/PlatformTenantsPage.tsx b/src/features/platform/pages/PlatformTenantsPage.tsx new file mode 100644 index 0000000..dbeeb78 --- /dev/null +++ b/src/features/platform/pages/PlatformTenantsPage.tsx @@ -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( + 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 ( + + } + onClick={() => setIsProvisionModalOpen(true)} + > + Provision New Tenant + + } + /> + + {/* Filter & Search Bar */} +
+
+ + 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" + /> +
+
+ + {/* Tenants Table */} +
+
+ + + + + + + + + + + + + + + {loading ? ( + + + + ) : filteredTenants.length === 0 ? ( + + + + ) : ( + filteredTenants.map((t: any) => ( + + + + + + + + + + + )) + )} + +
Tenant CodeOrganizationDomainContact EmailProductsAssetsStatusActions
Loading tenants...
No matching tenants found.
{t.tenant_code}{t.tenant_name}{t.domain || "N/A"}{t.contact_email}{t.total_products || 0}{t.total_assets || 0} + + {t.status ? "Active" : "Suspended"} + + + + + {String(t.id) === String(impersonatingTenantId) ? ( + + ) : ( + + )} +
+
+
+ + {/* Provision Tenant Modal */} + {isProvisionModalOpen && ( +
+
+
+

+ Provision New Tenant Account +

+ +
+ +
+
+ + + {formik.touched.tenant_name && formik.errors.tenant_name && ( +

{formik.errors.tenant_name}

+ )} +
+ +
+
+ + +
+
+ + + {formik.touched.contact_email && formik.errors.contact_email && ( +

{formik.errors.contact_email}

+ )} +
+
+ +
+

Initial Tenant Admin Credentials

+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 56496d9..4b52913 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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}`; } diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 6b894d7..9760727 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -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 */} }> + {/* Platform Control Center */} + } /> + } /> + } /> + } /> {/* Catalog */} diff --git a/src/routes/sidebar.config.ts b/src/routes/sidebar.config.ts index 171e2c4..4beed00 100644 --- a/src/routes/sidebar.config.ts +++ b/src/routes/sidebar.config.ts @@ -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', diff --git a/src/services/socket.service.ts b/src/services/socket.service.ts index e5766f0..dfebb15 100644 --- a/src/services/socket.service.ts +++ b/src/services/socket.service.ts @@ -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; diff --git a/vite.config.ts b/vite.config.ts index ee1f517..bbf7277 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ server: { proxy: { '/uploads': { - target: 'http://localhost:5001', + target: 'http://localhost:5002', changeOrigin: true, }, },