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.

This commit is contained in:
Yashwin 2026-01-20 18:09:45 +05:30
parent 084b09f2d9
commit d2dd79013d
22 changed files with 1962 additions and 359 deletions

3
.env
View File

@ -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

663
API_ENDPOINTS.txt Normal file
View File

@ -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
================================================================================

View File

@ -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<boolean>(false);
const [loadError, setLoadError] = useState<string | null>(null);
const loadedRoleIdRef = useRef<string | null>(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<void> => {
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<void> => {
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 = ({
</div>
)}
{/* General Error Display - Always visible */}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4 mx-5 mt-5">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
</div>
)}
{loadError && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4 mx-5">
<p className="text-sm text-[#ef4444]">{loadError}</p>
</div>
)}
{!isLoadingRole && (
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5 flex flex-col gap-0">
{/* General Error Display */}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
</div>
)}
{/* Role Name and Role Code Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
@ -187,12 +213,14 @@ export const EditRoleModal = ({
{...register('name')}
/>
<FormField
<FormSelect
label="Role Code"
required
placeholder="Enter Text Here"
placeholder="Select Role Code"
options={roleCodeOptions}
value={codeValue}
onValueChange={(value) => setValue('code', value as 'super_admin' | 'tenant_admin' | 'quality_manager' | 'developer' | 'viewer')}
error={errors.code?.message}
{...register('code')}
/>
</div>

View File

@ -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<typeof editTenantSchema>;
@ -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<EditTenantFormData>({
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<void> => {
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 && (
<div className="flex flex-col gap-0">
{/* General Error Display */}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
</div>
)}
{/* Tenant Name */}
<FormField
label="Tenant Name"
@ -167,33 +217,21 @@ export const EditTenantModal = ({
<FormField
label="Slug"
required
placeholder="Enter slug name"
placeholder="Enter slug (lowercase, numbers, hyphens only)"
error={errors.slug?.message}
{...register('slug')}
/>
{/* Status and Timezone Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
<FormSelect
label="Status"
required
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'blocked')}
error={errors.status?.message}
/>
<FormSelect
label="Timezone"
required
placeholder="Select Timezone"
options={timezoneOptions}
value={timezoneValue}
onValueChange={(value) => setValue('timezone', value)}
error={errors.timezone?.message}
/>
</div>
{/* Status */}
<FormSelect
label="Status"
required
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
error={errors.status?.message}
/>
</div>
)}
</form>

View File

