diff --git a/.env b/.env index 2ae02c5..2e1c1b7 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ -VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1 +VITE_API_BASE_URL=http://localhost:3000/api/v1 +# VITE_API_BASE_URL=https://qasure.tech4bizsolutions.com/api/v1 diff --git a/API_ENDPOINTS.txt b/API_ENDPOINTS.txt new file mode 100644 index 0000000..982050f --- /dev/null +++ b/API_ENDPOINTS.txt @@ -0,0 +1,663 @@ +================================================================================ +QAssure Frontend - API Endpoints Documentation +================================================================================ +Base URL: {{baseUrl}}/api/v1 (configured via VITE_API_BASE_URL environment variable) +All requests include Authorization header: Bearer {accessToken} (automatically added) + +================================================================================ +1. AUTHENTICATION APIs +================================================================================ + +1.1 Login +Method: POST +Endpoint: /auth/login +Headers: Content-Type: application/json +Request Body: +{ + "email": "string", + "password": "string" +} +Response: { + "success": true, + "data": { + "user": { + "id": "string", + "email": "string", + "first_name": "string", + "last_name": "string" + }, + "tenant_id": "string", + "roles": ["string"], + "access_token": "string", + "refresh_token": "string", + "token_type": "string", + "expires_in": number, + "expires_at": "string" + } +} + +1.2 Logout +Method: POST +Endpoint: /auth/logout +Headers: + - Content-Type: application/json + - Authorization: Bearer {accessToken} +Request Body: {} +Response: { + "success": true, + "message": "string" (optional) +} + +================================================================================ +2. TENANTS APIs +================================================================================ + +2.1 Get All Tenants +Method: GET +Endpoint: /tenants +Query Parameters: + - page: number (default: 1) + - limit: number (default: 20) + - status: string (optional) - Filter by status: "active", "suspended", "deleted" + - orderBy[]: string[] (optional) - Array format: ["field", "asc"] or ["field", "desc"] + Example: orderBy[]=name&orderBy[]=asc +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": [ + { + "id": "string", + "name": "string", + "slug": "string", + "status": "active" | "suspended" | "deleted", + "settings": object | null, + "subscription_tier": "string" | null, + "max_users": number | null, + "max_modules": number | null, + "created_at": "string", + "updated_at": "string" + } + ], + "pagination": { + "page": number, + "limit": number, + "total": number, + "totalPages": number, + "hasMore": boolean + } +} + +2.2 Get Tenant by ID +Method: GET +Endpoint: /tenants/{id} +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": { + "id": "string", + "name": "string", + "slug": "string", + "status": "active" | "suspended" | "deleted", + "settings": object | null, + "subscription_tier": "string" | null, + "max_users": number | null, + "max_modules": number | null, + "created_at": "string", + "updated_at": "string" + } +} + +2.3 Create Tenant +Method: POST +Endpoint: /tenants +Headers: + - Content-Type: application/json + - Authorization: Bearer {accessToken} +Request Body: +{ + "name": "string", // Required, min 3, max 100 characters + "slug": "string", // Required, min 3, max 100 characters, regex: ^[a-z0-9-]+$ + "status": "active" | "suspended" | "deleted", // Required + "settings": object | null, // Optional + "subscription_tier": "string" | null, // Optional, max 50 characters + "max_users": number | null, // Optional, min 1 + "max_modules": number | null // Optional, min 1 +} +Response: { + "success": true, + "data": { + "id": "string", + "name": "string", + "slug": "string", + "status": "active" | "suspended" | "deleted", + "settings": object | null, + "subscription_tier": "string" | null, + "max_users": number | null, + "max_modules": number | null, + "created_at": "string", + "updated_at": "string" + } +} + +2.4 Update Tenant +Method: PUT +Endpoint: /tenants/{id} +Headers: + - Content-Type: application/json + - Authorization: Bearer {accessToken} +Request Body: +{ + "name": "string", // Required, min 3, max 100 characters + "slug": "string", // Required, min 3, max 100 characters, regex: ^[a-z0-9-]+$ + "status": "active" | "suspended" | "deleted", // Required + "settings": object | null, // Optional + "subscription_tier": "string" | null, // Optional, max 50 characters + "max_users": number | null, // Optional, min 1 + "max_modules": number | null // Optional, min 1 +} +Response: { + "success": true, + "data": { + "id": "string", + "name": "string", + "slug": "string", + "status": "active" | "suspended" | "deleted", + "settings": object | null, + "subscription_tier": "string" | null, + "max_users": number | null, + "max_modules": number | null, + "created_at": "string", + "updated_at": "string" + } +} + +2.5 Delete Tenant +Method: DELETE +Endpoint: /tenants/{id} +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "message": "string" (optional) +} + +================================================================================ +3. USERS APIs +================================================================================ + +3.1 Get All Users +Method: GET +Endpoint: /users +Query Parameters: + - page: number (default: 1) + - limit: number (default: 20) + - status: string (optional) - Filter by status: "active", "suspended", "deleted" + - orderBy[]: string[] (optional) - Array format: ["field", "asc"] or ["field", "desc"] + Example: orderBy[]=email&orderBy[]=asc +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": [ + { + "id": "string", + "email": "string", + "first_name": "string", + "last_name": "string", + "status": "active" | "suspended" | "deleted", + "auth_provider": "string", + "tenant_id": "string" | null, + "role_id": "string" | null, + "created_at": "string", + "updated_at": "string" + } + ], + "pagination": { + "page": number, + "limit": number, + "total": number, + "totalPages": number, + "hasMore": boolean + } +} + +3.2 Get User by ID +Method: GET +Endpoint: /users/{id} +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": { + "id": "string", + "email": "string", + "first_name": "string", + "last_name": "string", + "status": "active" | "suspended" | "deleted", + "auth_provider": "string", + "tenant_id": "string" | null, + "role_id": "string" | null, + "created_at": "string", + "updated_at": "string" + } +} + +3.3 Create User +Method: POST +Endpoint: /users +Headers: + - Content-Type: application/json + - Authorization: Bearer {accessToken} +Request Body: +{ + "email": "string", // Required, valid email format + "password": "string", // Required, min 6 characters + "first_name": "string", // Required + "last_name": "string", // Required + "status": "active" | "suspended" | "deleted", // Required + "auth_provider": "local", // Required + "tenant_id": "string", // Required + "role_id": "string" // Required +} +Response: { + "success": true, + "data": { + "id": "string", + "email": "string", + "first_name": "string", + "last_name": "string", + "status": "active" | "suspended" | "deleted", + "auth_provider": "string", + "tenant_id": "string" | null, + "role_id": "string" | null, + "created_at": "string", + "updated_at": "string" + } +} + +3.4 Update User +Method: PUT +Endpoint: /users/{id} +Headers: + - Content-Type: application/json + - Authorization: Bearer {accessToken} +Request Body: +{ + "email": "string", // Required, valid email format + "first_name": "string", // Required + "last_name": "string", // Required + "status": "active" | "suspended" | "deleted", // Required + "auth_provider": "string", // Optional + "tenant_id": "string", // Required + "role_id": "string" // Required +} +Response: { + "success": true, + "data": { + "id": "string", + "email": "string", + "first_name": "string", + "last_name": "string", + "status": "active" | "suspended" | "deleted", + "auth_provider": "string", + "tenant_id": "string" | null, + "role_id": "string" | null, + "created_at": "string", + "updated_at": "string" + } +} + +3.5 Delete User +Method: DELETE +Endpoint: /users/{id} +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "message": "string" (optional) +} + +================================================================================ +4. ROLES APIs +================================================================================ + +4.1 Get All Roles +Method: GET +Endpoint: /roles +Query Parameters: + - page: number (default: 1) + - limit: number (default: 20) + - scope: string (optional) - Filter by scope: "platform", "tenant", "module" + - orderBy[]: string[] (optional) - Array format: ["field", "asc"] or ["field", "desc"] + Example: orderBy[]=name&orderBy[]=asc +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": [ + { + "id": "string", + "name": "string", + "code": "string", + "description": "string" | null, + "scope": "platform" | "tenant" | "module", + "created_at": "string", + "updated_at": "string" + } + ], + "pagination": { + "page": number, + "limit": number, + "total": number, + "totalPages": number, + "hasMore": boolean + } +} + +4.2 Get Role by ID +Method: GET +Endpoint: /roles/{id} +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": { + "id": "string", + "name": "string", + "code": "string", + "description": "string" | null, + "scope": "platform" | "tenant" | "module", + "created_at": "string", + "updated_at": "string" + } +} + +4.3 Create Role +Method: POST +Endpoint: /roles +Headers: + - Content-Type: application/json + - Authorization: Bearer {accessToken} +Request Body: +{ + "name": "string", // Required + "code": "super_admin" | "tenant_admin" | "quality_manager" | "developer" | "viewer", // Required, enum + "description": "string", // Required + "scope": "platform" | "tenant" | "module" // Required, enum +} +Response: { + "success": true, + "data": { + "id": "string", + "name": "string", + "code": "string", + "description": "string" | null, + "scope": "platform" | "tenant" | "module", + "created_at": "string", + "updated_at": "string" + } +} + +4.4 Update Role +Method: PUT +Endpoint: /roles/{id} +Headers: + - Content-Type: application/json + - Authorization: Bearer {accessToken} +Request Body: +{ + "name": "string", // Required + "code": "super_admin" | "tenant_admin" | "quality_manager" | "developer" | "viewer", // Required, enum + "description": "string", // Required + "scope": "platform" | "tenant" | "module" // Required, enum +} +Response: { + "success": true, + "data": { + "id": "string", + "name": "string", + "code": "string", + "description": "string" | null, + "scope": "platform" | "tenant" | "module", + "created_at": "string", + "updated_at": "string" + } +} + +4.5 Delete Role +Method: DELETE +Endpoint: /roles/{id} +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "message": "string" (optional) +} + +================================================================================ +5. MODULES APIs +================================================================================ + +5.1 Get All Modules +Method: GET +Endpoint: /modules +Query Parameters: + - page: number (default: 1) + - limit: number (default: 20) + - status: string (optional) - Filter by status: "running", "stopped", "failed", etc. + - orderBy[]: string[] (optional) - Array format: ["field", "asc"] or ["field", "desc"] + Example: orderBy[]=name&orderBy[]=asc +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": [ + { + "id": "string", + "module_id": "string", + "name": "string", + "description": "string", + "version": "string", + "status": "string", + "runtime_language": "string" | null, + "framework": "string" | null, + "base_url": "string", + "health_endpoint": "string", + "endpoints": ["string"] | null, + "kafka_topics": ["string"] | null, + "cpu_request": "string", + "cpu_limit": "string", + "memory_request": "string", + "memory_limit": "string", + "min_replicas": number, + "max_replicas": number, + "last_health_check": "string" | null, + "health_status": "string" | null, + "consecutive_failures": number | null, + "registered_by": "string", + "tenant_id": "string", + "metadata": object | null, + "created_at": "string", + "updated_at": "string", + "registered_by_email": "string" + } + ], + "pagination": { + "page": number, + "limit": number, + "total": number, + "totalPages": number, + "hasMore": boolean + } +} + +5.2 Get Module by ID +Method: GET +Endpoint: /modules/{id} +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": { + "id": "string", + "module_id": "string", + "name": "string", + "description": "string", + "version": "string", + "status": "string", + "runtime_language": "string" | null, + "framework": "string" | null, + "base_url": "string", + "health_endpoint": "string", + "endpoints": ["string"] | null, + "kafka_topics": ["string"] | null, + "cpu_request": "string", + "cpu_limit": "string", + "memory_request": "string", + "memory_limit": "string", + "min_replicas": number, + "max_replicas": number, + "last_health_check": "string" | null, + "health_status": "string" | null, + "consecutive_failures": number | null, + "registered_by": "string", + "tenant_id": "string", + "metadata": object | null, + "created_at": "string", + "updated_at": "string", + "registered_by_email": "string" + } +} + +================================================================================ +6. AUDIT LOGS APIs +================================================================================ + +6.1 Get All Audit Logs +Method: GET +Endpoint: /audit-logs +Query Parameters: + - page: number (default: 1) + - limit: number (default: 20) + - method: string (optional) - Filter by HTTP method: "GET", "POST", "PUT", "DELETE", "PATCH" + - orderBy[]: string[] (optional) - Array format: ["field", "asc"] or ["field", "desc"] + Example: orderBy[]=created_at&orderBy[]=desc +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": [ + { + "id": "string", + "tenant_id": "string" | null, + "user_id": "string" | null, + "action": "string", + "resource_type": "string", + "resource_id": "string" | null, + "request_method": "string" | null, + "request_path": "string" | null, + "request_body": object | null, + "response_status": number | null, + "response_body": object | null, + "ip_address": "string" | null, + "user_agent": "string" | null, + "correlation_id": "string" | null, + "changes": object | null, + "metadata": object | null, + "created_at": "string", + "updated_at": "string", + "user": { + "id": "string", + "email": "string", + "first_name": "string", + "last_name": "string" + } | null, + "tenant": { + "id": "string", + "name": "string" + } | null + } + ], + "pagination": { + "page": number, + "limit": number, + "total": number, + "totalPages": number, + "hasMore": boolean + } +} + +6.2 Get Audit Log by ID +Method: GET +Endpoint: /audit-logs/{id} +Headers: Authorization: Bearer {accessToken} +Response: { + "success": true, + "data": { + "id": "string", + "tenant_id": "string" | null, + "user_id": "string" | null, + "action": "string", + "resource_type": "string", + "resource_id": "string" | null, + "request_method": "string" | null, + "request_path": "string" | null, + "request_body": object | null, + "response_status": number | null, + "response_body": object | null, + "ip_address": "string" | null, + "user_agent": "string" | null, + "correlation_id": "string" | null, + "changes": object | null, + "metadata": object | null, + "created_at": "string", + "updated_at": "string", + "user": { + "id": "string", + "email": "string", + "first_name": "string", + "last_name": "string" + } | null, + "tenant": { + "id": "string", + "name": "string" + } | null + } +} + +================================================================================ +NOTES +================================================================================ + +1. Authentication: + - All API requests (except /auth/login) require Bearer token in Authorization header + - Token is automatically added by api-client interceptor from Redux store + - On 401 error (except for /auth/login and /auth/logout), user is redirected to login page + +2. Query Parameters: + - Pagination: page (default: 1), limit (default: 20) + - Sorting: orderBy[] format for array parameters (e.g., orderBy[]=name&orderBy[]=asc) + - Filtering: status, scope, method (varies by endpoint) + +3. Error Responses: + - Validation errors: { + "success": false, + "error": "Validation failed", + "details": [ + { + "path": "string", + "message": "string", + "code": "string" + } + ] + } + - General errors: { + "success": false, + "error": { + "code": "string", + "message": "string" + } + } + +4. Response Format: + - All successful responses include "success": true + - Data is wrapped in "data" property + - Paginated responses include "pagination" object + +5. Base URL: + - Configured via VITE_API_BASE_URL environment variable + - Default: http://localhost:3000/api/v1 + +================================================================================ +END OF DOCUMENTATION +================================================================================ diff --git a/src/components/shared/EditRoleModal.tsx b/src/components/shared/EditRoleModal.tsx index 42bb15b..67a184a 100644 --- a/src/components/shared/EditRoleModal.tsx +++ b/src/components/shared/EditRoleModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import type { ReactElement } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -10,7 +10,9 @@ import type { Role, UpdateRoleRequest } from '@/types/role'; // Validation schema const editRoleSchema = z.object({ name: z.string().min(1, 'Role name is required'), - code: z.string().min(1, 'Role code is required'), + code: z.enum(['super_admin', 'tenant_admin', 'quality_manager', 'developer', 'viewer'], { + message: 'Role code is required', + }), description: z.string().min(1, 'Description is required'), scope: z.enum(['platform', 'tenant', 'module'], { message: 'Scope is required', @@ -34,6 +36,14 @@ const scopeOptions = [ { value: 'module', label: 'Module' }, ]; +const roleCodeOptions = [ + { value: 'super_admin', label: 'Super Admin' }, + { value: 'tenant_admin', label: 'Tenant Admin' }, + { value: 'quality_manager', label: 'Quality Manager' }, + { value: 'developer', label: 'Developer' }, + { value: 'viewer', label: 'Viewer' }, +]; + export const EditRoleModal = ({ isOpen, onClose, @@ -44,6 +54,7 @@ export const EditRoleModal = ({ }: EditRoleModalProps): ReactElement | null => { const [isLoadingRole, setIsLoadingRole] = useState(false); const [loadError, setLoadError] = useState(null); + const loadedRoleIdRef = useRef(null); const { register, @@ -59,33 +70,40 @@ export const EditRoleModal = ({ }); const scopeValue = watch('scope'); + const codeValue = watch('code'); - // Load role data when modal opens + // Load role data when modal opens - only load once per roleId useEffect(() => { if (isOpen && roleId) { - const loadRole = async (): Promise => { - try { - setIsLoadingRole(true); - setLoadError(null); - clearErrors(); - const role = await onLoadRole(roleId); - reset({ - name: role.name, - code: role.code, - description: role.description || '', - scope: role.scope, - }); - } catch (err: any) { - setLoadError(err?.response?.data?.error?.message || 'Failed to load role details'); - } finally { - setIsLoadingRole(false); - } - }; - loadRole(); - } else { + // Only load if this is a new roleId or modal was closed and reopened + if (loadedRoleIdRef.current !== roleId) { + const loadRole = async (): Promise => { + try { + setIsLoadingRole(true); + setLoadError(null); + clearErrors(); + const role = await onLoadRole(roleId); + loadedRoleIdRef.current = roleId; + reset({ + name: role.name, + code: role.code as 'super_admin' | 'tenant_admin' | 'quality_manager' | 'developer' | 'viewer', + description: role.description || '', + scope: role.scope, + }); + } catch (err: any) { + setLoadError(err?.response?.data?.error?.message || 'Failed to load role details'); + } finally { + setIsLoadingRole(false); + } + }; + loadRole(); + } + } else if (!isOpen) { + // Only reset when modal is closed + loadedRoleIdRef.current = null; reset({ name: '', - code: '', + code: undefined, description: '', scope: 'platform', }); @@ -100,7 +118,9 @@ export const EditRoleModal = ({ clearErrors(); try { await onSubmit(roleId, data); + // Only reset form on success - this will be handled by parent closing modal } catch (error: any) { + // Don't reset form on error - keep the form data and show errors // Handle validation errors from API if (error?.response?.data?.details && Array.isArray(error.response.data.details)) { const validationErrors = error.response.data.details; @@ -114,8 +134,11 @@ export const EditRoleModal = ({ }); } else { // Handle general errors + // Check for nested error object with message property + const errorObj = error?.response?.data?.error; const errorMessage = - error?.response?.data?.error || + (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || + (typeof errorObj === 'string' ? errorObj : null) || error?.response?.data?.message || error?.message || 'Failed to update role. Please try again.'; @@ -124,6 +147,8 @@ export const EditRoleModal = ({ message: typeof errorMessage === 'string' ? errorMessage : 'Failed to update role. Please try again.', }); } + // Re-throw error to prevent form from thinking it succeeded + throw error; } }; @@ -162,20 +187,21 @@ export const EditRoleModal = ({ )} + {/* General Error Display - Always visible */} + {errors.root && ( +
+

