feat: Add LTTS logo and update various UI components and pages across superadmin and tenant sections.

This commit is contained in:
Yashwin 2026-03-02 21:41:27 +05:30
parent 6efcb90f3e
commit 757a9f216b
14 changed files with 537 additions and 223 deletions

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/LTTS.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>qassure-frontend</title>
</head>

4
public/LTTS.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -78,6 +78,35 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const isSuperAdmin = isSuperAdminCheck();
// Get role name for display
const getRoleName = (): string => {
if (isSuperAdmin) {
return 'Super Admin';
}
let rolesArray: string[] = [];
if (Array.isArray(roles)) {
rolesArray = roles;
} else if (typeof roles === 'string') {
try {
rolesArray = JSON.parse(roles);
} catch {
rolesArray = [];
}
}
// Get the first role and format it
if (rolesArray.length > 0) {
const role = rolesArray[0];
// Convert snake_case to Title Case
return role
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
return 'User';
};
const roleName = getRoleName();
// Fetch theme if tenant admin
if (!isSuperAdmin) {
useTenantTheme();
@ -212,8 +241,20 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
>
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
</div>
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
<div className="flex flex-col">
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
</div>
{(!isSuperAdmin && logoUrl) ? null : (
<div
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
style={{
color: !isSuperAdmin && theme?.accent_color ? theme.accent_color : '#084cc8',
}}
>
{roleName}
</div>
)}
</div>
</div>
<button
@ -238,6 +279,18 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<span className="text-[13px] font-medium text-[#0f1724]">Support Center</span>
</button>
</div>
{/* Powered by LTTS */}
<div className="w-full flex flex-col items-center justify-center gap-0 px-2">
<p className="text-[10px] font-medium text-[#9ca3af] capitalize leading-normal">
Powered by
</p>
<img
src="/LTTS.svg"
alt="L&T Technology Services"
className="h-6 w-auto max-w-[150px] object-contain"
/>
</div>
</aside>
{/* Desktop Sidebar */}
@ -266,8 +319,20 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
>
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
</div>
<div className="text-base md:text-base lg:text-base xl:text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
<div className="flex flex-col">
<div className="text-base md:text-base lg:text-base xl:text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]">
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'}
</div>
{(!isSuperAdmin && logoUrl) ? null : (
<div
className="text-[12px] font-semibold capitalize mt-[7px] -translate-y-1/2"
style={{
color: !isSuperAdmin && theme?.accent_color ? theme.accent_color : '#084cc8',
}}
>
{roleName}
</div>
)}
</div>
</div>
</div>
@ -289,6 +354,18 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<span className="text-xs md:text-xs lg:text-[13px] font-medium text-[#0f1724]">Support Center</span>
</button>
</div>
{/* Powered by LTTS */}
<div className="w-full flex flex-col items-center justify-center gap-0 px-2">
<p className="text-[10px] font-medium text-[#9ca3af] capitalize leading-normal">
Powered by
</p>
<img
src="/LTTS.svg"
alt="L&T Technology Services"
className="h-6 w-auto max-w-[150px] object-contain"
/>
</div>
</aside>
</>
);

View File

@ -65,7 +65,7 @@ export const DataTable = <T,>({
return (
<th
key={column.key}
className={`px-3 md:px-4 lg:px-5 py-2 md:py-2.5 lg:py-3 ${alignClass} text-[10px] md:text-xs font-medium text-[#9aa6b2] uppercase`}
className={`px-3 md:px-2 lg:px-4 xl:px-5 py-2 md:py-1 lg:py-2.5 xl:py-3 ${alignClass} text-[10px] md:text-[8px] lg:text-xs font-medium text-[#9aa6b2] uppercase`}
>
{column.label}
</th>
@ -75,7 +75,7 @@ export const DataTable = <T,>({
</thead>
<tbody>
<tr>
<td colSpan={columns.length} className="px-3 md:px-4 lg:px-5 py-6 md:py-7 lg:py-8 text-center text-xs md:text-sm text-[#6b7280]">
<td colSpan={columns.length} className="px-3 md:px-2 lg:px-4 xl:px-5 py-6 md:py-3 lg:py-7 xl:py-8 text-center text-xs md:text-xs lg:text-sm text-[#6b7280]">
{emptyMessage}
</td>
</tr>
@ -106,14 +106,14 @@ export const DataTable = <T,>({
: column.align === 'center'
? 'text-center'
: 'text-left';
return (
<th
key={column.key}
className={`px-3 md:px-4 lg:px-5 py-2 md:py-2.5 lg:py-3 ${alignClass} text-[10px] md:text-xs font-medium text-[#9aa6b2] uppercase`}
>
{column.label}
</th>
);
return (
<th
key={column.key}
className={`px-3 md:px-2 lg:px-4 xl:px-5 py-2 md:py-1 lg:py-2.5 xl:py-3 ${alignClass} text-[10px] md:text-[8px] lg:text-xs font-medium text-[#9aa6b2] uppercase`}
>
{column.label}
</th>
);
})}
</tr>
</thead>
@ -131,7 +131,7 @@ export const DataTable = <T,>({
? 'text-center'
: 'text-left';
return (
<td key={column.key} className={`px-3 md:px-4 lg:px-5 py-2.5 md:py-3 lg:py-4 ${alignClass} text-xs md:text-sm`}>
<td key={column.key} className={`px-3 md:px-2 lg:px-4 xl:px-5 py-2.5 md:py-1.5 lg:py-3 xl:py-4 ${alignClass} text-xs md:text-[10px] lg:text-sm`}>
{column.render ? column.render(item) : String((item as any)[column.key])}
</td>
);

View File

@ -69,7 +69,7 @@ const editRoleSchema = z.object({
"Code must be lowercase and use '_' for separation (e.g. abc_def)"
),
description: z.string().min(1, 'Description is required'),
modules: z.array(z.string().uuid()).optional().nullable(),
modules: z.array(z.uuid()).optional().nullable(),
permissions: z.array(z.object({
resource: z.string(),
action: z.string(),

View File

@ -12,13 +12,13 @@ import {
PrimaryButton,
SecondaryButton,
} from '@/components/shared';
import { tenantService } from '@/services/tenant-service';
// import { tenantService } from '@/services/tenant-service';
import { roleService } from '@/services/role-service';
import type { User } from '@/types/user';
// Validation schema
const editUserSchema = z.object({
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
email: z.email({ message: 'Please enter a valid email address' }),
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required'),
status: z.enum(['active', 'suspended', 'deleted'], {
@ -58,7 +58,7 @@ export const EditUserModal = ({
const [isLoadingUser, setIsLoadingUser] = useState<boolean>(false);
const [loadError, setLoadError] = useState<string | null>(null);
const loadedUserIdRef = useRef<string | null>(null);
const [selectedTenantId, setSelectedTenantId] = useState<string>('');
// const [selectedTenantId, setSelectedTenantId] = useState<string>('');
const [selectedRoleId, setSelectedRoleId] = useState<string>('');
const {
@ -75,71 +75,74 @@ export const EditUserModal = ({
});
const statusValue = watch('status');
const tenantIdValue = watch('tenant_id');
// const tenantIdValue = watch('tenant_id');
const roleIdValue = watch('role_id');
// Store tenant and role names from user response
const [currentTenantName, setCurrentTenantName] = useState<string>('');
// const [currentTenantName, setCurrentTenantName] = useState<string>('');
const [currentRoleName, setCurrentRoleName] = useState<string>('');
// Store initial options for immediate display
const [initialTenantOption, setInitialTenantOption] = useState<{ value: string; label: string } | null>(null);
// const [initialTenantOption, setInitialTenantOption] = useState<{ value: string; label: string } | null>(null);
const [initialRoleOption, setInitialRoleOption] = useState<{ value: string; label: string } | null>(null);
console.log('roleIdValue', roleIdValue);
console.log('initialRoleOption', initialRoleOption);
// Load tenants for dropdown - ensure selected tenant is included
const loadTenants = async (page: number, limit: number) => {
const response = await tenantService.getAll(page, limit);
let options = response.data.map((tenant) => ({
value: tenant.id,
label: tenant.name,
}));
// const loadTenants = async (page: number, limit: number) => {
// const response = await tenantService.getAll(page, limit);
// let options = response.data.map((tenant) => ({
// value: tenant.id,
// label: tenant.name,
// }));
// Always include initial option if it exists and matches the selected value
if (initialTenantOption && page === 1) {
const exists = options.find((opt) => opt.value === initialTenantOption.value);
if (!exists) {
options = [initialTenantOption, ...options];
}
}
// // Always include initial option if it exists and matches the selected value
// if (initialTenantOption && page === 1) {
// const exists = options.find((opt) => opt.value === initialTenantOption.value);
// if (!exists) {
// options = [initialTenantOption, ...options];
// }
// }
// If we have a selected tenant ID and it's not in the current options, add it with stored name
if (selectedTenantId && page === 1 && !initialTenantOption) {
const existingOption = options.find((opt) => opt.value === selectedTenantId);
if (!existingOption) {
// If we have the name from user response, use it; otherwise fetch
if (currentTenantName) {
options = [
{
value: selectedTenantId,
label: currentTenantName,
},
...options,
];
} else {
try {
const tenantResponse = await tenantService.getById(selectedTenantId);
if (tenantResponse.success) {
// Prepend the selected tenant to the options
options = [
{
value: tenantResponse.data.id,
label: tenantResponse.data.name,
},
...options,
];
}
} catch (err) {
// If fetching fails, just continue with existing options
console.warn('Failed to fetch selected tenant:', err);
}
}
}
}
// // If we have a selected tenant ID and it's not in the current options, add it with stored name
// if (selectedTenantId && page === 1 && !initialTenantOption) {
// const existingOption = options.find((opt) => opt.value === selectedTenantId);
// if (!existingOption) {
// // If we have the name from user response, use it; otherwise fetch
// if (currentTenantName) {
// options = [
// {
// value: selectedTenantId,
// label: currentTenantName,
// },
// ...options,
// ];
// } else {
// try {
// const tenantResponse = await tenantService.getById(selectedTenantId);
// if (tenantResponse.success) {
// // Prepend the selected tenant to the options
// options = [
// {
// value: tenantResponse.data.id,
// label: tenantResponse.data.name,
// },
// ...options,
// ];
// }
// } catch (err) {
// // If fetching fails, just continue with existing options
// console.warn('Failed to fetch selected tenant:', err);
// }
// }
// }
// }
return {
options,
pagination: response.pagination,
};
};
// return {
// options,
// pagination: response.pagination,
// };
// };
// Load roles for dropdown - ensure selected role is included
const loadRoles = async (page: number, limit: number) => {
@ -174,21 +177,21 @@ export const EditUserModal = ({
...options,
];
} else {
try {
const roleResponse = await roleService.getById(selectedRoleId);
if (roleResponse.success) {
// Prepend the selected role to the options
options = [
{
value: roleResponse.data.id,
label: roleResponse.data.name,
},
...options,
];
}
} catch (err) {
// If fetching fails, just continue with existing options
console.warn('Failed to fetch selected role:', err);
try {
const roleResponse = await roleService.getById(selectedRoleId);
if (roleResponse.success) {
// Prepend the selected role to the options
options = [
{
value: roleResponse.data.id,
label: roleResponse.data.name,
},
...options,
];
}
} catch (err) {
// If fetching fails, just continue with existing options
console.warn('Failed to fetch selected role:', err);
}
}
}
@ -205,62 +208,62 @@ export const EditUserModal = ({
if (isOpen && userId) {
// Only load if this is a new userId or modal was closed and reopened
if (loadedUserIdRef.current !== userId) {
const loadUser = async (): Promise<void> => {
try {
setIsLoadingUser(true);
setLoadError(null);
clearErrors();
const user = await onLoadUser(userId);
const loadUser = async (): Promise<void> => {
try {
setIsLoadingUser(true);
setLoadError(null);
clearErrors();
const user = await onLoadUser(userId);
loadedUserIdRef.current = userId;
// Extract tenant and role IDs from nested objects or fallback to direct properties
const tenantId = user.tenant?.id || user.tenant_id || '';
const roleId = user.role?.id || user.role_id || '';
const tenantName = user.tenant?.name || '';
// const tenantName = user.tenant?.name || '';
const roleName = user.role?.name || '';
setSelectedTenantId(tenantId);
setSelectedRoleId(roleId);
setCurrentTenantName(tenantName);
// setSelectedTenantId(tenantId);
setSelectedRoleId(roleId);
// setCurrentTenantName(tenantName);
setCurrentRoleName(roleName);
// Set initial options for immediate display using names from user response
if (tenantId && tenantName) {
setInitialTenantOption({ value: tenantId, label: tenantName });
}
// if (tenantId && tenantName) {
// setInitialTenantOption({ value: tenantId, label: tenantName });
// }
if (roleId && roleName) {
setInitialRoleOption({ value: roleId, label: roleName });
}
reset({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
status: user.status,
tenant_id: defaultTenantId || tenantId,
role_id: roleId,
});
reset({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
status: user.status,
tenant_id: defaultTenantId || tenantId,
role_id: roleId,
});
// If defaultTenantId is provided, override tenant_id
if (defaultTenantId) {
setValue('tenant_id', defaultTenantId, { shouldValidate: true });
// If defaultTenantId is provided, override tenant_id
if (defaultTenantId) {
setValue('tenant_id', defaultTenantId, { shouldValidate: true });
}
} catch (err: any) {
setLoadError(err?.response?.data?.error?.message || 'Failed to load user details');
} finally {
setIsLoadingUser(false);
}
} catch (err: any) {
setLoadError(err?.response?.data?.error?.message || 'Failed to load user details');
} finally {
setIsLoadingUser(false);
}
};
loadUser();
};
loadUser();
}
} else if (!isOpen) {
// Only reset when modal is closed
loadedUserIdRef.current = null;
setSelectedTenantId('');
// setSelectedTenantId('');
setSelectedRoleId('');
setCurrentTenantName('');
// setCurrentTenantName('');
setCurrentRoleName('');
setInitialTenantOption(null);
// setInitialTenantOption(null);
setInitialRoleOption(null);
reset({
email: '',
@ -404,8 +407,8 @@ export const EditUserModal = ({
</div>
{/* Tenant and Role Row */}
<div className={`grid ${defaultTenantId ? 'grid-cols-1' : 'grid-cols-2'} gap-5 pb-4`}>
{!defaultTenantId && (
<div className={`grid grid-cols-2 gap-5 pb-4`}>
{/* {!defaultTenantId && (
<PaginatedSelect
label="Assign Tenant"
required
@ -416,30 +419,31 @@ export const EditUserModal = ({
initialOption={initialTenantOption || undefined}
error={errors.tenant_id?.message}
/>
)} */}
{currentRoleName !== 'Tenant Admin' && (
<PaginatedSelect
label="Assign Role"
required
placeholder="Select Role"
value={roleIdValue || ''}
onValueChange={(value) => setValue('role_id', value)}
onLoadOptions={loadRoles}
initialOption={initialRoleOption || undefined}
error={errors.role_id?.message}
/>
)}
<PaginatedSelect
label="Assign Role"
{/* Status */}
<FormSelect
label="Status"
required
placeholder="Select Role"
value={roleIdValue || ''}
onValueChange={(value) => setValue('role_id', value)}
onLoadOptions={loadRoles}
initialOption={initialRoleOption || undefined}
error={errors.role_id?.message}
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
error={errors.status?.message}
/>
</div>
{/* Status */}
<FormSelect
label="Status"
required
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
error={errors.status?.message}
/>
</div>
)}
</form>

View File

@ -69,7 +69,7 @@ const newRoleSchema = z.object({
"Code must be lowercase and use '_' for separation (e.g. abc_def)"
),
description: z.string().min(1, 'Description is required'),
modules: z.array(z.string().uuid()).optional().nullable(),
modules: z.array(z.uuid()).optional().nullable(),
permissions: z.array(z.object({
resource: z.string(),
action: z.string(),

View File

@ -16,7 +16,7 @@ import { roleService } from '@/services/role-service';
// Validation schema
const newUserSchema = z
.object({
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
email: z.email({ message: 'Please enter a valid email address' }),
password: z.string().min(1, 'Password is required').min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(1, 'Confirm password is required'),
first_name: z.string().min(1, 'First name is required'),

View File

@ -31,10 +31,15 @@ const newModuleSchema = z.object({
.max(50, 'runtime_language must be at most 50 characters'),
framework: z.string().max(50, 'framework must be at most 50 characters').optional().nullable(),
webhookurl: z.string().max(500, "webhookurl must be at most 500 characters").url("Invalid URL format").nullable(),
base_url: z
frontend_base_url: z
.string()
.min(1, 'base_url is required')
.max(255, 'base_url must be at most 255 characters')
.min(1, 'frontend_base_url is required')
.max(255, 'frontend_base_url must be at most 255 characters')
.url('Invalid URL format'),
backend_base_url: z
.string()
.min(1, 'backend_base_url is required')
.max(255, 'backend_base_url must be at most 255 characters')
.url('Invalid URL format'),
health_endpoint: z
.string()
@ -50,8 +55,8 @@ const newModuleSchema = z.object({
max_replicas: z.number().int().min(1, 'max_replicas must be at least 1').max(50, 'max_replicas must be at most 50').optional().nullable(),
last_health_check: z.string().optional().nullable(),
consecutive_failures: z.number().int().optional().nullable(),
registered_by: z.string().uuid().optional().nullable(),
tenant_id: z.string().uuid().optional().nullable(),
registered_by: z.uuid().optional().nullable(),
tenant_id: z.uuid().optional().nullable(),
metadata: z.any().optional().nullable(),
});
@ -113,7 +118,8 @@ export const NewModuleModal = ({
runtime_language: '',
framework: null,
webhookurl: null,
base_url: '',
frontend_base_url: '',
backend_base_url: '',
health_endpoint: '',
endpoints: null,
kafka_topics: null,
@ -155,7 +161,7 @@ export const NewModuleModal = ({
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
const validationErrors = error.response.data.details;
validationErrors.forEach((detail: { path: string; message: string }) => {
if (detail.path === 'name' || detail.path === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'webhookurl' || detail.path === 'base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') {
if (detail.path === 'name' || detail.path === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'webhookurl' || detail.path === 'frontend_base_url' || detail.path === 'backend_base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') {
setError(detail.path as keyof NewModuleFormData, {
type: 'server',
message: detail.message,
@ -370,14 +376,28 @@ export const NewModuleModal = ({
<div className="mb-4">
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">URL Configuration</h3>
<div className="flex flex-col gap-0">
<FormField
label="Base URL"
required
type="url"
placeholder="https://example.com"
error={errors.base_url?.message}
{...register('base_url')}
/>
<div className="flex gap-5">
<div className="flex-1">
<FormField
label="Frontend Base URL"
required
type="url"
placeholder="https://frontend.example.com"
error={errors.frontend_base_url?.message}
{...register('frontend_base_url')}
/>
</div>
<div className="flex-1">
<FormField
label="Backend Base URL"
required
type="url"
placeholder="https://backend.example.com"
error={errors.backend_base_url?.message}
{...register('backend_base_url')}
/>
</div>
</div>
<FormField
label="Health Endpoint"

View File

@ -145,8 +145,12 @@ export const ViewModuleModal = ({
<p className="text-sm font-medium text-[#0e1b2a]">{module.framework || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Base URL</label>
<p className="text-sm font-medium text-[#0e1b2a] font-mono break-all">{module.base_url}</p>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Frontend Base URL</label>
<p className="text-sm font-medium text-[#0e1b2a] font-mono break-all">{module.frontend_base_url || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Backend Base URL</label>
<p className="text-sm font-medium text-[#0e1b2a] font-mono break-all">{module.backend_base_url || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Health Endpoint</label>

View File

@ -34,13 +34,14 @@ const tenantDetailsSchema = z.object({
}).optional().nullable(),
max_users: z.number().int().min(1, 'max_users must be at least 1').optional().nullable(),
max_modules: z.number().int().min(1, 'max_modules must be at least 1').optional().nullable(),
modules: z.array(z.string().uuid()).optional().nullable(),
modules: z.array(z.uuid()).optional().nullable(),
});
// Step 2: Contact Details Schema - user creation + organization address
const contactDetailsSchema = z
.object({
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
email: z
.email({ message: 'Please enter a valid email address' }),
password: z.string().min(1, 'Password is required').min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(1, 'Confirm password is required'),
first_name: z.string().min(1, 'First name is required'),
@ -113,6 +114,8 @@ const CreateTenantWizard = (): ReactElement => {
// Form instances for each step
const tenantDetailsForm = useForm<TenantDetailsForm>({
resolver: zodResolver(tenantDetailsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
name: '',
slug: '',
@ -139,6 +142,8 @@ const CreateTenantWizard = (): ReactElement => {
const contactDetailsForm = useForm<ContactDetailsForm>({
resolver: zodResolver(contactDetailsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
email: '',
password: '',
@ -157,6 +162,8 @@ const CreateTenantWizard = (): ReactElement => {
const settingsForm = useForm<SettingsForm>({
resolver: zodResolver(settingsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
enable_sso: false,
enable_2fa: false,
@ -331,14 +338,18 @@ const CreateTenantWizard = (): ReactElement => {
setLogoError(null);
setFaviconError(null);
if (!logoFileUrl && !logoFile) {
const isLogoMissing = !logoFileUrl && !logoFile;
const isFaviconMissing = !faviconFileUrl && !faviconFile;
if (isLogoMissing) {
setLogoError('Logo is required');
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
}
if (!faviconFileUrl && !faviconFile) {
if (isFaviconMissing) {
setFaviconError('Favicon is required');
}
if (isLogoMissing || isFaviconMissing) {
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
}
@ -379,40 +390,122 @@ const CreateTenantWizard = (): ReactElement => {
showToast.success(message);
navigate('/tenants');
} catch (err: any) {
// Handle validation errors from API - same as NewTenantModal
// Clear previous errors
tenantDetailsForm.clearErrors();
contactDetailsForm.clearErrors();
settingsForm.clearErrors();
// Handle validation errors from API
if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
const validationErrors = err.response.data.details;
let hasTenantErrors = false;
let hasContactErrors = false;
let hasSettingsErrors = false;
validationErrors.forEach((detail: { path: string; message: string }) => {
// Handle tenant details errors
if (
detail.path === 'name' ||
detail.path === 'slug' ||
detail.path === 'status' ||
detail.path === 'subscription_tier' ||
detail.path === 'max_users' ||
detail.path === 'max_modules' ||
detail.path === 'module_ids'
const path = detail.path;
// Handle nested paths first
if (path.startsWith('settings.contact.')) {
// Contact details errors from nested path
hasContactErrors = true;
const fieldName = path.replace('settings.contact.', '');
if (fieldName === 'confirmPassword') {
// Skip confirmPassword as it's not in the form schema
return;
}
contactDetailsForm.setError(fieldName as keyof ContactDetailsForm, {
type: 'server',
message: detail.message,
});
} else if (path.startsWith('settings.branding.')) {
// Settings/branding errors from nested path
hasSettingsErrors = true;
const fieldName = path.replace('settings.branding.', '');
// Map file_path fields to form fields
if (fieldName === 'logo_file_path') {
setLogoError(detail.message);
} else if (fieldName === 'favicon_file_path') {
setFaviconError(detail.message);
} else {
settingsForm.setError(fieldName as keyof SettingsForm, {
type: 'server',
message: detail.message,
});
}
} else if (path.startsWith('settings.')) {
// Other settings errors
hasSettingsErrors = true;
const fieldName = path.replace('settings.', '');
settingsForm.setError(fieldName as keyof SettingsForm, {
type: 'server',
message: detail.message,
});
}
// Handle tenant details errors (step 1)
else if (
path === 'name' ||
path === 'slug' ||
path === 'domain' ||
path === 'status' ||
path === 'subscription_tier' ||
path === 'max_users' ||
path === 'max_modules' ||
path === 'module_ids'
) {
const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path;
hasTenantErrors = true;
const fieldPath = path === 'module_ids' ? 'modules' : path;
tenantDetailsForm.setError(fieldPath as keyof TenantDetailsForm, {
type: 'server',
message: detail.message,
});
}
// Handle contact details errors
// Handle contact details errors (step 2) - direct paths
else if (
detail.path === 'email' ||
detail.path === 'password' ||
detail.path === 'first_name' ||
detail.path === 'last_name'
path === 'email' ||
path === 'password' ||
path === 'first_name' ||
path === 'last_name' ||
path === 'contact_phone' ||
path === 'address_line1' ||
path === 'address_line2' ||
path === 'city' ||
path === 'state' ||
path === 'postal_code' ||
path === 'country'
) {
contactDetailsForm.setError(detail.path as keyof ContactDetailsForm, {
hasContactErrors = true;
contactDetailsForm.setError(path as keyof ContactDetailsForm, {
type: 'server',
message: detail.message,
});
}
// Handle settings errors (step 3) - direct paths
else if (
path === 'enable_sso' ||
path === 'enable_2fa' ||
path === 'primary_color' ||
path === 'secondary_color' ||
path === 'accent_color'
) {
hasSettingsErrors = true;
settingsForm.setError(path as keyof SettingsForm, {
type: 'server',
message: detail.message,
});
}
});
// Navigate to the step with errors
if (hasTenantErrors) {
setCurrentStep(1);
} else if (hasContactErrors) {
setCurrentStep(2);
} else if (hasSettingsErrors) {
setCurrentStep(3);
}
} else {
// Handle general errors
const errorObj = err?.response?.data?.error;
const errorMessage =
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
@ -425,6 +518,7 @@ const CreateTenantWizard = (): ReactElement => {
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.',
});
showToast.error(typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.');
setCurrentStep(1); // Navigate to first step for general errors
}
} finally {
setIsSubmitting(false);
@ -778,6 +872,12 @@ const CreateTenantWizard = (): ReactElement => {
Set resource limits and security preferences for this tenant.
</p>
</div>
{/* General Error Display */}
{settingsForm.formState.errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<p className="text-sm text-[#ef4444]">{settingsForm.formState.errors.root.message}</p>
</div>
)}
{/* Branding Section */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4">

View File

@ -34,12 +34,12 @@ const tenantDetailsSchema = z.object({
}).optional().nullable(),
max_users: z.number().int().min(1, 'max_users must be at least 1').optional().nullable(),
max_modules: z.number().int().min(1, 'max_modules must be at least 1').optional().nullable(),
modules: z.array(z.string().uuid()).optional().nullable(),
modules: z.array(z.uuid()).optional().nullable(),
});
// Step 2: Contact Details Schema - NO password fields
const contactDetailsSchema = z.object({
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
email: z.email({ message: 'Please enter a valid email address' }),
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required'),
contact_phone: z
@ -122,6 +122,8 @@ const EditTenant = (): ReactElement => {
// Form instances for each step
const tenantDetailsForm = useForm<TenantDetailsForm>({
resolver: zodResolver(tenantDetailsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
name: '',
slug: '',
@ -136,6 +138,8 @@ const EditTenant = (): ReactElement => {
const contactDetailsForm = useForm<ContactDetailsForm>({
resolver: zodResolver(contactDetailsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
email: '',
first_name: '',
@ -152,6 +156,8 @@ const EditTenant = (): ReactElement => {
const settingsForm = useForm<SettingsForm>({
resolver: zodResolver(settingsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: {
enable_sso: false,
enable_2fa: false,
@ -433,14 +439,18 @@ const EditTenant = (): ReactElement => {
setLogoError(null);
setFaviconError(null);
if (!logoFilePath) {
const isLogoMissing = !logoFilePath;
const isFaviconMissing = !faviconFilePath;
if (isLogoMissing) {
setLogoError('Logo is required');
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
}
if (!faviconFilePath) {
if (isFaviconMissing) {
setFaviconError('Favicon is required');
}
if (isLogoMissing || isFaviconMissing) {
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
}
@ -476,47 +486,130 @@ const EditTenant = (): ReactElement => {
showToast.success(message);
navigate('/tenants');
} catch (err: any) {
// Clear previous errors
tenantDetailsForm.clearErrors();
contactDetailsForm.clearErrors();
settingsForm.clearErrors();
// Handle validation errors from API
if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
const validationErrors = err.response.data.details;
let hasTenantErrors = false;
let hasContactErrors = false;
let hasSettingsErrors = false;
validationErrors.forEach((detail: { path: string; message: string }) => {
if (
detail.path === 'name' ||
detail.path === 'slug' ||
detail.path === 'status' ||
detail.path === 'subscription_tier' ||
detail.path === 'max_users' ||
detail.path === 'max_modules' ||
detail.path === 'module_ids'
const path = detail.path;
// Handle nested paths first
if (path.startsWith('settings.contact.')) {
// Contact details errors from nested path
hasContactErrors = true;
const fieldName = path.replace('settings.contact.', '');
contactDetailsForm.setError(fieldName as keyof ContactDetailsForm, {
type: 'server',
message: detail.message,
});
} else if (path.startsWith('settings.branding.')) {
// Settings/branding errors from nested path
hasSettingsErrors = true;
const fieldName = path.replace('settings.branding.', '');
// Map file_path fields to form fields
if (fieldName === 'logo_file_path') {
setLogoError(detail.message);
} else if (fieldName === 'favicon_file_path') {
setFaviconError(detail.message);
} else {
settingsForm.setError(fieldName as keyof SettingsForm, {
type: 'server',
message: detail.message,
});
}
} else if (path.startsWith('settings.')) {
// Other settings errors
hasSettingsErrors = true;
const fieldName = path.replace('settings.', '');
settingsForm.setError(fieldName as keyof SettingsForm, {
type: 'server',
message: detail.message,
});
}
// Handle tenant details errors (step 1)
else if (
path === 'name' ||
path === 'slug' ||
path === 'domain' ||
path === 'status' ||
path === 'subscription_tier' ||
path === 'max_users' ||
path === 'max_modules' ||
path === 'module_ids'
) {
const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path;
hasTenantErrors = true;
const fieldPath = path === 'module_ids' ? 'modules' : path;
tenantDetailsForm.setError(fieldPath as keyof TenantDetailsForm, {
type: 'server',
message: detail.message,
});
} else if (
detail.path === 'email' ||
detail.path === 'first_name' ||
detail.path === 'last_name' ||
detail.path === 'contact_phone' ||
detail.path === 'address_line1' ||
detail.path === 'city' ||
detail.path === 'state' ||
detail.path === 'postal_code' ||
detail.path === 'country'
}
// Handle contact details errors (step 2) - direct paths
else if (
path === 'email' ||
path === 'first_name' ||
path === 'last_name' ||
path === 'contact_phone' ||
path === 'address_line1' ||
path === 'address_line2' ||
path === 'city' ||
path === 'state' ||
path === 'postal_code' ||
path === 'country'
) {
contactDetailsForm.setError(detail.path as keyof ContactDetailsForm, {
hasContactErrors = true;
contactDetailsForm.setError(path as keyof ContactDetailsForm, {
type: 'server',
message: detail.message,
});
}
// Handle settings errors (step 3) - direct paths
else if (
path === 'enable_sso' ||
path === 'enable_2fa' ||
path === 'primary_color' ||
path === 'secondary_color' ||
path === 'accent_color'
) {
hasSettingsErrors = true;
settingsForm.setError(path as keyof SettingsForm, {
type: 'server',
message: detail.message,
});
}
});
// Navigate to the step with errors
if (hasTenantErrors) {
setCurrentStep(1);
} else if (hasContactErrors) {
setCurrentStep(2);
} else if (hasSettingsErrors) {
setCurrentStep(3);
}
} else {
// Handle general errors
const errorObj = err?.response?.data?.error;
const errorMessage =
err?.response?.data?.error?.message ||
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) ||
(typeof errorObj === 'string' ? errorObj : null) ||
err?.response?.data?.message ||
err?.message ||
'Failed to update tenant. Please try again.';
showToast.error(errorMessage);
tenantDetailsForm.setError('root', {
type: 'server',
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to update tenant. Please try again.',
});
showToast.error(typeof errorMessage === 'string' ? errorMessage : 'Failed to update tenant. Please try again.');
setCurrentStep(1); // Navigate to first step for general errors
}
} finally {
setIsSubmitting(false);
@ -878,6 +971,12 @@ const EditTenant = (): ReactElement => {
<h2 className="text-lg font-semibold text-[#0f1724]">Configuration & Limits</h2>
<p className="text-sm text-[#6b7280] mt-1">Set resource limits and security preferences for this tenant.</p>
</div>
{/* General Error Display */}
{settingsForm.formState.errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<p className="text-sm text-[#ef4444]">{settingsForm.formState.errors.root.message}</p>
</div>
)}
{/* Branding Section - Same as CreateTenantWizard */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4">

View File

@ -245,13 +245,18 @@ const Settings = (): ReactElement => {
setLogoError(null);
setFaviconError(null);
if (!logoFilePath) {
const isLogoMissing = !logoFilePath;
const isFaviconMissing = !faviconFilePath;
if (isLogoMissing) {
setLogoError('Logo is required');
return;
}
if (!faviconFilePath) {
if (isFaviconMissing) {
setFaviconError('Favicon is required');
}
if (isLogoMissing || isFaviconMissing) {
return;
}

View File

@ -8,7 +8,8 @@ export interface Module {
runtime_language: string | null;
framework: string | null;
webhookurl: string | null;
base_url: string;
frontend_base_url: string;
backend_base_url: string;
health_endpoint: string;
endpoints: string[] | null;
kafka_topics: string[] | null;