@ -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<EditUserFormData>({
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<void> => {
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 && (
<div className="flex flex-col gap-0">
{/* General Error Display */}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
</div>
)}
{/* Email */}
<FormField
label="Email"
@ -295,7 +341,7 @@ export const EditUserModal = ({
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'blocked')}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
error={errors.status?.message}
/>
</div>

View File

@ -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')}
/>
<FormField
<FormSelect
label="Role Code"
required
placeholder="Enter Text Here"
placeholder="Select Role Code"
options={roleCodeOptions}
value={codeValue}
onValueChange={(value) => setValue('code', value as 'super_admin' | 'tenant_admin' | 'quality_manager' | 'developer' | 'viewer')}
error={errors.code?.message}
{...register('code')}
/>
</div>

View File

@ -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<typeof newTenantSchema>;
@ -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<NewTenantFormData>({
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<void> => {
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 = ({
}
>
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
{/* General Error Display */}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
</div>
)}
<div className="flex flex-col gap-0">
{/* Tenant Name */}
<FormField
@ -127,33 +176,21 @@ export const NewTenantModal = ({
<FormField
label="Slug"
required
placeholder="Enter slug name"
placeholder="Enter slug (lowercase, numbers, hyphens only)"
error={errors.slug?.message}
{...register('slug')}
/>
{/* Status and Timezone Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
<FormSelect
label="Status"
required
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'blocked')}
error={errors.status?.message}
/>
<FormSelect
label="Timezone"
required
placeholder="Select Timezone"
options={timezoneOptions}
value={timezoneValue}
onValueChange={(value) => setValue('timezone', value)}
error={errors.timezone?.message}
/>
</div>
{/* Status */}
<FormSelect
label="Status"
required
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
error={errors.status?.message}
/>
</div>
</form>
</Modal>

View File

@ -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<NewUserFormData>({
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<void> => {
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 = ({
}
>
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
{/* General Error Display */}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
</div>
)}
<div className="flex flex-col gap-0">
{/* Email */}
<FormField
@ -235,7 +281,7 @@ export const NewUserModal = ({
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'blocked')}
onValueChange={(value) => setValue('status', value as 'active' | 'suspended' | 'deleted')}
error={errors.status?.message}
/>
</div>

View File

@ -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<string, unknown> | 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<AuditLog>;
}
export const ViewAuditLogModal = ({
isOpen,
onClose,
auditLogId,
onLoadAuditLog,
}: ViewAuditLogModalProps): ReactElement | null => {
const [auditLog, setAuditLog] = useState<AuditLog | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Load audit log data when modal opens
useEffect(() => {
if (isOpen && auditLogId) {
const loadAuditLog = async (): Promise<void> => {
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title="View Audit Log Details"
description="View audit log information"
maxWidth="lg"
footer={
<SecondaryButton type="button" onClick={onClose} className="px-4 py-2.5 text-sm">
Close
</SecondaryButton>
}
>
<div className="p-5">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
</div>
)}
{error && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{error}</p>
</div>
)}
{!isLoading && !error && auditLog && (
<div className="flex flex-col gap-6">
{/* Basic Information */}
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Basic Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Action</label>
<p className="text-sm font-medium text-[#0e1b2a]">{auditLog.action}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Resource Type</label>
<p className="text-sm font-medium text-[#0e1b2a]">{auditLog.resource_type}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Resource ID</label>
<p className="text-sm font-medium text-[#0e1b2a] font-mono">
{auditLog.resource_id || 'N/A'}
</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Request Method</label>
<p className="text-sm font-medium text-[#0e1b2a]">{auditLog.request_method || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Request Path</label>
<p className="text-sm font-medium text-[#0e1b2a] font-mono break-all">
{auditLog.request_path || 'N/A'}
</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Response Status</label>
<p className="text-sm font-medium text-[#0e1b2a]">
{auditLog.response_status || 'N/A'}
</p>
</div>
</div>
</div>
{/* User & Tenant Information */}
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">User & Tenant Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{auditLog.user && (
<>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">User Name</label>
<p className="text-sm font-medium text-[#0e1b2a]">
{auditLog.user.first_name} {auditLog.user.last_name}
</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">User Email</label>
<p className="text-sm font-medium text-[#0e1b2a]">{auditLog.user.email}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">User ID</label>
<p className="text-sm font-medium text-[#0e1b2a] font-mono">{auditLog.user.id}</p>
</div>
</>
)}
{auditLog.tenant && (
<>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Tenant Name</label>
<p className="text-sm font-medium text-[#0e1b2a]">{auditLog.tenant.name}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Tenant ID</label>
<p className="text-sm font-medium text-[#0e1b2a] font-mono">{auditLog.tenant.id}</p>
</div>
</>
)}
{!auditLog.user && !auditLog.tenant && (
<div className="col-span-2">
<p className="text-sm text-[#6b7280]">No user or tenant information available</p>
</div>
)}
</div>
</div>
{/* Request & Response Information */}
{(auditLog.request_body || auditLog.response_body) && (
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Request & Response</h3>
<div className="grid grid-cols-1 gap-4">
{auditLog.request_body && (
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Request Body</label>
<pre className="text-xs font-medium text-[#0e1b2a] bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md p-3 overflow-auto max-h-60">
{formatJSON(auditLog.request_body)}
</pre>
</div>
)}
{auditLog.response_body && (
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Response Body</label>
<pre className="text-xs font-medium text-[#0e1b2a] bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md p-3 overflow-auto max-h-60">
{formatJSON(auditLog.response_body)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Changes & Metadata */}
{(auditLog.changes || auditLog.metadata) && (
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Changes & Metadata</h3>
<div className="grid grid-cols-1 gap-4">
{auditLog.changes && (
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Changes</label>
<pre className="text-xs font-medium text-[#0e1b2a] bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md p-3 overflow-auto max-h-60">
{formatJSON(auditLog.changes)}
</pre>
</div>
)}
{auditLog.metadata && (
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Metadata</label>
<pre className="text-xs font-medium text-[#0e1b2a] bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-md p-3 overflow-auto max-h-60">
{formatJSON(auditLog.metadata)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Additional Information */}
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">IP Address</label>
<p className="text-sm font-medium text-[#0e1b2a]">{auditLog.ip_address || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">User Agent</label>
<p className="text-sm font-medium text-[#0e1b2a] break-all">
{auditLog.user_agent || 'N/A'}
</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Correlation ID</label>
<p className="text-sm font-medium text-[#0e1b2a] font-mono">
{auditLog.correlation_id || 'N/A'}
</p>
</div>
</div>
</div>
{/* Timestamps */}
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Timestamps</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Created At</label>
<p className="text-sm font-medium text-[#0e1b2a]">{formatDate(auditLog.created_at)}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Updated At</label>
<p className="text-sm font-medium text-[#0e1b2a]">{formatDate(auditLog.updated_at)}</p>
</div>
</div>
</div>
</div>
)}
</div>
</Modal>
);
};

View File

@ -102,24 +102,20 @@ export const ViewModuleModal = ({
{!isLoading && !error && module && (
<div className="flex flex-col gap-6">
{/* Basic Information */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Basic Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Module ID</label>
<p className="text-sm text-[#0e1b2a] font-mono">{module.module_id}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Name</label>
<p className="text-sm text-[#0e1b2a]">{module.name}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.name}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Description</label>
<p className="text-sm text-[#0e1b2a]">{module.description}</p>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Module ID</label>
<p className="text-sm font-medium text-[#0e1b2a] font-mono">{module.module_id}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Version</label>
<p className="text-sm text-[#0e1b2a]">{module.version}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.version}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Status</label>
@ -129,74 +125,70 @@ export const ViewModuleModal = ({
</StatusBadge>
</div>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Health Status</label>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(module.health_status)}>
{module.health_status || 'N/A'}
</StatusBadge>
</div>
</div>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Description</label>
<p className="text-sm font-medium text-[#0e1b2a]">{module.description}</p>
</div>
</div>
{/* Technical Details */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Technical Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Runtime Language</label>
<p className="text-sm text-[#0e1b2a]">{module.runtime_language || 'N/A'}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.runtime_language || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Framework</label>
<p className="text-sm text-[#0e1b2a]">{module.framework || 'N/A'}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.framework || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Base URL</label>
<p className="text-sm text-[#0e1b2a] font-mono break-all">{module.base_url}</p>
<p className="text-sm font-medium text-[#0e1b2a] font-mono break-all">{module.base_url}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Health Endpoint</label>
<p className="text-sm text-[#0e1b2a]">{module.health_endpoint}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.health_endpoint}</p>
</div>
</div>
</div>
{/* Resource Limits */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Resource Limits</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">CPU Request</label>
<p className="text-sm text-[#0e1b2a]">{module.cpu_request}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.cpu_request}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">CPU Limit</label>
<p className="text-sm text-[#0e1b2a]">{module.cpu_limit}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.cpu_limit}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Memory Request</label>
<p className="text-sm text-[#0e1b2a]">{module.memory_request}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.memory_request}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Memory Limit</label>
<p className="text-sm text-[#0e1b2a]">{module.memory_limit}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.memory_limit}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Min Replicas</label>
<p className="text-sm text-[#0e1b2a]">{module.min_replicas}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.min_replicas}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Max Replicas</label>
<p className="text-sm text-[#0e1b2a]">{module.max_replicas}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.max_replicas}</p>
</div>
</div>
</div>
{/* Additional Information */}
{(module.endpoints || module.kafka_topics || module.consecutive_failures !== null) && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Additional Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{module.endpoints && module.endpoints.length > 0 && (
@ -204,7 +196,7 @@ export const ViewModuleModal = ({
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Endpoints</label>
<div className="flex flex-col gap-1">
{module.endpoints.map((endpoint, index) => (
<p key={index} className="text-sm text-[#0e1b2a] font-mono">
<p key={index} className="text-sm font-medium text-[#0e1b2a] font-mono">
{endpoint}
</p>
))}
@ -216,7 +208,7 @@ export const ViewModuleModal = ({
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Kafka Topics</label>
<div className="flex flex-col gap-1">
{module.kafka_topics.map((topic, index) => (
<p key={index} className="text-sm text-[#0e1b2a] font-mono">
<p key={index} className="text-sm font-medium text-[#0e1b2a] font-mono">
{topic}
</p>
))}
@ -226,43 +218,43 @@ export const ViewModuleModal = ({
{module.consecutive_failures !== null && (
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Consecutive Failures</label>
<p className="text-sm text-[#0e1b2a]">{module.consecutive_failures}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.consecutive_failures}</p>
</div>
)}
</div>
</div>
)}
{/* Health */}
<div className="flex flex-col gap-4 pb-6 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Health</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Health Status</label>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(module.health_status)}>
{module.health_status || 'N/A'}
</StatusBadge>
</div>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Last Health Check</label>
<p className="text-sm font-medium text-[#0e1b2a]">{formatDate(module.last_health_check)}</p>
</div>
</div>
</div>
{/* Registration Information */}
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Registration Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Registered By</label>
<p className="text-sm text-[#0e1b2a]">{module.registered_by_email || module.registered_by}</p>
<p className="text-sm font-medium text-[#0e1b2a]">{module.registered_by_email || module.registered_by}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Tenant ID</label>
<p className="text-sm text-[#0e1b2a] font-mono">{module.tenant_id}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Last Health Check</label>
<p className="text-sm text-[#0e1b2a]">{formatDate(module.last_health_check)}</p>
</div>
</div>
</div>
{/* Timestamps */}
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-[#0e1b2a]">Timestamps</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Created At</label>
<p className="text-sm text-[#0e1b2a]">{formatDate(module.created_at)}</p>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Updated At</label>
<p className="text-sm text-[#0e1b2a]">{formatDate(module.updated_at)}</p>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">Registered At</label>
<p className="text-sm font-medium text-[#0e1b2a]">{formatDate(module.created_at)}</p>
</div>
</div>
</div>

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchRecentActivity = async (): Promise<void> => {
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 (
<Card className="flex-1 border-[rgba(0,0,0,0.2)] w-full">
<CardHeader className="border-b border-[rgba(0,0,0,0.08)] pb-3 md:pb-4 pt-3 md:pt-4 px-4 md:px-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-2">
@ -85,55 +91,75 @@ export const RecentActivity = () => {
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[600px]">
<thead>
<tr className="bg-white border-b border-[rgba(0,0,0,0.08)]">
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
Action
</th>
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
Resource Type
</th>
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
Resource ID
</th>
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
IP Address
</th>
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
Timestamp
</th>
</tr>
</thead>
<tbody>
{activityData.map((activity, index) => (
<tr
key={index}
className="border-b border-[rgba(0,0,0,0.08)] last:border-b-0"
>
<td className="px-3 md:px-5 py-2.5 md:py-3.5">
<span className={getActionBadgeClass(activity.action)}>
{activity.action}
</span>
</td>
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-medium text-[#0f1724]">
{activity.resourceType}
</td>
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-normal text-[#0f1724] whitespace-nowrap">
{activity.resourceId}
</td>
<td className="px-3 md:px-5 py-2 md:py-4 text-xs md:text-[13px] font-normal text-[#6b7280]">
{activity.ipAddress}
</td>
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-normal text-[#6b7280] whitespace-nowrap">
{activity.timestamp}
</td>
</tr>
))}
</tbody>
</table>
</div>
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 text-[#112868] animate-spin" />
</div>
)}
{error && (
<div className="p-4 text-center">
<p className="text-xs text-[#ef4444]">{error}</p>
</div>
)}
{!isLoading && !error && (
<div className="overflow-x-auto">
{auditLogs.length === 0 ? (
<div className="p-4 text-center">
<p className="text-xs text-[#6b7280]">No recent activity found</p>
</div>
) : (
<table className="w-full border-collapse min-w-[600px]">
<thead>
<tr className="bg-white border-b border-[rgba(0,0,0,0.08)]">
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
Action
</th>
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
Resource Type
</th>
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
Resource ID
</th>
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
IP Address
</th>
<th className="px-3 md:px-5 py-2 md:py-3 text-left text-xs md:text-[13px] font-medium text-[#6b7280]">
Timestamp
</th>
</tr>
</thead>
<tbody>
{auditLogs.map((log) => (
<tr
key={log.id}
className="border-b border-[rgba(0,0,0,0.08)] last:border-b-0"
>
<td className="px-3 md:px-5 py-2.5 md:py-3.5">
<StatusBadge variant={getActionVariant(log.action)}>
{log.action}
</StatusBadge>
</td>
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-medium text-[#0f1724]">
{log.resource_type}
</td>
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-normal text-[#0f1724] whitespace-nowrap">
{log.resource_id || 'N/A'}
</td>
<td className="px-3 md:px-5 py-2 md:py-4 text-xs md:text-[13px] font-normal text-[#6b7280]">
{log.ip_address || 'N/A'}
</td>
<td className="px-3 md:px-5 py-2.5 md:py-3.5 text-xs md:text-[13px] font-normal text-[#6b7280] whitespace-nowrap">
{formatRelativeTime(log.created_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</CardContent>
</Card>
);

View File

@ -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<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(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<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null);
// View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [selectedAuditLogId, setSelectedAuditLogId] = useState<string | null>(null);
const fetchAuditLogs = async (
page: number,
itemsPerPage: number,
method: string | null = null,
sortBy: string[] | null = null
): Promise<void> => {
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<AuditLog> => {
const response = await auditLogService.getById(id);
return response.data;
};
// Define table columns
const columns: Column<AuditLog>[] = [
{
key: 'created_at',
label: 'Timestamp',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724]">{formatDate(log.created_at)}</span>
),
mobileLabel: 'Time',
},
{
key: 'resource_type',
label: 'Resource Type',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724]">{log.resource_type}</span>
),
},
{
key: 'action',
label: 'Action',
render: (log) => (
<StatusBadge variant={getActionVariant(log.action)}>
{log.action}
</StatusBadge>
),
},
{
key: 'resource_id',
label: 'Resource ID',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724] font-mono truncate max-w-[150px]">
{log.resource_id || 'N/A'}
</span>
),
},
{
key: 'user',
label: 'User',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724]">
{log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'}
</span>
),
},
{
key: 'request_method',
label: 'Method',
render: (log) => (
<StatusBadge variant={getMethodVariant(log.request_method)}>
{log.request_method ? log.request_method.toUpperCase() : 'N/A'}
</StatusBadge>
),
},
{
key: 'response_status',
label: 'Status',
render: (log) => (
<span className={`text-sm font-normal ${getStatusColor(log.response_status)}`}>
{log.response_status || 'N/A'}
</span>
),
},
{
key: 'ip_address',
label: 'IP Address',
render: (log) => (
<span className="text-sm font-normal text-[#0f1724] font-mono">
{log.ip_address || 'N/A'}
</span>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (log) => (
<div className="flex justify-end">
<button
type="button"
onClick={() => handleViewAuditLog(log.id)}
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
>
View
</button>
</div>
),
},
];
// Mobile card renderer
const mobileCardRenderer = (log: AuditLog) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">{log.resource_type}</h3>
<div className="mt-1">
<StatusBadge variant={getActionVariant(log.action)}>
{log.action}
</StatusBadge>
</div>
</div>
<button
type="button"
onClick={() => handleViewAuditLog(log.id)}
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors shrink-0"
>
View
</button>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Timestamp:</span>
<p className="text-[#0f1724] font-normal mt-1">{formatDate(log.created_at)}</p>
</div>
<div>
<span className="text-[#9aa6b2]">User:</span>
<p className="text-[#0f1724] font-normal mt-1">
{log.user ? `${log.user.first_name} ${log.user.last_name}` : 'N/A'}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Method:</span>
<div className="mt-1">
<StatusBadge variant={getMethodVariant(log.request_method)}>
{log.request_method ? log.request_method.toUpperCase() : 'N/A'}
</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Status:</span>
<p className={`font-normal mt-1 ${getStatusColor(log.response_status)}`}>
{log.response_status || 'N/A'}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Resource ID:</span>
<p className="text-[#0f1724] font-normal mt-1 font-mono truncate">
{log.resource_id || 'N/A'}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">IP Address:</span>
<p className="text-[#0f1724] font-normal mt-1 font-mono">{log.ip_address || 'N/A'}</p>
</div>
</div>
</div>
);
return (
<Layout
currentPage="Audit Logs"
@ -10,9 +281,106 @@ const AuditLogs = (): ReactElement => {
description: 'View and manage all audit logs in the QAssure platform.',
}}
>
<div>Audit Logs</div>
</Layout>
)
}
{/* Table Container */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Table Header with Filters */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Method Filter */}
<FilterDropdown
label="Method"
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'PATCH', label: 'PATCH' },
]}
value={methodFilter}
onChange={(value) => {
setMethodFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All"
/>
export default AuditLogs
{/* Sort Filter */}
<FilterDropdown
label="Sort by"
options={[
{ value: ['created_at', 'desc'], label: 'Newest First' },
{ value: ['created_at', 'asc'], label: 'Oldest First' },
{ value: ['action', 'asc'], label: 'Action (A-Z)' },
{ value: ['action', 'desc'], label: 'Action (Z-A)' },
{ value: ['resource_type', 'asc'], label: 'Resource Type (A-Z)' },
{ value: ['resource_type', 'desc'], label: 'Resource Type (Z-A)' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Default"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button>
</div>
</div>
{/* Table */}
<DataTable
columns={columns}
data={auditLogs}
keyExtractor={(log) => log.id}
isLoading={isLoading}
error={error}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No audit logs found"
/>
{/* Pagination */}
{pagination.total > 0 && (
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => setCurrentPage(page)}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1);
}}
/>
</div>
)}
</div>
{/* View Audit Log Modal */}
<ViewAuditLogModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedAuditLogId(null);
}}
auditLogId={selectedAuditLogId}
onLoadAuditLog={loadAuditLog}
/>
</Layout>
);
};
export default AuditLogs;

View File

@ -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<string, unknown> | null;
subscription_tier?: string | null;
max_users?: number | null;
max_modules?: number | null;
}): Promise<void> => {
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<string, unknown> | null;
subscription_tier?: string | null;
max_users?: number | null;
max_modules?: number | null;
}
): Promise<void> => {
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) => {

View File

@ -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) => {

View File

@ -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<AuditLogsResponse> => {
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<AuditLogsResponse>(`/audit-logs?${params.toString()}`);
return response.data;
},
getById: async (id: string): Promise<GetAuditLogResponse> => {
const response = await apiClient.get<GetAuditLogResponse>(`/audit-logs/${id}`);
return response.data;
},
};

View File

@ -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<string, unknown> | 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<string, unknown> | null;
subscription_tier?: string | null;
max_users?: number | null;
max_modules?: number | null;
}
export interface UpdateTenantResponse {

53
src/types/audit-log.ts Normal file
View File

@ -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<string, unknown> | null;
response_status: number | null;
response_body: Record<string, unknown> | null;
ip_address: string | null;
user_agent: string | null;
correlation_id: string | null;
changes: Record<string, unknown> | null;
metadata: Record<string, unknown> | 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;
}

View File

@ -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;

View File

@ -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;