{errors.root.message}

+
+ )} + {loadError && ( -
+

{loadError}

)} {!isLoadingRole && (
- {/* General Error Display */} - {errors.root && ( -
-

{errors.root.message}

-
- )} {/* Role Name and Role Code Row */}
@@ -187,12 +213,14 @@ export const EditRoleModal = ({ {...register('name')} /> - setValue('code', value as 'super_admin' | 'tenant_admin' | 'quality_manager' | 'developer' | 'viewer')} error={errors.code?.message} - {...register('code')} />
diff --git a/src/components/shared/EditTenantModal.tsx b/src/components/shared/EditTenantModal.tsx index 399efc4..e146e6c 100644 --- a/src/components/shared/EditTenantModal.tsx +++ b/src/components/shared/EditTenantModal.tsx @@ -7,14 +7,26 @@ import { Loader2 } from 'lucide-react'; import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared'; import type { Tenant } from '@/types/tenant'; -// Validation schema +// Validation schema - matches backend validation const editTenantSchema = z.object({ - name: z.string().min(1, 'Tenant name is required'), - slug: z.string().min(1, 'Slug is required'), - status: z.enum(['active', 'suspended', 'blocked'], { + name: z + .string() + .min(1, 'name is required') + .min(3, 'name must be at least 3 characters') + .max(100, 'name must be at most 100 characters'), + slug: z + .string() + .min(1, 'slug is required') + .min(3, 'slug must be at least 3 characters') + .max(100, 'slug must be at most 100 characters') + .regex(/^[a-z0-9-]+$/, 'slug format is invalid'), + status: z.enum(['active', 'suspended', 'deleted'], { message: 'Status is required', }), - timezone: z.string().min(1, 'Timezone is required'), + settings: z.any().optional().nullable(), + subscription_tier: z.string().max(50, 'subscription_tier must be at most 50 characters').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(), }); type EditTenantFormData = z.infer; @@ -31,20 +43,7 @@ interface EditTenantModalProps { const statusOptions = [ { value: 'active', label: 'Active' }, { value: 'suspended', label: 'Suspended' }, - { value: 'blocked', label: 'Blocked' }, -]; - -const timezoneOptions = [ - { value: 'America/New_York', label: 'America/New_York (EST)' }, - { value: 'America/Chicago', label: 'America/Chicago (CST)' }, - { value: 'America/Denver', label: 'America/Denver (MST)' }, - { value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST)' }, - { value: 'UTC', label: 'UTC' }, - { value: 'Europe/London', label: 'Europe/London (GMT)' }, - { value: 'Asia/Dubai', label: 'Asia/Dubai (GST)' }, - { value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' }, - { value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' }, - { value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT)' }, + { value: 'deleted', label: 'Deleted' }, ]; export const EditTenantModal = ({ @@ -64,13 +63,14 @@ export const EditTenantModal = ({ setValue, watch, reset, + setError, + clearErrors, formState: { errors }, } = useForm({ resolver: zodResolver(editTenantSchema), }); const statusValue = watch('status'); - const timezoneValue = watch('timezone'); // Load tenant data when modal opens useEffect(() => { @@ -79,12 +79,16 @@ export const EditTenantModal = ({ try { setIsLoadingTenant(true); setLoadError(null); + clearErrors(); const tenant = await onLoadTenant(tenantId); reset({ name: tenant.name, slug: tenant.slug, status: tenant.status, - timezone: tenant.settings?.timezone || 'America/New_York', + settings: tenant.settings, + subscription_tier: tenant.subscription_tier, + max_users: tenant.max_users, + max_modules: tenant.max_modules, }); } catch (err: any) { setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details'); @@ -98,15 +102,54 @@ export const EditTenantModal = ({ name: '', slug: '', status: 'active', - timezone: 'America/New_York', + settings: null, + subscription_tier: null, + max_users: null, + max_modules: null, }); setLoadError(null); + clearErrors(); } - }, [isOpen, tenantId, onLoadTenant, reset]); + }, [isOpen, tenantId, onLoadTenant, reset, clearErrors]); const handleFormSubmit = async (data: EditTenantFormData): Promise => { - if (tenantId) { + if (!tenantId) return; + + clearErrors(); + try { await onSubmit(tenantId, data); + } catch (error: any) { + // Handle validation errors from API + 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 === 'slug' || + detail.path === 'status' || + detail.path === 'settings' || + detail.path === 'subscription_tier' || + detail.path === 'max_users' || + detail.path === 'max_modules' + ) { + setError(detail.path as keyof EditTenantFormData, { + type: 'server', + message: detail.message, + }); + } + }); + } else { + // Handle general errors + const errorMessage = + error?.response?.data?.error || + error?.response?.data?.message || + error?.message || + 'Failed to update tenant. Please try again.'; + setError('root', { + type: 'server', + message: typeof errorMessage === 'string' ? errorMessage : 'Failed to update tenant. Please try again.', + }); + } } }; @@ -154,6 +197,13 @@ export const EditTenantModal = ({ {!isLoadingTenant && (
+ {/* General Error Display */} + {errors.root && ( +
+

{errors.root.message}

+
+ )} + {/* Tenant Name */} - {/* Status and Timezone Row */} -
- setValue('status', value as 'active' | 'suspended' | 'blocked')} - error={errors.status?.message} - /> - - setValue('timezone', value)} - error={errors.timezone?.message} - /> -
+ {/* Status */} + setValue('status', value as 'active' | 'suspended' | 'deleted')} + error={errors.status?.message} + />
)} diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index 3e9b4e0..3a148cc 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -21,7 +21,7 @@ const editUserSchema = z.object({ email: z.string().min(1, 'Email is required').email('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', 'blocked'], { + status: z.enum(['active', 'suspended', 'deleted'], { message: 'Status is required', }), tenant_id: z.string().min(1, 'Tenant is required'), @@ -42,7 +42,7 @@ interface EditUserModalProps { const statusOptions = [ { value: 'active', label: 'Active' }, { value: 'suspended', label: 'Suspended' }, - { value: 'blocked', label: 'Blocked' }, + { value: 'deleted', label: 'Deleted' }, ]; export const EditUserModal = ({ @@ -64,6 +64,8 @@ export const EditUserModal = ({ setValue, watch, reset, + setError, + clearErrors, formState: { errors }, } = useForm({ resolver: zodResolver(editUserSchema), @@ -148,6 +150,7 @@ export const EditUserModal = ({ try { setIsLoadingUser(true); setLoadError(null); + clearErrors(); const user = await onLoadUser(userId); // Store selected IDs for dropdown pre-loading @@ -183,12 +186,48 @@ export const EditUserModal = ({ role_id: '', }); setLoadError(null); + clearErrors(); } - }, [isOpen, userId, onLoadUser, reset]); + }, [isOpen, userId, onLoadUser, reset, clearErrors]); const handleFormSubmit = async (data: EditUserFormData): Promise => { - if (userId) { + if (!userId) return; + + clearErrors(); + try { await onSubmit(userId, data); + } catch (error: any) { + // Handle validation errors from API + 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 === 'email' || + detail.path === 'first_name' || + detail.path === 'last_name' || + detail.path === 'status' || + detail.path === 'auth_provider' || + detail.path === 'tenant_id' || + detail.path === 'role_id' + ) { + setError(detail.path as keyof EditUserFormData, { + type: 'server', + message: detail.message, + }); + } + }); + } else { + // Handle general errors + const errorMessage = + error?.response?.data?.error || + error?.response?.data?.message || + error?.message || + 'Failed to update user. Please try again.'; + setError('root', { + type: 'server', + message: typeof errorMessage === 'string' ? errorMessage : 'Failed to update user. Please try again.', + }); + } } }; @@ -236,6 +275,13 @@ export const EditUserModal = ({ {!isLoadingUser && (
+ {/* General Error Display */} + {errors.root && ( +
+

{errors.root.message}

+
+ )} + {/* Email */} setValue('status', value as 'active' | 'suspended' | 'blocked')} + onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')} error={errors.status?.message} />
diff --git a/src/components/shared/NewRoleModal.tsx b/src/components/shared/NewRoleModal.tsx index 4a91af0..021651c 100644 --- a/src/components/shared/NewRoleModal.tsx +++ b/src/components/shared/NewRoleModal.tsx @@ -9,7 +9,9 @@ import type { CreateRoleRequest } from '@/types/role'; // Validation schema const newRoleSchema = z.object({ name: z.string().min(1, 'Role name is required'), - code: z.string().min(1, 'Role code is required'), + code: z.enum(['super_admin', 'tenant_admin', 'quality_manager', 'developer', 'viewer'], { + message: 'Role code is required', + }), description: z.string().min(1, 'Description is required'), scope: z.enum(['platform', 'tenant', 'module'], { message: 'Scope is required', @@ -31,6 +33,14 @@ const scopeOptions = [ { value: 'module', label: 'Module' }, ]; +const roleCodeOptions = [ + { value: 'super_admin', label: 'Super Admin' }, + { value: 'tenant_admin', label: 'Tenant Admin' }, + { value: 'quality_manager', label: 'Quality Manager' }, + { value: 'developer', label: 'Developer' }, + { value: 'viewer', label: 'Viewer' }, +]; + export const NewRoleModal = ({ isOpen, onClose, @@ -50,17 +60,19 @@ export const NewRoleModal = ({ resolver: zodResolver(newRoleSchema), defaultValues: { scope: 'platform', + code: undefined, }, }); const scopeValue = watch('scope'); + const codeValue = watch('code'); // Reset form when modal closes useEffect(() => { if (!isOpen) { reset({ name: '', - code: '', + code: undefined, description: '', scope: 'platform', }); @@ -86,8 +98,11 @@ export const NewRoleModal = ({ }); } else { // Handle general errors + // Check for nested error object with message property + const errorObj = error?.response?.data?.error; const errorMessage = - error?.response?.data?.error || + (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || + (typeof errorObj === 'string' ? errorObj : null) || error?.response?.data?.message || error?.message || 'Failed to create role. Please try again.'; @@ -146,12 +161,14 @@ export const NewRoleModal = ({ {...register('name')} /> - setValue('code', value as 'super_admin' | 'tenant_admin' | 'quality_manager' | 'developer' | 'viewer')} error={errors.code?.message} - {...register('code')} />
diff --git a/src/components/shared/NewTenantModal.tsx b/src/components/shared/NewTenantModal.tsx index a468a22..9a28340 100644 --- a/src/components/shared/NewTenantModal.tsx +++ b/src/components/shared/NewTenantModal.tsx @@ -5,14 +5,26 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton } from '@/components/shared'; -// Validation schema +// Validation schema - matches backend validation const newTenantSchema = z.object({ - name: z.string().min(1, 'Tenant name is required'), - slug: z.string().min(1, 'Slug is required'), - status: z.enum(['active', 'suspended', 'blocked'], { + name: z + .string() + .min(1, 'name is required') + .min(3, 'name must be at least 3 characters') + .max(100, 'name must be at most 100 characters'), + slug: z + .string() + .min(1, 'slug is required') + .min(3, 'slug must be at least 3 characters') + .max(100, 'slug must be at most 100 characters') + .regex(/^[a-z0-9-]+$/, 'slug format is invalid'), + status: z.enum(['active', 'suspended', 'deleted'], { message: 'Status is required', }), - timezone: z.string().min(1, 'Timezone is required'), + settings: z.any().optional().nullable(), + subscription_tier: z.string().max(50, 'subscription_tier must be at most 50 characters').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(), }); type NewTenantFormData = z.infer; @@ -27,20 +39,7 @@ interface NewTenantModalProps { const statusOptions = [ { value: 'active', label: 'Active' }, { value: 'suspended', label: 'Suspended' }, - { value: 'blocked', label: 'Blocked' }, -]; - -const timezoneOptions = [ - { value: 'America/New_York', label: 'America/New_York (EST)' }, - { value: 'America/Chicago', label: 'America/Chicago (CST)' }, - { value: 'America/Denver', label: 'America/Denver (MST)' }, - { value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST)' }, - { value: 'UTC', label: 'UTC' }, - { value: 'Europe/London', label: 'Europe/London (GMT)' }, - { value: 'Asia/Dubai', label: 'Asia/Dubai (GST)' }, - { value: 'Asia/Kolkata', label: 'Asia/Kolkata (IST)' }, - { value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST)' }, - { value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT)' }, + { value: 'deleted', label: 'Deleted' }, ]; export const NewTenantModal = ({ @@ -55,17 +54,21 @@ export const NewTenantModal = ({ setValue, watch, reset, + setError, + clearErrors, formState: { errors }, } = useForm({ resolver: zodResolver(newTenantSchema), defaultValues: { status: 'active', - timezone: 'America/New_York', + settings: null, + subscription_tier: null, + max_users: null, + max_modules: null, }, }); const statusValue = watch('status'); - const timezoneValue = watch('timezone'); // Reset form when modal closes useEffect(() => { @@ -74,13 +77,52 @@ export const NewTenantModal = ({ name: '', slug: '', status: 'active', - timezone: 'America/New_York', + settings: null, + subscription_tier: null, + max_users: null, + max_modules: null, }); + clearErrors(); } - }, [isOpen, reset]); + }, [isOpen, reset, clearErrors]); const handleFormSubmit = async (data: NewTenantFormData): Promise => { - await onSubmit(data); + clearErrors(); + try { + await onSubmit(data); + } catch (error: any) { + // Handle validation errors from API + 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 === 'slug' || + detail.path === 'status' || + detail.path === 'settings' || + detail.path === 'subscription_tier' || + detail.path === 'max_users' || + detail.path === 'max_modules' + ) { + setError(detail.path as keyof NewTenantFormData, { + type: 'server', + message: detail.message, + }); + } + }); + } else { + // Handle general errors + const errorMessage = + error?.response?.data?.error || + error?.response?.data?.message || + error?.message || + 'Failed to create tenant. Please try again.'; + setError('root', { + type: 'server', + message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.', + }); + } + } }; return ( @@ -113,6 +155,13 @@ export const NewTenantModal = ({ } >
+ {/* General Error Display */} + {errors.root && ( +
+

{errors.root.message}

+
+ )} +
{/* Tenant Name */} - {/* Status and Timezone Row */} -
- setValue('status', value as 'active' | 'suspended' | 'blocked')} - error={errors.status?.message} - /> - - setValue('timezone', value)} - error={errors.timezone?.message} - /> -
+ {/* Status */} + setValue('status', value as 'active' | 'suspended' | 'deleted')} + error={errors.status?.message} + />
diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index 4f14eaf..df1f730 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -22,7 +22,7 @@ const newUserSchema = z confirmPassword: z.string().min(1, 'Confirm password is required'), 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', 'blocked'], { + status: z.enum(['active', 'suspended', 'deleted'], { message: 'Status is required', }), auth_provider: z.enum(['local'], { @@ -48,7 +48,7 @@ interface NewUserModalProps { const statusOptions = [ { value: 'active', label: 'Active' }, { value: 'suspended', label: 'Suspended' }, - { value: 'blocked', label: 'Blocked' }, + { value: 'deleted', label: 'Deleted' }, ]; export const NewUserModal = ({ @@ -63,6 +63,8 @@ export const NewUserModal = ({ setValue, watch, reset, + setError, + clearErrors, formState: { errors }, } = useForm({ resolver: zodResolver(newUserSchema), @@ -92,8 +94,9 @@ export const NewUserModal = ({ tenant_id: '', role_id: '', }); + clearErrors(); } - }, [isOpen, reset]); + }, [isOpen, reset, clearErrors]); // Load tenants for dropdown const loadTenants = async (page: number, limit: number) => { @@ -120,8 +123,44 @@ export const NewUserModal = ({ }; const handleFormSubmit = async (data: NewUserFormData): Promise => { - const { confirmPassword, ...submitData } = data; - await onSubmit(submitData); + clearErrors(); + try { + const { confirmPassword, ...submitData } = data; + await onSubmit(submitData); + } catch (error: any) { + // Handle validation errors from API + 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 === 'email' || + detail.path === 'password' || + detail.path === 'first_name' || + detail.path === 'last_name' || + detail.path === 'status' || + detail.path === 'auth_provider' || + detail.path === 'tenant_id' || + detail.path === 'role_id' + ) { + setError(detail.path as keyof NewUserFormData, { + type: 'server', + message: detail.message, + }); + } + }); + } else { + // Handle general errors + const errorMessage = + error?.response?.data?.error || + error?.response?.data?.message || + error?.message || + 'Failed to create user. Please try again.'; + setError('root', { + type: 'server', + message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create user. Please try again.', + }); + } + } }; return ( @@ -154,6 +193,13 @@ export const NewUserModal = ({ } >
+ {/* General Error Display */} + {errors.root && ( +
+

{errors.root.message}

+
+ )} +
{/* Email */} setValue('status', value as 'active' | 'suspended' | 'blocked')} + onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')} error={errors.status?.message} />
diff --git a/src/components/shared/ViewAuditLogModal.tsx b/src/components/shared/ViewAuditLogModal.tsx new file mode 100644 index 0000000..3615a73 --- /dev/null +++ b/src/components/shared/ViewAuditLogModal.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Modal, SecondaryButton } from '@/components/shared'; +import type { AuditLog } from '@/types/audit-log'; + +// Helper function to format date +const formatDate = (dateString: string | null): string => { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +}; + +// Helper function to format JSON +const formatJSON = (obj: Record | null): string => { + if (!obj) return 'N/A'; + return JSON.stringify(obj, null, 2); +}; + +interface ViewAuditLogModalProps { + isOpen: boolean; + onClose: () => void; + auditLogId: string | null; + onLoadAuditLog: (id: string) => Promise; +} + +export const ViewAuditLogModal = ({ + isOpen, + onClose, + auditLogId, + onLoadAuditLog, +}: ViewAuditLogModalProps): ReactElement | null => { + const [auditLog, setAuditLog] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Load audit log data when modal opens + useEffect(() => { + if (isOpen && auditLogId) { + const loadAuditLog = async (): Promise => { + try { + setIsLoading(true); + setError(null); + const data = await onLoadAuditLog(auditLogId); + setAuditLog(data); + } catch (err: any) { + setError(err?.response?.data?.error?.message || 'Failed to load audit log details'); + } finally { + setIsLoading(false); + } + }; + loadAuditLog(); + } else { + setAuditLog(null); + setError(null); + } + }, [isOpen, auditLogId, onLoadAuditLog]); + + return ( + + Close + + } + > +
+ {isLoading && ( +
+ +
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!isLoading && !error && auditLog && ( +
+ {/* Basic Information */} +
+

Basic Information

+
+
+ +

{auditLog.action}

+
+
+ +

{auditLog.resource_type}

+
+
+ +

+ {auditLog.resource_id || 'N/A'} +

+
+
+ +

{auditLog.request_method || 'N/A'}

+
+
+ +

+ {auditLog.request_path || 'N/A'} +

+
+
+ +

+ {auditLog.response_status || 'N/A'} +

+
+
+
+ + {/* User & Tenant Information */} +
+

User & Tenant Information

+
+ {auditLog.user && ( + <> +
+ +

+ {auditLog.user.first_name} {auditLog.user.last_name} +

+
+
+ +

{auditLog.user.email}

+
+
+ +

{auditLog.user.id}

+
+ + )} + {auditLog.tenant && ( + <> +
+ +

{auditLog.tenant.name}

+
+
+ +

{auditLog.tenant.id}

+
+ + )} + {!auditLog.user && !auditLog.tenant && ( +
+

No user or tenant information available

+
+ )} +
+
+ + {/* Request & Response Information */} + {(auditLog.request_body || auditLog.response_body) && ( +
+

Request & Response

+
+ {auditLog.request_body && ( +
+ +
+                        {formatJSON(auditLog.request_body)}
+                      
+
+ )} + {auditLog.response_body && ( +
+ +
+                        {formatJSON(auditLog.response_body)}
+                      
+
+ )} +
+
+ )} + + {/* Changes & Metadata */} + {(auditLog.changes || auditLog.metadata) && ( +
+

Changes & Metadata

+
+ {auditLog.changes && ( +
+ +
+                        {formatJSON(auditLog.changes)}
+                      
+
+ )} + {auditLog.metadata && ( +
+ +
+                        {formatJSON(auditLog.metadata)}
+                      
+
+ )} +
+
+ )} + + {/* Additional Information */} +
+

Additional Information

+
+
+ +

{auditLog.ip_address || 'N/A'}

+
+
+ +

+ {auditLog.user_agent || 'N/A'} +

+
+
+ +

+ {auditLog.correlation_id || 'N/A'} +

+
+
+
+ + {/* Timestamps */} +
+

Timestamps

+
+
+ +

{formatDate(auditLog.created_at)}

+
+
+ +

{formatDate(auditLog.updated_at)}

+
+
+
+
+ )} +
+
+ ); +}; diff --git a/src/components/shared/ViewModuleModal.tsx b/src/components/shared/ViewModuleModal.tsx index 8642ca1..1c6a034 100644 --- a/src/components/shared/ViewModuleModal.tsx +++ b/src/components/shared/ViewModuleModal.tsx @@ -102,24 +102,20 @@ export const ViewModuleModal = ({ {!isLoading && !error && module && (
{/* Basic Information */} -
+

Basic Information

-
- -

{module.module_id}

-
-

{module.name}

+

{module.name}

- -

{module.description}

+ +

{module.module_id}

-

{module.version}

+

{module.version}

@@ -129,74 +125,70 @@ export const ViewModuleModal = ({
-
- -
- - {module.health_status || 'N/A'} - -
-
+
+
+ +

{module.description}

{/* Technical Details */} -
+

Technical Details

-

{module.runtime_language || 'N/A'}

+

{module.runtime_language || 'N/A'}

-

{module.framework || 'N/A'}

+

{module.framework || 'N/A'}

-

{module.base_url}

+

{module.base_url}

-

{module.health_endpoint}

+

{module.health_endpoint}

{/* Resource Limits */} -
+

Resource Limits

-

{module.cpu_request}

+

{module.cpu_request}

-

{module.cpu_limit}

+

{module.cpu_limit}

-

{module.memory_request}

+

{module.memory_request}

-

{module.memory_limit}

+

{module.memory_limit}

-

{module.min_replicas}

+

{module.min_replicas}

-

{module.max_replicas}

+

{module.max_replicas}

{/* Additional Information */} {(module.endpoints || module.kafka_topics || module.consecutive_failures !== null) && ( -
+

Additional Information

{module.endpoints && module.endpoints.length > 0 && ( @@ -204,7 +196,7 @@ export const ViewModuleModal = ({
{module.endpoints.map((endpoint, index) => ( -

+

{endpoint}

))} @@ -216,7 +208,7 @@ export const ViewModuleModal = ({
{module.kafka_topics.map((topic, index) => ( -

+

{topic}

))} @@ -226,43 +218,43 @@ export const ViewModuleModal = ({ {module.consecutive_failures !== null && (
-

{module.consecutive_failures}

+

{module.consecutive_failures}

)}
)} + {/* Health */} +
+

Health

+
+
+ +
+ + {module.health_status || 'N/A'} + +
+
+
+ +

{formatDate(module.last_health_check)}

+
+
+
+ {/* Registration Information */}

Registration Information

-

{module.registered_by_email || module.registered_by}

+

{module.registered_by_email || module.registered_by}

- -

{module.tenant_id}

-
-
- -

{formatDate(module.last_health_check)}

-
-
-
- - {/* Timestamps */} -
-

Timestamps

-
-
- -

{formatDate(module.created_at)}

-
-
- -

{formatDate(module.updated_at)}

+ +

{formatDate(module.created_at)}

diff --git a/src/components/shared/ViewTenantModal.tsx b/src/components/shared/ViewTenantModal.tsx index d6e5249..2f9975c 100644 --- a/src/components/shared/ViewTenantModal.tsx +++ b/src/components/shared/ViewTenantModal.tsx @@ -16,7 +16,7 @@ const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => switch (status.toLowerCase()) { case 'active': return 'success'; - case 'blocked': + case 'deleted': return 'failure'; case 'suspended': return 'process'; diff --git a/src/components/shared/ViewUserModal.tsx b/src/components/shared/ViewUserModal.tsx index c9fcef5..66fa9c3 100644 --- a/src/components/shared/ViewUserModal.tsx +++ b/src/components/shared/ViewUserModal.tsx @@ -9,7 +9,7 @@ const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => switch (status.toLowerCase()) { case 'active': return 'success'; - case 'blocked': + case 'deleted': return 'failure'; case 'suspended': return 'process'; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 56ed604..8e0d502 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -22,5 +22,6 @@ export { NewRoleModal } from './NewRoleModal'; export { ViewRoleModal } from './ViewRoleModal'; export { EditRoleModal } from './EditRoleModal'; export { ViewModuleModal } from './ViewModuleModal'; +export { ViewAuditLogModal } from './ViewAuditLogModal'; export { PageHeader } from './PageHeader'; export type { TabItem } from './PageHeader'; \ No newline at end of file diff --git a/src/features/dashboard/components/RecentActivity.tsx b/src/features/dashboard/components/RecentActivity.tsx index 12c210f..fdd22ad 100644 --- a/src/features/dashboard/components/RecentActivity.tsx +++ b/src/features/dashboard/components/RecentActivity.tsx @@ -1,72 +1,78 @@ -import { ChevronDown, Filter } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { ChevronDown, Filter, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardHeader, CardContent } from '@/components/ui/card'; -import type { ActivityLog } from '@/types/dashboard'; -import { cn } from '@/lib/utils'; +import { StatusBadge } from '@/components/shared'; +import { auditLogService } from '@/services/audit-log-service'; +import type { AuditLog } from '@/types/audit-log'; -const activityData: ActivityLog[] = [ - { - action: 'CREATE', - resourceType: 'USER', - resourceId: 'john@acme.com', - ipAddress: '192.168.1.100', - timestamp: '2 mins ago', - }, - { - action: 'UPDATE', - resourceType: 'TENANT', - resourceId: 'acme-medical', - ipAddress: '10.0.0.21', - timestamp: '5 mins ago', - }, - { - action: 'SUSPEND', - resourceType: 'USER', - resourceId: 'alice@globex.io', - ipAddress: '172.16.3.8', - timestamp: '12 mins ago', - }, - { - action: 'LOGIN', - resourceType: 'SESSION', - resourceId: 'session-98f2a', - ipAddress: '192.168.10.4', - timestamp: '20 mins ago', - }, - { - action: 'LOGOUT', - resourceType: 'SESSION', - resourceId: 'session-77bd1', - ipAddress: '192.168.10.4', - timestamp: '28 mins ago', - }, - { - action: 'CREATE', - resourceType: 'MODULE', - resourceId: 'hello-world-v1', - ipAddress: '10.1.0.15', - timestamp: '35 mins ago', - }, -]; +// Helper function to get action badge variant +const getActionVariant = (action: string): 'success' | 'failure' | 'info' | 'process' => { + const lowerAction = action.toLowerCase(); + if (lowerAction.includes('create') || lowerAction.includes('register')) return 'success'; + if (lowerAction.includes('update') || lowerAction.includes('version_update') || lowerAction.includes('login')) return 'info'; + if (lowerAction.includes('delete') || lowerAction.includes('deregister')) return 'failure'; + if (lowerAction.includes('read') || lowerAction.includes('get') || lowerAction.includes('status_change')) return 'process'; + return 'info'; +}; -const getActionBadgeClass = (action: ActivityLog['action']): string => { - const baseClass = 'px-2.5 py-1 rounded-full text-[11px] font-semibold uppercase h-[14px]'; - switch (action) { - case 'CREATE': - return cn(baseClass, 'bg-[rgba(16,185,129,0.1)] text-[#059669]'); - case 'UPDATE': - case 'LOGIN': - return cn(baseClass, 'bg-[rgba(37,99,235,0.1)] text-[#2563eb]'); - case 'SUSPEND': - return cn(baseClass, 'bg-[rgba(245,158,11,0.1)] text-[#d97706]'); - case 'LOGOUT': - return cn(baseClass, 'bg-white text-[#6b7280]'); - default: - return baseClass; +// Helper function to format relative time +const formatRelativeTime = (dateString: string): string => { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) { + return `${diffInSeconds} sec${diffInSeconds !== 1 ? 's' : ''} ago`; } + + const diffInMinutes = Math.floor(diffInSeconds / 60); + if (diffInMinutes < 60) { + return `${diffInMinutes} min${diffInMinutes !== 1 ? 's' : ''} ago`; + } + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) { + return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`; + } + + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 7) { + return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`; + } + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); }; export const RecentActivity = () => { + const [auditLogs, setAuditLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRecentActivity = async (): Promise => { + try { + setIsLoading(true); + setError(null); + const response = await auditLogService.getAll(1, 5, null, ['created_at', 'desc']); + if (response.success) { + setAuditLogs(response.data); + } else { + setError('Failed to load recent activity'); + } + } catch (err: any) { + setError(err?.response?.data?.error?.message || 'Failed to load recent activity'); + } finally { + setIsLoading(false); + } + }; + + fetchRecentActivity(); + }, []); return ( @@ -85,55 +91,75 @@ export const RecentActivity = () => {
-
- - - - - - - - - - - - {activityData.map((activity, index) => ( - - - - - - - - ))} - -
- Action - - Resource Type - - Resource ID - - IP Address - - Timestamp -
- - {activity.action} - - - {activity.resourceType} - - {activity.resourceId} - - {activity.ipAddress} - - {activity.timestamp} -
-
+ {isLoading && ( +
+ +
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!isLoading && !error && ( +
+ {auditLogs.length === 0 ? ( +
+

No recent activity found

+
+ ) : ( + + + + + + + + + + + + {auditLogs.map((log) => ( + + + + + + + + ))} + +
+ Action + + Resource Type + + Resource ID + + IP Address + + Timestamp +
+ + {log.action} + + + {log.resource_type} + + {log.resource_id || 'N/A'} + + {log.ip_address || 'N/A'} + + {formatRelativeTime(log.created_at)} +
+ )} +
+ )}
); diff --git a/src/pages/AuditLogs.tsx b/src/pages/AuditLogs.tsx index 37685fa..970ee06 100644 --- a/src/pages/AuditLogs.tsx +++ b/src/pages/AuditLogs.tsx @@ -1,7 +1,278 @@ -import { Layout } from "@/components/layout/Layout" -import type { ReactElement } from "react" +import { useState, useEffect } from 'react'; +import type { ReactElement } from 'react'; +import { Layout } from '@/components/layout/Layout'; +import { + ViewAuditLogModal, + DataTable, + Pagination, + FilterDropdown, + StatusBadge, + type Column, +} from '@/components/shared'; +import { Download, ArrowUpDown } from 'lucide-react'; +import { auditLogService } from '@/services/audit-log-service'; +import type { AuditLog } from '@/types/audit-log'; + +// Helper function to format date +const formatDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +// Helper function to get action badge variant +const getActionVariant = (action: string): 'success' | 'failure' | 'info' | 'process' => { + const lowerAction = action.toLowerCase(); + if (lowerAction.includes('create') || lowerAction.includes('register')) return 'success'; + if (lowerAction.includes('update') || lowerAction.includes('version_update') || lowerAction.includes('login')) return 'info'; + if (lowerAction.includes('delete') || lowerAction.includes('deregister')) return 'failure'; + if (lowerAction.includes('read') || lowerAction.includes('get') || lowerAction.includes('status_change')) return 'process'; + return 'info'; +}; + +// Helper function to get method badge variant +const getMethodVariant = (method: string | null): 'success' | 'failure' | 'info' | 'process' => { + if (!method) return 'info'; + const upperMethod = method.toUpperCase(); + if (upperMethod === 'GET') return 'success'; + if (upperMethod === 'POST') return 'info'; + if (upperMethod === 'PUT' || upperMethod === 'PATCH') return 'process'; + if (upperMethod === 'DELETE') return 'failure'; + return 'info'; +}; + +// Helper function to get status badge color based on response status +const getStatusColor = (status: number | null): string => { + if (!status) return 'text-[#6b7280]'; + if (status >= 200 && status < 300) return 'text-[#10b981]'; + if (status >= 300 && status < 400) return 'text-[#f59e0b]'; + if (status >= 400) return 'text-[#ef4444]'; + return 'text-[#6b7280]'; +}; const AuditLogs = (): ReactElement => { + const [auditLogs, setAuditLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(5); + const [pagination, setPagination] = useState<{ + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; + }>({ + page: 1, + limit: 5, + total: 0, + totalPages: 1, + hasMore: false, + }); + + // Filter state + const [methodFilter, setMethodFilter] = useState(null); + const [orderBy, setOrderBy] = useState(null); + + // View modal + const [viewModalOpen, setViewModalOpen] = useState(false); + const [selectedAuditLogId, setSelectedAuditLogId] = useState(null); + + const fetchAuditLogs = async ( + page: number, + itemsPerPage: number, + method: string | null = null, + sortBy: string[] | null = null + ): Promise => { + try { + setIsLoading(true); + setError(null); + const response = await auditLogService.getAll(page, itemsPerPage, method, sortBy); + if (response.success) { + setAuditLogs(response.data); + setPagination(response.pagination); + } else { + setError('Failed to load audit logs'); + } + } catch (err: any) { + setError(err?.response?.data?.error?.message || 'Failed to load audit logs'); + } finally { + setIsLoading(false); + } + }; + + // Fetch audit logs on mount and when pagination/filters change + useEffect(() => { + fetchAuditLogs(currentPage, limit, methodFilter, orderBy); + }, [currentPage, limit, methodFilter, orderBy]); + + // View audit log handler + const handleViewAuditLog = (auditLogId: string): void => { + setSelectedAuditLogId(auditLogId); + setViewModalOpen(true); + }; + + // Load audit log for view + const loadAuditLog = async (id: string): Promise => { + const response = await auditLogService.getById(id); + return response.data; + }; + + // Define table columns + const columns: Column[] = [ + { + key: 'created_at', + label: 'Timestamp', + render: (log) => ( + {formatDate(log.created_at)} + ), + mobileLabel: 'Time', + }, + { + key: 'resource_type', + label: 'Resource Type', + render: (log) => ( + {log.resource_type} + ), + }, + { + key: 'action', + label: 'Action', + render: (log) => ( + + {log.action} + + ), + }, + { + key: 'resource_id', + label: 'Resource ID', + render: (log) => ( + + {log.resource_id || 'N/A'} + + ), + }, + { + key: 'user', + label: 'User', + render: (log) => ( + + {log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'} + + ), + }, + { + key: 'request_method', + label: 'Method', + render: (log) => ( + + {log.request_method ? log.request_method.toUpperCase() : 'N/A'} + + ), + }, + { + key: 'response_status', + label: 'Status', + render: (log) => ( + + {log.response_status || 'N/A'} + + ), + }, + { + key: 'ip_address', + label: 'IP Address', + render: (log) => ( + + {log.ip_address || 'N/A'} + + ), + }, + { + key: 'actions', + label: 'Actions', + align: 'right', + render: (log) => ( +
+ +
+ ), + }, + ]; + + // Mobile card renderer + const mobileCardRenderer = (log: AuditLog) => ( +
+
+
+

{log.resource_type}

+
+ + {log.action} + +
+
+ +
+
+
+ Timestamp: +

{formatDate(log.created_at)}

+
+
+ User: +

+ {log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'} +

+
+
+ Method: +
+ + {log.request_method ? log.request_method.toUpperCase() : 'N/A'} + +
+
+
+ Status: +

+ {log.response_status || 'N/A'} +

+
+
+ Resource ID: +

+ {log.resource_id || 'N/A'} +

+
+
+ IP Address: +

{log.ip_address || 'N/A'}

+
+
+
+ ); + return ( { description: 'View and manage all audit logs in the QAssure platform.', }} > -
Audit Logs
-
- ) -} + {/* Table Container */} +
+ {/* Table Header with Filters */} +
+ {/* Filters */} +
+ {/* Method Filter */} + { + setMethodFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All" + /> -export default AuditLogs \ No newline at end of file + {/* Sort Filter */} + { + setOrderBy(value as string[] | null); + setCurrentPage(1); + }} + placeholder="Default" + showIcon + icon={} + /> +
+ + {/* Actions */} +
+ {/* Export Button */} + +
+
+ + {/* Table */} + log.id} + isLoading={isLoading} + error={error} + mobileCardRenderer={mobileCardRenderer} + emptyMessage="No audit logs found" + /> + + {/* Pagination */} + {pagination.total > 0 && ( +
+ setCurrentPage(page)} + onLimitChange={(newLimit: number) => { + setLimit(newLimit); + setCurrentPage(1); + }} + /> +
+ )} +
+ + {/* View Audit Log Modal */} + { + setViewModalOpen(false); + setSelectedAuditLogId(null); + }} + auditLogId={selectedAuditLogId} + onLoadAuditLog={loadAuditLog} + /> + + ); +}; + +export default AuditLogs; diff --git a/src/pages/Tenants.tsx b/src/pages/Tenants.tsx index 5532235..be417ff 100644 --- a/src/pages/Tenants.tsx +++ b/src/pages/Tenants.tsx @@ -36,7 +36,7 @@ const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => switch (status.toLowerCase()) { case 'active': return 'success'; - case 'blocked': + case 'deleted': return 'failure'; case 'suspended': return 'process'; @@ -118,19 +118,15 @@ const Tenants = (): ReactElement => { const handleCreateTenant = async (data: { name: string; slug: string; - status: 'active' | 'suspended' | 'blocked'; - timezone: string; + status: 'active' | 'suspended' | 'deleted'; + settings?: Record | null; + subscription_tier?: string | null; + max_users?: number | null; + max_modules?: number | null; }): Promise => { try { setIsCreating(true); - await tenantService.create({ - name: data.name, - slug: data.slug, - status: data.status, - settings: { - timezone: data.timezone, - }, - }); + await tenantService.create(data); // Close modal and refresh tenant list setIsModalOpen(false); await fetchTenants(currentPage, limit, statusFilter, orderBy); @@ -160,20 +156,16 @@ const Tenants = (): ReactElement => { data: { name: string; slug: string; - status: 'active' | 'suspended' | 'blocked'; - timezone: string; + status: 'active' | 'suspended' | 'deleted'; + settings?: Record | null; + subscription_tier?: string | null; + max_users?: number | null; + max_modules?: number | null; } ): Promise => { try { setIsUpdating(true); - await tenantService.update(id, { - name: data.name, - slug: data.slug, - status: data.status, - settings: { - timezone: data.timezone, - }, - }); + await tenantService.update(id, data); setEditModalOpen(false); setSelectedTenantId(null); await fetchTenants(currentPage, limit, statusFilter, orderBy); @@ -236,7 +228,7 @@ const Tenants = (): ReactElement => { options={[ { value: 'active', label: 'Active' }, { value: 'suspended', label: 'Suspended' }, - { value: 'blocked', label: 'Blocked' }, + { value: 'deleted', label: 'Deleted' }, ]} value={statusFilter} onChange={(value) => { diff --git a/src/pages/Users.tsx b/src/pages/Users.tsx index 7cd5353..2c50e8d 100644 --- a/src/pages/Users.tsx +++ b/src/pages/Users.tsx @@ -38,7 +38,7 @@ const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => return 'process'; case 'inactive': return 'failure'; - case 'blocked': + case 'deleted': return 'failure'; case 'suspended': return 'process'; @@ -116,7 +116,7 @@ const Users = (): ReactElement => { password: string; first_name: string; last_name: string; - status: 'active' | 'suspended' | 'blocked'; + status: 'active' | 'suspended' | 'deleted'; auth_provider: 'local'; tenant_id: string; role_id: string; @@ -153,7 +153,7 @@ const Users = (): ReactElement => { email: string; first_name: string; last_name: string; - status: 'active' | 'suspended' | 'blocked'; + status: 'active' | 'suspended' | 'deleted'; tenant_id: string; role_id: string; } @@ -328,7 +328,7 @@ const Users = (): ReactElement => { { value: 'pending_verification', label: 'Pending Verification' }, { value: 'inactive', label: 'Inactive' }, { value: 'suspended', label: 'Suspended' }, - { value: 'blocked', label: 'Blocked' }, + { value: 'deleted', label: 'Deleted' }, ]} value={statusFilter} onChange={(value) => { diff --git a/src/services/audit-log-service.ts b/src/services/audit-log-service.ts new file mode 100644 index 0000000..637ba0c --- /dev/null +++ b/src/services/audit-log-service.ts @@ -0,0 +1,28 @@ +import apiClient from './api-client'; +import type { AuditLogsResponse, GetAuditLogResponse } from '@/types/audit-log'; + +export const auditLogService = { + getAll: async ( + page: number = 1, + limit: number = 20, + method?: string | null, + orderBy?: string[] | null + ): Promise => { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('limit', String(limit)); + if (method) { + params.append('method', method); + } + if (orderBy && Array.isArray(orderBy) && orderBy.length === 2) { + params.append('orderBy[]', orderBy[0]); + params.append('orderBy[]', orderBy[1]); + } + const response = await apiClient.get(`/audit-logs?${params.toString()}`); + return response.data; + }, + getById: async (id: string): Promise => { + const response = await apiClient.get(`/audit-logs/${id}`); + return response.data; + }, +}; diff --git a/src/services/tenant-service.ts b/src/services/tenant-service.ts index 77da2cc..b756d2a 100644 --- a/src/services/tenant-service.ts +++ b/src/services/tenant-service.ts @@ -10,10 +10,11 @@ export interface TenantsResponse { export interface CreateTenantRequest { name: string; slug: string; - status: 'active' | 'suspended' | 'blocked'; - settings: { - timezone: string; - }; + status: 'active' | 'suspended' | 'deleted'; + settings?: Record | null; + subscription_tier?: string | null; + max_users?: number | null; + max_modules?: number | null; } export interface CreateTenantResponse { @@ -39,10 +40,11 @@ export interface GetTenantResponse { export interface UpdateTenantRequest { name: string; slug: string; - status: 'active' | 'suspended' | 'blocked'; - settings: { - timezone: string; - }; + status: 'active' | 'suspended' | 'deleted'; + settings?: Record | null; + subscription_tier?: string | null; + max_users?: number | null; + max_modules?: number | null; } export interface UpdateTenantResponse { diff --git a/src/types/audit-log.ts b/src/types/audit-log.ts new file mode 100644 index 0000000..7ff51e0 --- /dev/null +++ b/src/types/audit-log.ts @@ -0,0 +1,53 @@ +export interface AuditLogUser { + id: string; + email: string; + first_name: string; + last_name: string; +} + +export interface AuditLogTenant { + id: string; + name: string; +} + +export interface AuditLog { + id: string; + tenant_id: string | null; + user_id: string | null; + action: string; + resource_type: string; + resource_id: string | null; + request_method: string | null; + request_path: string | null; + request_body: Record | null; + response_status: number | null; + response_body: Record | null; + ip_address: string | null; + user_agent: string | null; + correlation_id: string | null; + changes: Record | null; + metadata: Record | null; + created_at: string; + updated_at: string; + user: AuditLogUser | null; + tenant: AuditLogTenant | null; +} + +export interface Pagination { + page: number; + limit: number; + total: number; + totalPages: number; + hasMore: boolean; +} + +export interface AuditLogsResponse { + success: boolean; + data: AuditLog[]; + pagination: Pagination; +} + +export interface GetAuditLogResponse { + success: boolean; + data: AuditLog; +} diff --git a/src/types/tenant.ts b/src/types/tenant.ts index c5eeb0d..4f1aedd 100644 --- a/src/types/tenant.ts +++ b/src/types/tenant.ts @@ -6,7 +6,7 @@ export interface Tenant { id: string; name: string; slug: string; - status: 'active' | 'suspended' | 'blocked'; + status: 'active' | 'suspended' | 'deleted'; settings: TenantSettings | null; subscription_tier: string | null; max_users: number | null; diff --git a/src/types/user.ts b/src/types/user.ts index a4fc62d..bb243c7 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -3,7 +3,7 @@ export interface User { email: string; first_name: string; last_name: string; - status: 'active' | 'suspended' | 'blocked'; + status: 'active' | 'suspended' | 'deleted'; auth_provider: string; tenant_id?: string; role_id?: string; @@ -30,7 +30,7 @@ export interface CreateUserRequest { password: string; first_name: string; last_name: string; - status: 'active' | 'suspended' | 'blocked'; + status: 'active' | 'suspended' | 'deleted'; auth_provider: 'local'; tenant_id: string; role_id: string; @@ -50,7 +50,7 @@ export interface UpdateUserRequest { email: string; first_name: string; last_name: string; - status: 'active' | 'suspended' | 'blocked'; + status: 'active' | 'suspended' | 'deleted'; auth_provider?: string; tenant_id: string; role_id: string;