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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>qassure-frontend</title> <title>qassure-frontend</title>
</head> </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(); 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 // Fetch theme if tenant admin
if (!isSuperAdmin) { if (!isSuperAdmin) {
useTenantTheme(); useTenantTheme();
@ -212,8 +241,20 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
> >
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} /> <Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
</div> </div>
<div className="text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]"> <div className="flex flex-col">
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'} <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>
</div> </div>
<button <button
@ -238,6 +279,18 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<span className="text-[13px] font-medium text-[#0f1724]">Support Center</span> <span className="text-[13px] font-medium text-[#0f1724]">Support Center</span>
</button> </button>
</div> </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> </aside>
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
@ -266,8 +319,20 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
> >
<Shield className="w-6 h-6 text-white" strokeWidth={1.67} /> <Shield className="w-6 h-6 text-white" strokeWidth={1.67} />
</div> </div>
<div className="text-base md:text-base lg:text-base xl:text-[18px] font-bold text-[#0f1724] tracking-[-0.36px]"> <div className="flex flex-col">
{(!isSuperAdmin && logoUrl) ? '' : 'QAssure'} <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> </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> <span className="text-xs md:text-xs lg:text-[13px] font-medium text-[#0f1724]">Support Center</span>
</button> </button>
</div> </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> </aside>
</> </>
); );

View File

