From d2dd79013d0efae2f38a4cd5d32b066bcf443b36 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Tue, 20 Jan 2026 18:09:45 +0530 Subject: [PATCH] Update API base URL for local development, enhance role and tenant management modals with improved validation and error handling, and standardize status options across user, tenant, and role types to include 'deleted' instead of 'blocked'. Add audit log functionality with pagination and filtering in the AuditLogs page. --- .env | 3 +- API_ENDPOINTS.txt | 663 ++++++++++++++++++ src/components/shared/EditRoleModal.tsx | 98 ++- src/components/shared/EditTenantModal.tsx | 132 ++-- src/components/shared/EditUserModal.tsx | 56 +- src/components/shared/NewRoleModal.tsx | 29 +- src/components/shared/NewTenantModal.tsx | 131 ++-- src/components/shared/NewUserModal.tsx | 58 +- src/components/shared/ViewAuditLogModal.tsx | 265 +++++++ src/components/shared/ViewModuleModal.tsx | 102 ++- src/components/shared/ViewTenantModal.tsx | 2 +- src/components/shared/ViewUserModal.tsx | 2 +- src/components/shared/index.ts | 1 + .../dashboard/components/RecentActivity.tsx | 246 ++++--- src/pages/AuditLogs.tsx | 382 +++++++++- src/pages/Tenants.tsx | 36 +- src/pages/Users.tsx | 8 +- src/services/audit-log-service.ts | 28 + src/services/tenant-service.ts | 18 +- src/types/audit-log.ts | 53 ++ src/types/tenant.ts | 2 +- src/types/user.ts | 6 +- 22 files changed, 1962 insertions(+), 359 deletions(-) create mode 100644 API_ENDPOINTS.txt create mode 100644 src/components/shared/ViewAuditLogModal.tsx create mode 100644 src/services/audit-log-service.ts create mode 100644 src/types/audit-log.ts 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;