feat(platform): build Platform Control Center UI, tenant provisioning modal, support impersonation bar & fix 401 login reload
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,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
@@ -7,7 +7,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/uploads': {
|
||||
target: 'http://localhost:5001',
|
||||
target: 'http://localhost:5002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user