@ -65,7 +65,7 @@ export const DataTable = <T,>({
return ( return (
<th <th
key={column.key} 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} {column.label}
</th> </th>
@ -75,7 +75,7 @@ export const DataTable = <T,>({
</thead> </thead>
<tbody> <tbody>
<tr> <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} {emptyMessage}
</td> </td>
</tr> </tr>
@ -106,14 +106,14 @@ export const DataTable = <T,>({
: column.align === 'center' : column.align === 'center'
? 'text-center' ? 'text-center'
: 'text-left'; : 'text-left';
return ( return (
<th <th
key={column.key} 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} {column.label}
</th> </th>
); );
})} })}
</tr> </tr>
</thead> </thead>
@ -131,7 +131,7 @@ export const DataTable = <T,>({
? 'text-center' ? 'text-center'
: 'text-left'; : 'text-left';
return ( 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])} {column.render ? column.render(item) : String((item as any)[column.key])}
</td> </td>
); );

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ import { roleService } from '@/services/role-service';
// Validation schema // Validation schema
const newUserSchema = z const newUserSchema = z
.object({ .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'), 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'), confirmPassword: z.string().min(1, 'Confirm password is required'),
first_name: z.string().min(1, 'First name 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'), .max(50, 'runtime_language must be at most 50 characters'),
framework: z.string().max(50, 'framework must be at most 50 characters').optional().nullable(), 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(), 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() .string()
.min(1, 'base_url is required') .min(1, 'frontend_base_url is required')
.max(255, 'base_url must be at most 255 characters') .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'), .url('Invalid URL format'),
health_endpoint: z health_endpoint: z
.string() .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(), 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(), last_health_check: z.string().optional().nullable(),
consecutive_failures: z.number().int().optional().nullable(), consecutive_failures: z.number().int().optional().nullable(),
registered_by: z.string().uuid().optional().nullable(), registered_by: z.uuid().optional().nullable(),
tenant_id: z.string().uuid().optional().nullable(), tenant_id: z.uuid().optional().nullable(),
metadata: z.any().optional().nullable(), metadata: z.any().optional().nullable(),
}); });
@ -113,7 +118,8 @@ export const NewModuleModal = ({
runtime_language: '', runtime_language: '',
framework: null, framework: null,
webhookurl: null, webhookurl: null,
base_url: '', frontend_base_url: '',
backend_base_url: '',
health_endpoint: '', health_endpoint: '',
endpoints: null, endpoints: null,
kafka_topics: null, kafka_topics: null,
@ -155,7 +161,7 @@ export const NewModuleModal = ({
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) { if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
const validationErrors = error.response.data.details; const validationErrors = error.response.data.details;
validationErrors.forEach((detail: { path: string; message: string }) => { 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, { setError(detail.path as keyof NewModuleFormData, {
type: 'server', type: 'server',
message: detail.message, message: detail.message,
@ -370,14 +376,28 @@ export const NewModuleModal = ({
<div className="mb-4"> <div className="mb-4">
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">URL Configuration</h3> <h3 className="text-sm font-semibold text-[#0f1724] mb-3">URL Configuration</h3>
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<FormField <div className="flex gap-5">
label="Base URL" <div className="flex-1">
required <FormField
type="url" label="Frontend Base URL"
placeholder="https://example.com" required
error={errors.base_url?.message} type="url"
{...register('base_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 <FormField
label="Health Endpoint" 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> <p className="text-sm font-medium text-[#0e1b2a]">{module.framework || 'N/A'}</p>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Base URL</label> <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.base_url}</p> <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>
<div> <div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Health Endpoint</label> <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(), }).optional().nullable(),
max_users: z.number().int().min(1, 'max_users must be at least 1').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(), 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 // Step 2: Contact Details Schema - user creation + organization address
const contactDetailsSchema = z const contactDetailsSchema = z
.object({ .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'), 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'), confirmPassword: z.string().min(1, 'Confirm password is required'),
first_name: z.string().min(1, 'First name is required'), first_name: z.string().min(1, 'First name is required'),
@ -113,6 +114,8 @@ const CreateTenantWizard = (): ReactElement => {
// Form instances for each step // Form instances for each step
const tenantDetailsForm = useForm<TenantDetailsForm>({ const tenantDetailsForm = useForm<TenantDetailsForm>({
resolver: zodResolver(tenantDetailsSchema), resolver: zodResolver(tenantDetailsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: { defaultValues: {
name: '', name: '',
slug: '', slug: '',
@ -139,6 +142,8 @@ const CreateTenantWizard = (): ReactElement => {
const contactDetailsForm = useForm<ContactDetailsForm>({ const contactDetailsForm = useForm<ContactDetailsForm>({
resolver: zodResolver(contactDetailsSchema), resolver: zodResolver(contactDetailsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: { defaultValues: {
email: '', email: '',
password: '', password: '',
@ -157,6 +162,8 @@ const CreateTenantWizard = (): ReactElement => {
const settingsForm = useForm<SettingsForm>({ const settingsForm = useForm<SettingsForm>({
resolver: zodResolver(settingsSchema), resolver: zodResolver(settingsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: { defaultValues: {
enable_sso: false, enable_sso: false,
enable_2fa: false, enable_2fa: false,
@ -331,14 +338,18 @@ const CreateTenantWizard = (): ReactElement => {
setLogoError(null); setLogoError(null);
setFaviconError(null); setFaviconError(null);
if (!logoFileUrl && !logoFile) { const isLogoMissing = !logoFileUrl && !logoFile;
const isFaviconMissing = !faviconFileUrl && !faviconFile;
if (isLogoMissing) {
setLogoError('Logo is required'); setLogoError('Logo is required');
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
} }
if (!faviconFileUrl && !faviconFile) { if (isFaviconMissing) {
setFaviconError('Favicon is required'); setFaviconError('Favicon is required');
}
if (isLogoMissing || isFaviconMissing) {
setCurrentStep(3); // Go to settings step where logo/favicon are setCurrentStep(3); // Go to settings step where logo/favicon are
return; return;
} }
@ -379,40 +390,122 @@ const CreateTenantWizard = (): ReactElement => {
showToast.success(message); showToast.success(message);
navigate('/tenants'); navigate('/tenants');
} catch (err: any) { } 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)) { if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
const validationErrors = 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 }) => { validationErrors.forEach((detail: { path: string; message: string }) => {
// Handle tenant details errors const path = detail.path;
if (
detail.path === 'name' || // Handle nested paths first
detail.path === 'slug' || if (path.startsWith('settings.contact.')) {
detail.path === 'status' || // Contact details errors from nested path
detail.path === 'subscription_tier' || hasContactErrors = true;
detail.path === 'max_users' || const fieldName = path.replace('settings.contact.', '');
detail.path === 'max_modules' || if (fieldName === 'confirmPassword') {
detail.path === 'module_ids' // 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, { tenantDetailsForm.setError(fieldPath as keyof TenantDetailsForm, {
type: 'server', type: 'server',
message: detail.message, message: detail.message,
}); });
} }
// Handle contact details errors // Handle contact details errors (step 2) - direct paths
else if ( else if (
detail.path === 'email' || path === 'email' ||
detail.path === 'password' || path === 'password' ||
detail.path === 'first_name' || path === 'first_name' ||
detail.path === 'last_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', type: 'server',
message: detail.message, message: detail.message,
}); });
} }
}); });
// Navigate to the step with errors
if (hasTenantErrors) {
setCurrentStep(1);
} else if (hasContactErrors) {
setCurrentStep(2);
} else if (hasSettingsErrors) {
setCurrentStep(3);
}
} else { } else {
// Handle general errors
const errorObj = err?.response?.data?.error; const errorObj = err?.response?.data?.error;
const errorMessage = const errorMessage =
(typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || (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.', 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.'); showToast.error(typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.');
setCurrentStep(1); // Navigate to first step for general errors
} }
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@ -778,6 +872,12 @@ const CreateTenantWizard = (): ReactElement => {
Set resource limits and security preferences for this tenant. Set resource limits and security preferences for this tenant.
</p> </p>
</div> </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 */} {/* 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"> <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(), }).optional().nullable(),
max_users: z.number().int().min(1, 'max_users must be at least 1').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(), 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 // Step 2: Contact Details Schema - NO password fields
const contactDetailsSchema = z.object({ 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'), first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required'), last_name: z.string().min(1, 'Last name is required'),
contact_phone: z contact_phone: z
@ -122,6 +122,8 @@ const EditTenant = (): ReactElement => {
// Form instances for each step // Form instances for each step
const tenantDetailsForm = useForm<TenantDetailsForm>({ const tenantDetailsForm = useForm<TenantDetailsForm>({
resolver: zodResolver(tenantDetailsSchema), resolver: zodResolver(tenantDetailsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: { defaultValues: {
name: '', name: '',
slug: '', slug: '',
@ -136,6 +138,8 @@ const EditTenant = (): ReactElement => {
const contactDetailsForm = useForm<ContactDetailsForm>({ const contactDetailsForm = useForm<ContactDetailsForm>({
resolver: zodResolver(contactDetailsSchema), resolver: zodResolver(contactDetailsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: { defaultValues: {
email: '', email: '',
first_name: '', first_name: '',
@ -152,6 +156,8 @@ const EditTenant = (): ReactElement => {
const settingsForm = useForm<SettingsForm>({ const settingsForm = useForm<SettingsForm>({
resolver: zodResolver(settingsSchema), resolver: zodResolver(settingsSchema),
mode: 'onChange',
reValidateMode: 'onChange',
defaultValues: { defaultValues: {
enable_sso: false, enable_sso: false,
enable_2fa: false, enable_2fa: false,
@ -433,14 +439,18 @@ const EditTenant = (): ReactElement => {
setLogoError(null); setLogoError(null);
setFaviconError(null); setFaviconError(null);
if (!logoFilePath) { const isLogoMissing = !logoFilePath;
const isFaviconMissing = !faviconFilePath;
if (isLogoMissing) {
setLogoError('Logo is required'); setLogoError('Logo is required');
setCurrentStep(3); // Go to settings step where logo/favicon are
return;
} }
if (!faviconFilePath) { if (isFaviconMissing) {
setFaviconError('Favicon is required'); setFaviconError('Favicon is required');
}
if (isLogoMissing || isFaviconMissing) {
setCurrentStep(3); // Go to settings step where logo/favicon are setCurrentStep(3); // Go to settings step where logo/favicon are
return; return;
} }
@ -476,47 +486,130 @@ const EditTenant = (): ReactElement => {
showToast.success(message); showToast.success(message);
navigate('/tenants'); navigate('/tenants');
} catch (err: any) { } 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)) { if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
const validationErrors = 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 }) => { validationErrors.forEach((detail: { path: string; message: string }) => {
if ( const path = detail.path;
detail.path === 'name' ||
detail.path === 'slug' || // Handle nested paths first
detail.path === 'status' || if (path.startsWith('settings.contact.')) {
detail.path === 'subscription_tier' || // Contact details errors from nested path
detail.path === 'max_users' || hasContactErrors = true;
detail.path === 'max_modules' || const fieldName = path.replace('settings.contact.', '');
detail.path === 'module_ids' 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, { tenantDetailsForm.setError(fieldPath as keyof TenantDetailsForm, {
type: 'server', type: 'server',
message: detail.message, message: detail.message,
}); });
} else if ( }
detail.path === 'email' || // Handle contact details errors (step 2) - direct paths
detail.path === 'first_name' || else if (
detail.path === 'last_name' || path === 'email' ||
detail.path === 'contact_phone' || path === 'first_name' ||
detail.path === 'address_line1' || path === 'last_name' ||
detail.path === 'city' || path === 'contact_phone' ||
detail.path === 'state' || path === 'address_line1' ||
detail.path === 'postal_code' || path === 'address_line2' ||
detail.path === 'country' 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', type: 'server',
message: detail.message, message: detail.message,
}); });
} }
}); });
// Navigate to the step with errors
if (hasTenantErrors) {
setCurrentStep(1);
} else if (hasContactErrors) {
setCurrentStep(2);
} else if (hasSettingsErrors) {
setCurrentStep(3);
}
} else { } else {
// Handle general errors
const errorObj = err?.response?.data?.error;
const errorMessage = 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?.response?.data?.message ||
err?.message || err?.message ||
'Failed to update tenant. Please try again.'; '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 { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@ -878,6 +971,12 @@ const EditTenant = (): ReactElement => {
<h2 className="text-lg font-semibold text-[#0f1724]">Configuration & Limits</h2> <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> <p className="text-sm text-[#6b7280] mt-1">Set resource limits and security preferences for this tenant.</p>
</div> </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 */} {/* 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"> <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); setLogoError(null);
setFaviconError(null); setFaviconError(null);
if (!logoFilePath) { const isLogoMissing = !logoFilePath;
const isFaviconMissing = !faviconFilePath;
if (isLogoMissing) {
setLogoError('Logo is required'); setLogoError('Logo is required');
return;
} }
if (!faviconFilePath) { if (isFaviconMissing) {
setFaviconError('Favicon is required'); setFaviconError('Favicon is required');
}
if (isLogoMissing || isFaviconMissing) {
return; return;
} }

View File

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