14 changed files with 777 additions and 344 deletions
+1 -8
View File
@@ -32,13 +32,6 @@ const Dashboard = () => {
try {
const response = await moduleApi.launchModule(moduleId);
await fetch(response.target_url, {
method: "POST",
headers: { "Content-Type": "application/json", ...response.headers },
body: JSON.stringify(response.payload),
credentials: "include",
});
if (response.redirect_url) {
try {
const url = new URL(response.redirect_url);
@@ -202,4 +195,4 @@ const Dashboard = () => {
);
};
export default Dashboard;
export default Dashboard;
@@ -1,5 +1,5 @@
export type ModuleStatus = 'active' | 'inactive';
export type EnvironmentTrustType = 'internal' | 'full' | 'none';
export type EnvironmentTrustType = 'hmac' | 'rsa' | 'secret' | 'internal' | 'full' | 'none' | string;
export interface Module {
id: string;
@@ -37,6 +37,7 @@ export interface ModuleEnvironment {
frontend_base_url: string;
backend_base_url: string;
sso_entry_path: string;
sso_exchange_endpoint?: string;
permission_sync_endpoint: string;
provisioning_endpoint: string;
trust_type: EnvironmentTrustType;
@@ -51,6 +52,7 @@ export interface EnvironmentCreate {
frontend_base_url: string;
backend_base_url: string;
sso_entry_path?: string;
sso_exchange_endpoint?: string;
permission_sync_endpoint?: string;
provisioning_endpoint?: string;
trust_type?: EnvironmentTrustType;
@@ -64,6 +66,7 @@ export interface EnvironmentUpdate {
frontend_base_url?: string;
backend_base_url?: string;
sso_entry_path?: string;
sso_exchange_endpoint?: string;
permission_sync_endpoint?: string;
provisioning_endpoint?: string;
trust_type?: EnvironmentTrustType;
@@ -15,6 +15,7 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
frontend_base_url: '',
backend_base_url: '',
sso_entry_path: '/sso/callback',
sso_exchange_endpoint: '/sso/exchange',
permission_sync_endpoint: '/internal/permissions',
provisioning_endpoint: '/internal/tenants/provision',
trust_type: 'hmac',
@@ -31,10 +32,11 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
slug: environment.slug,
frontend_base_url: environment.frontend_base_url,
backend_base_url: environment.backend_base_url,
sso_entry_path: environment.sso_entry_path,
permission_sync_endpoint: environment.permission_sync_endpoint,
provisioning_endpoint: environment.provisioning_endpoint,
trust_type: environment.trust_type,
sso_entry_path: environment.sso_entry_path || '/sso/callback',
sso_exchange_endpoint: environment.sso_exchange_endpoint || '/sso/exchange',
permission_sync_endpoint: environment.permission_sync_endpoint || '/internal/permissions',
provisioning_endpoint: environment.provisioning_endpoint || '/internal/tenants/provision',
trust_type: environment.trust_type || 'hmac',
hmac_secret: '',
is_default: environment.is_default,
is_active: environment.is_active,
@@ -58,10 +60,11 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
frontend_base_url: formData.frontend_base_url,
backend_base_url: formData.backend_base_url,
sso_entry_path: formData.sso_entry_path,
sso_exchange_endpoint: formData.sso_exchange_endpoint,
permission_sync_endpoint: formData.permission_sync_endpoint,
provisioning_endpoint: formData.provisioning_endpoint,
trust_type: formData.trust_type as any,
...(formData.hmac_secret && { trust_credentials }),
...(formData.hmac_secret.trim() ? { trust_credentials } : {}),
is_default: formData.is_default,
is_active: formData.is_active,
};
@@ -72,6 +75,7 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
frontend_base_url: formData.frontend_base_url,
backend_base_url: formData.backend_base_url,
sso_entry_path: formData.sso_entry_path,
sso_exchange_endpoint: formData.sso_exchange_endpoint,
permission_sync_endpoint: formData.permission_sync_endpoint,
provisioning_endpoint: formData.provisioning_endpoint,
trust_type: formData.trust_type as any,
@@ -83,7 +87,7 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
}
onClose(true);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to save environment');
setError(err.response?.data?.detail || err.message || 'Failed to save environment');
} finally {
setLoading(false);
}
@@ -103,7 +107,7 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
disabled={loading}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? "Saving..." : environment ? "Update" : "Create"}
{loading ? "Saving..." : environment ? "Update Environment" : "Create Environment"}
</button>
<button
type="button"
@@ -117,7 +121,7 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
>
<form id="env-form" onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
@@ -132,21 +136,24 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="prod, staging, eu-prod"
placeholder="local, prod, staging"
required
disabled={!!environment}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Trust Type
Trust Type *
</label>
<input
type="text"
value="HMAC-SHA256"
disabled
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-secondary) rounded-lg opacity-60 cursor-not-allowed"
/>
<select
value={formData.trust_type}
onChange={(e) => setFormData({ ...formData, trust_type: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="hmac">HMAC (Shared Secret)</option>
<option value="rsa">RSA (Public Key)</option>
<option value="secret">Secret Key</option>
<option value="internal">Internal Service</option>
</select>
</div>
</div>
@@ -159,7 +166,7 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
value={formData.frontend_base_url}
onChange={(e) => setFormData({ ...formData, frontend_base_url: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://module.example.com"
placeholder="http://127.0.0.1:5173"
required
/>
</div>
@@ -173,7 +180,7 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
value={formData.backend_base_url}
onChange={(e) => setFormData({ ...formData, backend_base_url: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://api.module.example.com"
placeholder="http://127.0.0.1:5002"
required
/>
</div>
@@ -188,71 +195,87 @@ const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProp
value={formData.sso_entry_path}
onChange={(e) => setFormData({ ...formData, sso_entry_path: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="/sso/callback"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Permission Sync Endpoint *
SSO Exchange Endpoint *
</label>
<input
type="text"
value={formData.sso_exchange_endpoint}
onChange={(e) => setFormData({ ...formData, sso_exchange_endpoint: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="/sso/exchange"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Permission Sync Endpoint
</label>
<input
type="text"
value={formData.permission_sync_endpoint}
onChange={(e) => setFormData({ ...formData, permission_sync_endpoint: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
placeholder="/internal/permissions"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Provisioning Endpoint
</label>
<input
type="text"
value={formData.provisioning_endpoint}
onChange={(e) => setFormData({ ...formData, provisioning_endpoint: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="/internal/tenants/provision"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
Provisioning Endpoint *
</label>
<input
type="text"
value={formData.provisioning_endpoint}
onChange={(e) => setFormData({ ...formData, provisioning_endpoint: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
HMAC Secret {!environment && "*"}
{formData.trust_type === 'rsa' ? 'Public Key' : 'Trust Secret / HMAC Secret'} {!environment && '*'}
</label>
<input
type="password"
value={formData.hmac_secret}
onChange={(e) => setFormData({ ...formData, hmac_secret: e.target.value })}
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={environment ? "Leave blank to keep existing" : "Enter secret"}
placeholder={environment ? "Leave blank to keep existing secret" : "Enter shared trust secret"}
required={!environment}
/>
<p className="text-xs text-(--text-secondary) mt-1">
Stored securely on backend
{environment ? 'Leave blank to preserve existing credentials, or enter a new value to update.' : 'Secret key stored securely in backend and used for SSO validation.'}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-6 pt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_default}
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
className="rounded border-(--card-border) bg-(--background)"
className="rounded border-(--card-border) bg-(--background) text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-(--text-primary)">Set as default</span>
<span className="text-sm font-medium text-(--text-primary)">Set as default environment</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="rounded border-(--card-border) bg-(--background)"
className="rounded border-(--card-border) bg-(--background) text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-(--text-primary)">Active</span>
<span className="text-sm font-medium text-(--text-primary)">Active</span>
</label>
</div>
</form>
@@ -133,10 +133,16 @@ const ModuleEnvironments = () => {
<p className="text-(--text-primary)">{env.trust_type}</p>
</div>
<div>
<span className="text-gray-500">SSO Path:</span>
<span className="text-gray-500">SSO Entry Path:</span>
<p className="text-(--text-primary) font-mono text-xs">{env.sso_entry_path}</p>
</div>
</div>
{env.sso_exchange_endpoint && (
<div>
<span className="text-gray-500">SSO Exchange Endpoint:</span>
<p className="text-(--text-primary) font-mono text-xs">{env.sso_exchange_endpoint}</p>
</div>
)}
</div>
<div className="flex items-center gap-2 pt-2 border-t">
@@ -11,9 +11,7 @@ export interface Module {
}
export interface LaunchResponse {
target_url: string;
payload: Record<string, any>;
headers: Record<string, string>;
grant_code: string;
redirect_url: string;
}
@@ -23,4 +21,4 @@ export const moduleApi = {
launchModule: (moduleId: string): Promise<LaunchResponse> =>
apiClient.post<LaunchResponse>("/api/sso/initiate", { module_id: moduleId }, { toast: false }),
};
};
+1
View File
@@ -4,6 +4,7 @@ export type Role = {
tenant_id?: string | null;
tenant_name?: string | null;
is_default: boolean;
assigned_modules?: string[];
created_at: string;
updated_at: string;
};
@@ -149,7 +149,13 @@ const AddRoles = () => {
const includedIds = new Set<string>();
accessOptions.forEach((option) => {
if (user.role?.accesses.includes(option.access_code)) {
// Subscribed module accesses returned by the backend are scoped to the tenant's active subscriptions
const isModuleAccess = Boolean(
option.module_id || (option.module_name && option.module_name !== "SaaS (Internal)")
);
const isAllowed = isModuleAccess || user.role?.accesses.includes(option.access_code);
if (isAllowed) {
let current: RoleAccess | undefined = option;
while (current) {
if (includedIds.has(current.id)) break;
@@ -102,7 +102,13 @@ const AllRoles = () => {
const includedIds = new Set<string>();
accessOptions.forEach((option) => {
if (currentUser.role?.accesses.includes(option.access_code)) {
// Subscribed module accesses returned by the backend are scoped to the tenant's active subscriptions
const isModuleAccess = Boolean(
option.module_id || (option.module_name && option.module_name !== "SaaS (Internal)")
);
const isAllowed = isModuleAccess || currentUser.role?.accesses.includes(option.access_code);
if (isAllowed) {
let current: RoleAccess | undefined = option;
while (current) {
if (includedIds.has(current.id)) break;
@@ -1,3 +1,23 @@
export type PlanApplicationEnvironment = {
id: string;
slug: string;
is_default: boolean;
is_active: boolean;
frontend_base_url: string;
sso_entry_path?: string;
backend_base_url: string;
};
export type PlanApplication = {
module_id: string;
module_code: string;
module_name: string;
description?: string | null;
icon_url?: string | null;
display_order: number;
environments: PlanApplicationEnvironment[];
};
export type SubscriptionPlan = {
id: string;
name: string;
@@ -9,13 +29,16 @@ export type SubscriptionPlan = {
status: string;
created_at: string;
updated_at: string;
applications?: PlanApplication[];
};
export type SubscriptionPlanDetail = SubscriptionPlan & {
access_ids: string[];
module_access_ids: string[];
applications?: PlanApplication[];
};
export type SubscriptionPlanCreateRequest = {
name: string;
description?: string;
+13 -1
View File
@@ -20,17 +20,29 @@ export type ModuleEnvironmentAssignment = {
environment_slug: string;
};
export type TenantOwnerCreate = {
first_name: string;
last_name: string;
email: string;
password: string;
phone_number?: string;
};
export type TenantCreateRequest = {
tenant_name: string;
tenant_domain: string;
tenant_logo_url?: string;
plan_id: string;
selected_module_ids: string[];
module_environments: ModuleEnvironmentAssignment[];
owner: TenantOwnerCreate;
start_date?: string;
end_date?: string;
status?: TenantStatus;
module_environments?: ModuleEnvironmentAssignment[];
};
export type TenantUpdateRequest = {
tenant_name?: string;
tenant_domain?: string;
+527 -244
View File
@@ -7,11 +7,8 @@ import { tenantsApi } from "../TenantsApi";
import type { Tenant, TenantCreateRequest, ModuleEnvironmentAssignment, TenantStatus } from "../TenantsTypes";
import { useAuth } from "../../../context/AuthContext";
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
import type { RoleAccess } from "../../roles/RolesTypes";
import { adminModuleApi } from "../../modules/admin/AdminModuleApi";
import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
import { AlertCircle } from "lucide-react";
import type { SubscriptionPlan, PlanApplication } from "../../subscriptions/SubscriptionTypes";
import { CheckCircle2, AlertCircle, Building2, CreditCard, Layers, UserCheck, ShieldCheck } from "lucide-react";
const toIsoDate = (value: Date) => value.toISOString().slice(0, 10);
@@ -22,49 +19,69 @@ const addDays = (dateValue: string, days?: number | null) => {
return toIsoDate(nextDate);
};
const APP_METADATA: Record<string, { title: string; description: string; badge: string }> = {
pim: {
title: "PIM (Product Information Management)",
description: "Manage product catalogs, attributes, categories, media, variants, and syndicated channels.",
badge: "Catalog & Products",
},
inventory: {
title: "Inventory Management",
description: "Manage stores, stock levels, purchases, sales, point of sale (POS), and operational fulfillment.",
badge: "Stock & Warehousing",
},
fulfillment: {
title: "Fulfillment & Logistics",
description: "Manage orders, dispatch waves, carrier manifests, vehicle hubs, tracking, and delivery operations.",
badge: "Shipping & Logistics",
},
};
const AddTenants = () => {
const today = new Date().toISOString().slice(0, 10);
const { hasAccess, isLoading: isAuthLoading } = useAuth();
const canCreateTenant = hasAccess("superadmin.tenant.create");
const navigate = useNavigate();
// Section 1: Company Details
const [tenantName, setTenantName] = useState("");
const [tenantDomain, setTenantDomain] = useState("");
const [tenantLogoUrl, setTenantLogoUrl] = useState("");
// Section 2: Subscription
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [isPlansLoading, setIsPlansLoading] = useState(false);
const [selectedPlanId, setSelectedPlanId] = useState("");
const [startDate, setStartDate] = useState(today);
const [endDate, setEndDate] = useState("");
const [tenantStatus, setTenantStatus] = useState<TenantStatus>("ACTIVE");
// Section 3: Applications & Environments
const [selectedModuleIds, setSelectedModuleIds] = useState<string[]>([]);
const [moduleEnvAssignments, setModuleEnvAssignments] = useState<ModuleEnvironmentAssignment[]>([]);
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [isPlansLoading, setIsPlansLoading] = useState(false);
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
const [allModules, setAllModules] = useState<Module[]>([]);
const [moduleEnvironments, setModuleEnvironments] = useState<Record<string, ModuleEnvironment[]>>({});
const [loadingEnvironments, setLoadingEnvironments] = useState<Record<string, boolean>>({});
const [planModules, setPlanModules] = useState<{ module_id: string; module_name: string }[]>([]);
const [isPlanModulesLoading, setIsPlanModulesLoading] = useState(false);
// Section 4: Tenant Owner
const [ownerFirstName, setOwnerFirstName] = useState("");
const [ownerLastName, setOwnerLastName] = useState("");
const [ownerEmail, setOwnerEmail] = useState("");
const [ownerPassword, setOwnerPassword] = useState("");
const [ownerConfirmPassword, setOwnerConfirmPassword] = useState("");
const [ownerPhone, setOwnerPhone] = useState("");
// UI state
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
const [isTenantLoading, setIsTenantLoading] = useState(true);
const [tenantError, setTenantError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [createdTenantSuccess, setCreatedTenantSuccess] = useState<{
tenantName: string;
tenantDomain: string;
ownerEmail: string;
selectedApps: string[];
} | null>(null);
useEffect(() => {
if (!selectedPlanId) return;
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
if (!selectedPlan) return;
setStartDate((prev) => prev || today);
setEndDate(addDays(startDate || today, selectedPlan.duration_days));
}, [plans, selectedPlanId, startDate, today]);
// Load existing tenant if not authorized to create
useEffect(() => {
if (isAuthLoading || canCreateTenant) {
setIsTenantLoading(false);
@@ -93,125 +110,120 @@ const AddTenants = () => {
return () => { isMounted = false; };
}, [canCreateTenant, isAuthLoading]);
// Load active subscription plans with direct applications contract
useEffect(() => {
if (!canCreateTenant) return;
let isMounted = true;
const loadData = async () => {
const loadPlans = async () => {
setIsPlansLoading(true);
try {
const [plansData, accessesData, modulesData] = await Promise.all([
subscriptionsApi.getAll({ status: "active" }),
subscriptionsApi.getAccesses(),
adminModuleApi.listModules(),
]);
const plansData = await subscriptionsApi.getAll({ status: "active" });
if (isMounted) {
setPlans(plansData);
setAllAccesses(accessesData);
setAllModules(modulesData);
}
} catch (error) {
console.error("Failed to load data:", error);
console.error("Failed to load subscription plans:", error);
} finally {
if (isMounted) setIsPlansLoading(false);
}
};
loadData();
loadPlans();
return () => { isMounted = false; };
}, [canCreateTenant]);
// When plan changes, sync dates and auto-select plan-included applications
const selectedPlan = plans.find((p) => p.id === selectedPlanId);
useEffect(() => {
if (!selectedPlanId || allAccesses.length === 0 || allModules.length === 0) {
setPlanModules([]);
if (!selectedPlan) {
setSelectedModuleIds([]);
setModuleEnvAssignments([]);
return;
}
let isMounted = true;
setStartDate((prev) => prev || today);
setEndDate(addDays(startDate || today, selectedPlan.duration_days));
const resolvePlanModules = async () => {
setIsPlanModulesLoading(true);
try {
const planDetail = await subscriptionsApi.getById(selectedPlanId);
const apps: PlanApplication[] = selectedPlan.applications || [];
const appIds = apps.map((a) => a.module_id);
setSelectedModuleIds(appIds);
const moduleAccessIdSet = new Set(planDetail.module_access_ids);
const moduleIdsFromPlan = new Set<string>();
const initialAssignments: ModuleEnvironmentAssignment[] = apps.map((app) => {
const defaultEnv = app.environments.find((e) => e.is_default) || app.environments[0];
return {
module_id: app.module_id,
environment_slug: defaultEnv?.slug || "local",
};
});
setModuleEnvAssignments(initialAssignments);
}, [selectedPlanId, plans]);
allAccesses.forEach((access) => {
if (access.module_id && moduleAccessIdSet.has(access.id)) {
moduleIdsFromPlan.add(access.module_id);
}
});
const moduleMap = new Map(allModules.map((m) => [m.id, m]));
const resolvedModules: { module_id: string; module_name: string }[] = [];
moduleIdsFromPlan.forEach((modId) => {
const mod = moduleMap.get(modId);
if (mod) {
resolvedModules.push({ module_id: mod.id, module_name: mod.module_name });
}
});
if (isMounted) {
setPlanModules(resolvedModules);
const newAssignments: ModuleEnvironmentAssignment[] = [];
await Promise.all(
resolvedModules.map(async (mod) => {
if (!moduleEnvironments[mod.module_id]) {
setLoadingEnvironments((prev) => ({ ...prev, [mod.module_id]: true }));
try {
const envs = await adminModuleApi.listEnvironments(mod.module_id);
if (isMounted) {
setModuleEnvironments((prev) => ({ ...prev, [mod.module_id]: envs }));
const defaultEnv = envs.find((e) => e.is_default)?.slug || envs[0]?.slug || "";
newAssignments.push({ module_id: mod.module_id, environment_slug: defaultEnv });
}
} catch {
if (isMounted) {
setModuleEnvironments((prev) => ({ ...prev, [mod.module_id]: [] }));
newAssignments.push({ module_id: mod.module_id, environment_slug: "" });
}
} finally {
if (isMounted) setLoadingEnvironments((prev) => ({ ...prev, [mod.module_id]: false }));
}
} else {
const envs = moduleEnvironments[mod.module_id];
const defaultEnv = envs.find((e) => e.is_default)?.slug || envs[0]?.slug || "";
newAssignments.push({ module_id: mod.module_id, environment_slug: defaultEnv });
}
})
);
if (isMounted) setModuleEnvAssignments(newAssignments);
}
} catch (error) {
console.error("Failed to resolve plan modules:", error);
} finally {
if (isMounted) setIsPlanModulesLoading(false);
const handleToggleModule = (moduleId: string) => {
setSelectedModuleIds((prev) => {
if (prev.includes(moduleId)) {
if (prev.length === 1) return prev; // At least one application must remain selected
return prev.filter((id) => id !== moduleId);
} else {
return [...prev, moduleId];
}
};
});
};
resolvePlanModules();
return () => { isMounted = false; };
}, [selectedPlanId, allAccesses, allModules]);
const handleEnvironmentChange = (moduleId: string, envSlug: string) => {
setModuleEnvAssignments((prev) => {
const existing = prev.find((a) => a.module_id === moduleId);
if (existing) {
return prev.map((a) => (a.module_id === moduleId ? { ...a, environment_slug: envSlug } : a));
}
return [...prev, { module_id: moduleId, environment_slug: envSlug }];
});
};
const handleEnvironmentChange = (moduleId: string, slug: string) => {
setModuleEnvAssignments((prev) =>
prev.map((a) => (a.module_id === moduleId ? { ...a, environment_slug: slug } : a))
);
const validateForm = (): string | null => {
if (!tenantName.trim()) return "Company / Tenant Name is required.";
if (!tenantDomain.trim()) return "Tenant Domain is required.";
if (!selectedPlanId) return "Please select a Subscription Plan.";
if (selectedModuleIds.length === 0) return "At least one application must be selected.";
for (const modId of selectedModuleIds) {
const assignment = moduleEnvAssignments.find((a) => a.module_id === modId);
if (!assignment || !assignment.environment_slug) {
const app = selectedPlan?.applications?.find((a) => a.module_id === modId);
return `Please select a valid environment for ${app?.module_name || "selected application"}.`;
}
}
if (!ownerFirstName.trim() || !ownerLastName.trim()) return "Owner First and Last Name are required.";
if (!ownerEmail.trim()) return "Owner Email address is required.";
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(ownerEmail.trim())) return "Please enter a valid Owner Email address.";
if (!ownerPassword) return "Owner Password is required.";
if (ownerPassword.length < 8) return "Owner Password must be at least 8 characters long.";
if (ownerPassword !== ownerConfirmPassword) return "Passwords do not match.";
return null;
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setErrorMessage("");
const clientValidationError = validateForm();
if (clientValidationError) {
setErrorMessage(clientValidationError);
return;
}
setIsLoading(true);
try {
const filteredAssignments = moduleEnvAssignments.filter((a) =>
selectedModuleIds.includes(a.module_id) && a.environment_slug
);
const payload: TenantCreateRequest = {
tenant_name: tenantName.trim(),
tenant_domain: tenantDomain.trim(),
@@ -220,40 +232,59 @@ const AddTenants = () => {
start_date: startDate || undefined,
end_date: endDate || undefined,
status: tenantStatus,
module_environments: moduleEnvAssignments.filter((a) => a.environment_slug),
selected_module_ids: selectedModuleIds,
module_environments: filteredAssignments,
owner: {
first_name: ownerFirstName.trim(),
last_name: ownerLastName.trim(),
email: ownerEmail.trim().toLowerCase(),
password: ownerPassword,
phone_number: ownerPhone.trim() || undefined,
},
};
await tenantsApi.create(payload);
navigate("/tenants");
const appNames = (selectedPlan?.applications || [])
.filter((a) => selectedModuleIds.includes(a.module_id))
.map((a) => a.module_name);
setCreatedTenantSuccess({
tenantName: tenantName.trim(),
tenantDomain: tenantDomain.trim(),
ownerEmail: ownerEmail.trim().toLowerCase(),
selectedApps: appNames,
});
// Clear password from memory
setOwnerPassword("");
setOwnerConfirmPassword("");
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to add tenant.";
let message = "Unable to create organisation.";
if (error instanceof Error) {
message = error.message;
} else if (typeof error === "object" && error !== null) {
const anyErr = error as any;
message = anyErr.response?.data?.detail || anyErr.message || JSON.stringify(error);
}
setErrorMessage(message);
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
if (!canCreateTenant) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<CustomBackButton to="/tenants" tooltip="Back to Tenants" />
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
{canCreateTenant ? "Add Tenant" : "Tenant Details"}
</h1>
<p className="text-sm text-[var(--text-secondary)]">
{canCreateTenant
? "Create a new tenant with domain and subscription plan."
: "Your account is assigned to this tenant."}
</p>
</div>
<div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]">Tenant Details</h1>
<p className="text-sm text-[var(--text-secondary)]">Your account is assigned to this tenant.</p>
</div>
</div>
</div>
{!canCreateTenant ? (
isAuthLoading || isTenantLoading ? (
{isAuthLoading || isTenantLoading ? (
<div className="rounded-lg border border-gray-200 bg-white p-6 relative">
<CustomLoader />
</div>
@@ -264,99 +295,178 @@ const AddTenants = () => {
) : currentTenant ? (
<div className="space-y-6 rounded-lg border border-gray-200 bg-white p-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="Tenant Name"
value={currentTenant.tenant_name}
disabled
readOnly
/>
<CustomInput
label="Tenant Domain"
value={currentTenant.tenant_domain}
disabled
readOnly
/>
<CustomInput label="Tenant Name" value={currentTenant.tenant_name} disabled readOnly />
<CustomInput label="Tenant Domain" value={currentTenant.tenant_domain} disabled readOnly />
</div>
<CustomInput
label="Tenant Logo URL"
value={currentTenant.tenant_logo_url ?? ""}
disabled
readOnly
/>
<CustomInput label="Tenant Logo URL" value={currentTenant.tenant_logo_url ?? ""} disabled readOnly />
</div>
) : (
<div className="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-500">
No tenant assigned.
</div>
)
) : (
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-lg border border-gray-200 bg-white p-6"
>
)}
</div>
);
}
// Success view
if (createdTenantSuccess) {
return (
<div className="max-w-3xl mx-auto py-8 px-4">
<div className="rounded-xl border border-emerald-200 bg-white shadow-sm overflow-hidden">
<div className="bg-emerald-50 border-b border-emerald-100 p-6 text-center">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-emerald-100 text-emerald-600 mb-3">
<CheckCircle2 size={28} />
</div>
<h2 className="text-2xl font-bold text-gray-900">Organisation Created Successfully</h2>
<p className="text-sm text-emerald-800 mt-1">
Tenant organisation and Primary Administrator account have been provisioned.
</p>
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-gray-50 p-4 rounded-lg border border-gray-200">
<div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Organisation Name</span>
<p className="text-base font-semibold text-gray-900 mt-0.5">{createdTenantSuccess.tenantName}</p>
</div>
<div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Tenant Domain</span>
<p className="text-base font-mono text-gray-900 mt-0.5">{createdTenantSuccess.tenantDomain}</p>
</div>
<div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Primary Admin Email</span>
<p className="text-base font-semibold text-gray-900 mt-0.5">{createdTenantSuccess.ownerEmail}</p>
</div>
<div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Provisioned Applications</span>
<p className="text-base text-gray-900 mt-0.5">{createdTenantSuccess.selectedApps.join(", ")}</p>
</div>
</div>
<div className="rounded-lg bg-blue-50 border border-blue-100 p-4 text-sm text-blue-800">
<p className="font-semibold mb-1">Next Steps:</p>
<ul className="list-disc list-inside space-y-1 text-xs leading-relaxed text-blue-700">
<li>The Organisation Administrator can now sign in directly to the SaaS portal using their credentials.</li>
<li>Module launching for provisioned applications (PIM, Inventory, F&L) is immediately available via SSO.</li>
<li>Provisioning events have been registered durably in the outbox for background service synchronization.</li>
</ul>
</div>
<div className="flex justify-end gap-3 pt-2">
<CustomButton
variant="outlined"
onClick={() => {
setCreatedTenantSuccess(null);
setTenantName("");
setTenantDomain("");
setTenantLogoUrl("");
setSelectedPlanId("");
setOwnerFirstName("");
setOwnerLastName("");
setOwnerEmail("");
setOwnerPhone("");
}}
>
Create Another Organisation
</CustomButton>
<CustomButton
variant="primary"
onClick={() => navigate("/tenants")}
>
View Tenants List
</CustomButton>
</div>
</div>
</div>
</div>
);
}
const includedApplications = selectedPlan?.applications || [];
return (
<div className="max-w-5xl mx-auto space-y-6 pb-12">
{/* Header */}
<div className="flex items-center gap-4">
<CustomBackButton to="/tenants" tooltip="Back to Tenants" />
<div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]">Add Organisation / Tenant</h1>
<p className="text-sm text-[var(--text-secondary)]">
Create a new organisation, assign subscribed applications (PIM, Inventory, F&L), and configure its Primary Administrator.
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Section 1: Company Details */}
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<Building2 className="text-blue-600" size={20} />
<h2 className="text-lg font-bold text-gray-900">1. Company Details</h2>
</div>
<p className="text-xs text-gray-500 mb-6">
This creates the canonical organization identity shared across all its subscribed applications.
</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="Tenant Name"
label="Company / Tenant Name *"
name="tenant_name"
placeholder="Enter tenant name"
placeholder="e.g. Ash Traders Ltd"
value={tenantName}
onChange={(e) => setTenantName(e.target.value)}
required
/>
<CustomInput
label="Tenant Domain"
label="Tenant Domain / Identifier *"
name="tenant_domain"
placeholder="example.com"
placeholder="e.g. ashtraders"
value={tenantDomain}
onChange={(e) => setTenantDomain(e.target.value)}
required
/>
</div>
<CustomInput
label="Tenant Logo URL"
name="tenant_logo_url"
placeholder="https://"
value={tenantLogoUrl}
onChange={(e) => setTenantLogoUrl(e.target.value)}
/>
<div className="mt-4">
<CustomInput
label="Tenant Logo URL (Optional)"
name="tenant_logo_url"
placeholder="https://..."
value={tenantLogoUrl}
onChange={(e) => setTenantLogoUrl(e.target.value)}
/>
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Subscription Plan</h3>
{/* Section 2: Subscription Plan */}
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<CreditCard className="text-blue-600" size={20} />
<h2 className="text-lg font-bold text-gray-900">2. Subscription Plan</h2>
</div>
<p className="text-xs text-gray-500 mb-6">
Select the subscription plan. When chosen, only its included applications and valid environments are presented.
</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomDropdown
label="Select Plan"
label="Subscription Plan *"
name="plan_id"
value={selectedPlanId}
onChange={(e) => setSelectedPlanId(e.target.value)}
options={plans.map((plan) => ({
label: `${plan.name}${plan.price != null ? `${new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(plan.price)}` : ""}`,
label: `${plan.name}${plan.price != null ? `$${plan.price}` : ""}`,
value: plan.id,
}))}
placeholder={isPlansLoading ? "Loading plans..." : "Select a subscription plan"}
placeholder={isPlansLoading ? "Loading subscription plans..." : "Select a subscription plan"}
disabled={isPlansLoading}
required
/>
</div>
<div className="grid grid-cols-1 gap-6 border-t border-gray-200 pt-6 md:grid-cols-3">
<CustomDatePicker
label="Start Date"
value={startDate}
onChange={(e) => {
const nextStartDate = e.target.value;
setStartDate(nextStartDate);
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
setEndDate(addDays(nextStartDate, selectedPlan?.duration_days));
}}
/>
<CustomDatePicker
label="End Date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
min={startDate || undefined}
/>
<CustomDropdown
label="Status"
label="Initial Status *"
value={tenantStatus}
onChange={(e) => setTenantStatus(e.target.value as TenantStatus)}
options={[
@@ -367,77 +477,250 @@ const AddTenants = () => {
/>
</div>
{/* Module Environment Assignment — appears after plan selection */}
{selectedPlanId && (
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-1">Module Environments</h3>
<p className="text-sm text-gray-500 mb-4">
Select the environment for each module included in this plan.
</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 mt-4">
<CustomDatePicker
label="Start Date"
value={startDate}
onChange={(e) => {
const nextStartDate = e.target.value;
setStartDate(nextStartDate);
if (selectedPlan) {
setEndDate(addDays(nextStartDate, selectedPlan.duration_days));
}
}}
/>
<CustomDatePicker
label="End Date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
min={startDate || undefined}
/>
</div>
</div>
{isPlanModulesLoading ? (
<div className="text-sm text-gray-500">Resolving plan modules...</div>
) : planModules.length === 0 ? (
<div className="text-sm text-gray-500 flex items-center gap-2">
<AlertCircle size={16} />
This plan has no module-level accesses configured.
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{planModules.map((mod) => {
const envs = moduleEnvironments[mod.module_id] || [];
const isLoadingEnv = loadingEnvironments[mod.module_id];
const assignment = moduleEnvAssignments.find((a) => a.module_id === mod.module_id);
{/* Section 3: Applications & Environments */}
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<Layers className="text-blue-600" size={20} />
<h2 className="text-lg font-bold text-gray-900">3. Applications & Environments</h2>
</div>
<p className="text-xs text-gray-500 mb-6">
Configure target environments for the applications included in this plan. Internal Inventory submodules (Stock, Sales, POS, Purchase, Settings) are managed internally within the Inventory application.
</p>
return (
<div
key={mod.module_id}
className="p-4 rounded-lg border border-primary-200 bg-primary-50"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div>
<div className="font-medium text-gray-900">{mod.module_name}</div>
</div>
</div>
<div className="w-48">
<CustomDropdown
label=""
value={assignment?.environment_slug || ""}
onChange={(e) => handleEnvironmentChange(mod.module_id, e.target.value)}
options={envs.map((env) => ({
label: `${env.slug}${env.is_default ? " (default)" : ""}`,
value: env.slug,
}))}
placeholder={isLoadingEnv ? "Loading..." : "Select Environment"}
disabled={isLoadingEnv || envs.length === 0}
/>
{envs.length === 0 && !isLoadingEnv && (
<div className="text-xs text-red-500 mt-1">No environments found</div>
)}
{!selectedPlanId ? (
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center text-sm text-gray-500">
Please select a Subscription Plan in Section 2 to display included applications.
</div>
) : includedApplications.length === 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 flex items-center gap-2">
<AlertCircle size={18} />
The selected plan does not include any onboarding applications (PIM, Inventory, or Fulfillment & Logistics).
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{includedApplications.map((app) => {
const isSelected = selectedModuleIds.includes(app.module_id);
const metadata = APP_METADATA[app.module_code] || {
title: app.module_name,
description: app.description || "Enterprise modular application.",
badge: "Application",
};
const assignment = moduleEnvAssignments.find((a) => a.module_id === app.module_id);
const envOptions = app.environments.map((e) => ({
label: `${e.slug}${e.is_default ? " (default)" : ""}`,
value: e.slug,
}));
return (
<div
key={app.module_id}
className={`rounded-xl border p-5 transition-all ${
isSelected
? "border-blue-300 bg-blue-50/40 shadow-xs"
: "border-gray-200 bg-gray-50 opacity-60"
}`}
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3.5">
<input
type="checkbox"
id={`app-toggle-${app.module_id}`}
checked={isSelected}
onChange={() => handleToggleModule(app.module_id)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div>
<div className="flex items-center gap-2">
<label
htmlFor={`app-toggle-${app.module_id}`}
className="font-bold text-gray-900 cursor-pointer text-base"
>
{metadata.title}
</label>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
{metadata.badge}
</span>
</div>
<p className="text-xs text-gray-600 mt-1 max-w-xl leading-relaxed">
{metadata.description}
</p>
</div>
</div>
);
})}
</div>
)}
<div className="w-full md:w-56 shrink-0">
<label className="block text-xs font-semibold text-gray-700 mb-1">
Target Environment *
</label>
<CustomDropdown
label=""
value={assignment?.environment_slug || ""}
onChange={(e) => handleEnvironmentChange(app.module_id, e.target.value)}
options={envOptions}
placeholder={envOptions.length === 0 ? "No env available" : "Select Environment"}
disabled={!isSelected || envOptions.length === 0}
/>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Section 4: Tenant Owner / Primary Admin */}
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<UserCheck className="text-blue-600" size={20} />
<h2 className="text-lg font-bold text-gray-900">4. Primary Administrator (Tenant Owner)</h2>
</div>
<p className="text-xs text-gray-500 mb-6">
This person becomes the organisations first SaaS administrator. They sign in to SaaS, launch purchased applications via SSO, and manage users and roles.
</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="First Name *"
placeholder="e.g. Ash"
value={ownerFirstName}
onChange={(e) => setOwnerFirstName(e.target.value)}
required
/>
<CustomInput
label="Last Name *"
placeholder="e.g. Admin"
value={ownerLastName}
onChange={(e) => setOwnerLastName(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 mt-4">
<CustomInput
label="Email Address *"
type="email"
placeholder="e.g. owner@ashtraders.com"
value={ownerEmail}
onChange={(e) => setOwnerEmail(e.target.value)}
required
/>
<CustomInput
label="Phone Number (Optional)"
type="tel"
placeholder="+91..."
value={ownerPhone}
onChange={(e) => setOwnerPhone(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 mt-4">
<CustomInput
label="Password *"
type="password"
placeholder="Min 8 chars, uppercase, digit, symbol"
value={ownerPassword}
onChange={(e) => setOwnerPassword(e.target.value)}
required
/>
<CustomInput
label="Confirm Password *"
type="password"
placeholder="Re-enter password"
value={ownerConfirmPassword}
onChange={(e) => setOwnerConfirmPassword(e.target.value)}
required
/>
</div>
</div>
{/* Review & Submission */}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-6 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="text-blue-600" size={20} />
<h3 className="text-base font-bold text-gray-900">Review Summary</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 text-xs">
<div className="bg-white p-3 rounded-lg border border-gray-200">
<span className="text-gray-500 font-medium">Organisation</span>
<p className="font-semibold text-gray-900 mt-0.5 truncate">{tenantName || "—"}</p>
<p className="font-mono text-gray-600 truncate">{tenantDomain ? `@${tenantDomain}` : "—"}</p>
</div>
<div className="bg-white p-3 rounded-lg border border-gray-200">
<span className="text-gray-500 font-medium">Subscription Plan</span>
<p className="font-semibold text-gray-900 mt-0.5 truncate">{selectedPlan?.name || "—"}</p>
<p className="text-gray-600">{tenantStatus}</p>
</div>
<div className="bg-white p-3 rounded-lg border border-gray-200">
<span className="text-gray-500 font-medium">Applications</span>
<p className="font-semibold text-gray-900 mt-0.5">
{selectedModuleIds.length} Selected
</p>
<p className="text-gray-600 truncate">
{includedApplications
.filter((a) => selectedModuleIds.includes(a.module_id))
.map((a) => a.module_name)
.join(", ") || "None"}
</p>
</div>
<div className="bg-white p-3 rounded-lg border border-gray-200">
<span className="text-gray-500 font-medium">Primary Admin</span>
<p className="font-semibold text-gray-900 mt-0.5 truncate">
{ownerFirstName && ownerLastName ? `${ownerFirstName} ${ownerLastName}` : "—"}
</p>
<p className="text-gray-600 truncate">{ownerEmail || "—"}</p>
</div>
</div>
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{errorMessage}
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-600 flex items-start gap-2">
<AlertCircle size={18} className="shrink-0 mt-0.5" />
<span>{errorMessage}</span>
</div>
)}
<div className="flex justify-end pt-4">
<CustomButton type="submit" variant="primary" disabled={isLoading} loading={isLoading}>
Create & Provision Tenant
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200">
<CustomButton
type="button"
variant="outlined"
onClick={() => navigate("/tenants")}
disabled={isLoading}
>
Cancel
</CustomButton>
<CustomButton
type="submit"
variant="primary"
disabled={isLoading}
loading={isLoading}
>
{isLoading ? "Creating organisation and provisioning applications…" : "Create Organisation"}
</CustomButton>
</div>
</form>
)}
</div>
</form>
</div>
);
};
+41 -20
View File
@@ -363,28 +363,49 @@ const AddUsers = () => {
readOnly
/>
)}
<CustomDropdown
label={t('fields.role')}
name="role_id"
placeholder={
isSuperAdminUser
? isRoleLoading
? t('fields.roleLoading')
: t('fields.roleSuperAdminPlaceholder')
: formData.tenant_id
<div>
<CustomDropdown
label={t('fields.role')}
name="role_id"
placeholder={
isSuperAdminUser
? isRoleLoading
? t('fields.roleLoading')
: t('fields.rolePlaceholder')
: t('fields.roleFilterPlaceholder')
}
value={formData.role_id ?? ""}
onChange={handleRoleChange}
options={filteredRoles.map((role) => ({
label: role.role_name,
value: role.id,
}))}
disabled={isRoleLoading || (!isRoleLoading && filteredRoles.length === 0)}
/>
: t('fields.roleSuperAdminPlaceholder')
: formData.tenant_id
? isRoleLoading
? t('fields.roleLoading')
: t('fields.rolePlaceholder')
: t('fields.roleFilterPlaceholder')
}
value={formData.role_id ?? ""}
onChange={handleRoleChange}
options={filteredRoles.map((role) => ({
label: role.role_name,
value: role.id,
}))}
disabled={isRoleLoading || (!isRoleLoading && filteredRoles.length === 0)}
/>
{formData.role_id && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="font-medium text-(--text-primary)">Grants Module Access:</span>
{(() => {
const selRole = roles.find((r) => r.id === formData.role_id);
if (selRole?.assigned_modules && selRole.assigned_modules.length > 0) {
return selRole.assigned_modules.map((mod) => (
<span
key={mod}
className="inline-flex items-center rounded-md bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-950/40 dark:text-emerald-400"
>
{mod}
</span>
));
}
return <span className="italic text-(--text-secondary)">None (SaaS administration only)</span>;
})()}
</div>
)}
</div>
<CustomInput
label={t('fields.password')}
name="password"
+53 -14
View File
@@ -642,7 +642,25 @@ const AllUsers = () => {
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.phone')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{selectedUser.phone_number || "--"}</p></div>
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.status')}</p><CustomStatus status={selectedUser.status} /></div>
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.tenant')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{getTenantName(selectedUser.tenant_id)}</p></div>
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.role')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{getRoleName(selectedUser.role_id)}</p></div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.role')}</p>
<p className="text-sm font-medium text-[var(--text-primary)]">{getRoleName(selectedUser.role_id)}</p>
{(() => {
const r = roles.find((role) => role.id === selectedUser.role_id);
if (r?.assigned_modules && r.assigned_modules.length > 0) {
return (
<div className="mt-1 flex flex-wrap gap-1">
{r.assigned_modules.map((m) => (
<span key={m} className="inline-flex items-center rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-950/40 dark:text-emerald-400">
{m}
</span>
))}
</div>
);
}
return null;
})()}
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.created')}</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
@@ -658,7 +676,7 @@ const AllUsers = () => {
</div>
) : (
<p className="text-sm text-[var(--text-secondary)]">{t('common:common.noData')}</p>
<p className="text-sm text-[var(--text-secondary)]">{t('common.noData')}</p>
)}
</CustomModal>
@@ -666,7 +684,7 @@ const AllUsers = () => {
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
title={t('edit')}
title={t('actions.edit')}
size="lg"
footer={
<>
@@ -675,22 +693,43 @@ const AllUsers = () => {
</>
}
>
<form id="edit-user-form" onSubmit={handleUpdate} className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<form id="edit-user-form" onSubmit={handleUpdate} className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CustomInput label={t('fields.firstName')} name="first_name" value={editForm.first_name} onChange={handleEditChange} required />
<CustomInput label={t('fields.lastName')} name="last_name" value={editForm.last_name} onChange={handleEditChange} />
<CustomInput label={t('fields.email')} name="email" type="email" value={editForm.email} onChange={handleEditChange} required />
<CustomPhoneInput label={t('fields.phone')} name="phone_number" value={editForm.phone_number} onChange={handleEditPhoneChange} defaultCountry="IN" />
<CustomInput label={t('fields.tenant')} value={getTenantName(currentUser?.tenant_id ?? editForm.tenant_id) || "--"} disabled readOnly />
<CustomDropdown
label={t('fields.role')}
name="role_id"
value={editForm.role_id}
onChange={handleEditChange}
options={filteredRoles.map((r) => ({ label: r.role_name, value: r.id }))}
placeholder={isRoleLoading ? t('fields.roleLoading') : t('fields.rolePlaceholder')}
disabled={isRoleLoading || filteredRoles.length === 0}
/>
<div>
<CustomDropdown
label={t('fields.role')}
name="role_id"
value={editForm.role_id}
onChange={handleEditChange}
options={filteredRoles.map((r) => ({ label: r.role_name, value: r.id }))}
placeholder={isRoleLoading ? t('fields.roleLoading') : t('fields.rolePlaceholder')}
disabled={isRoleLoading || filteredRoles.length === 0}
/>
{editForm.role_id && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="font-medium text-[var(--text-primary)]">Grants Module Access:</span>
{(() => {
const selRole = roles.find((r) => r.id === editForm.role_id);
if (selRole?.assigned_modules && selRole.assigned_modules.length > 0) {
return selRole.assigned_modules.map((mod) => (
<span
key={mod}
className="inline-flex items-center rounded-md bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-950/40 dark:text-emerald-400"
>
{mod}
</span>
));
}
return <span className="italic text-[var(--text-secondary)]">None (SaaS administration only)</span>;
})()}
</div>
)}
</div>
<CustomInput label={t('fields.password')} name="password" type="password" placeholder={t('fields.passwordEditPlaceholder')} value={editForm.password} onChange={handleEditChange} />
<div className="flex items-center">
<CustomCheckBox label={t('fields.active')} checked={editForm.status === "active"} onChange={handleStatusChange} />
+26 -7
View File
@@ -14,6 +14,27 @@ let isRefreshing = false;
let refreshQueue: (() => void)[] = [];
const API_TOAST_OPTIONS = { autoClose: 2000 };
const getApiErrorMessage = (data: unknown, fallback: string): string => {
if (typeof data === "string" && data.trim()) return data;
if (typeof data !== "object" || data === null) return fallback;
const payload = data as {
detail?: string | Array<{ msg?: string }>;
message?: string;
};
if (typeof payload.detail === "string") return payload.detail;
if (Array.isArray(payload.detail)) {
const messages = payload.detail
.map((item) => item?.msg)
.filter((message): message is string => Boolean(message));
if (messages.length) return messages.join(" ");
}
if (typeof payload.message === "string") return payload.message;
return fallback;
};
const hardLogout = () => {
clearAuthCookies();
toast.error(
@@ -117,12 +138,10 @@ const request = async <T>(
: await response.text();
if (!response.ok) {
const backendError =
typeof data === "object" && data !== null
? (data as any).detail || (data as any).message
: undefined;
const finalErrorMessage = backendError || errorMessage || "Request failed";
const finalErrorMessage = getApiErrorMessage(
data,
errorMessage || "Request failed"
);
if (showToast) {
toast.error(finalErrorMessage, API_TOAST_OPTIONS);
@@ -166,4 +185,4 @@ export const apiClient = {
delete: <T>(path: string, options?: ApiRequestOptions) =>
request<T>(path, { ...options, method: "DELETE" }),
};
};