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:
parent
084b09f2d9
commit
d2dd79013d
3
.env
3
.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
|
||||
|
||||
663
API_ENDPOINTS.txt
Normal file
663
API_ENDPOINTS.txt
Normal 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
|
||||
================================================================================
|
||||
@ -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,19 +70,23 @@ 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) {
|
||||
// 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,
|
||||
code: role.code as 'super_admin' | 'tenant_admin' | 'quality_manager' | 'developer' | 'viewer',
|
||||
description: role.description || '',
|
||||
scope: role.scope,
|
||||
});
|
||||
@ -82,10 +97,13 @@ export const EditRoleModal = ({
|
||||
}
|
||||
};
|
||||
loadRole();
|
||||
} else {
|
||||
}
|
||||
} 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>
|
||||
|
||||
|
||||
@ -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">
|
||||
{/* Status */}
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
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}
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
label="Timezone"
|
||||
required
|
||||
placeholder="Select Timezone"
|
||||
options={timezoneOptions}
|
||||
value={timezoneValue}
|
||||
onValueChange={(value) => setValue('timezone', value)}
|
||||
error={errors.timezone?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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> => {
|
||||
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">
|
||||
{/* Status */}
|
||||
<FormSelect
|
||||
label="Status"
|
||||
required
|
||||
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}
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
label="Timezone"
|
||||
required
|
||||
placeholder="Select Timezone"
|
||||
options={timezoneOptions}
|
||||
value={timezoneValue}
|
||||
onValueChange={(value) => setValue('timezone', value)}
|
||||
error={errors.timezone?.message}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@ -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> => {
|
||||
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>
|
||||
|
||||
265
src/components/shared/ViewAuditLogModal.tsx
Normal file
265
src/components/shared/ViewAuditLogModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
@ -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,7 +91,25 @@ export const RecentActivity = () => {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{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)]">
|
||||
@ -107,33 +131,35 @@ export const RecentActivity = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activityData.map((activity, index) => (
|
||||
{auditLogs.map((log) => (
|
||||
<tr
|
||||
key={index}
|
||||
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">
|
||||
<span className={getActionBadgeClass(activity.action)}>
|
||||
{activity.action}
|
||||
</span>
|
||||
<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]">
|
||||
{activity.resourceType}
|
||||
{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">
|
||||
{activity.resourceId}
|
||||
{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]">
|
||||
{activity.ipAddress}
|
||||
{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">
|
||||
{activity.timestamp}
|
||||
{formatRelativeTime(log.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
28
src/services/audit-log-service.ts
Normal file
28
src/services/audit-log-service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@ -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
53
src/types/audit-log.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user