Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2dbd907f8 | ||
|
|
0592f0fff8 | ||
|
|
8637e0ad3c |
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 organisation’s 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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" }),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user