frontend_nyla/src/hooks/useAdminRbacRules.ts
ValueOn AG 77e7eba711 BREAKING CHANGE
API and persisted records use PowerOnModel system fields:
- sysCreatedAt, sysCreatedBy, sysModifiedAt, sysModifiedBy
Removed legacy JSON/DB field names:
- _createdAt, _createdBy, _modifiedAt, _modifiedBy
Frontend (frontend_nyla) and gateway call sites were updated accordingly.
Database:
- Bootstrap runs idempotent backfill (_migrateSystemFieldColumns) from old
  underscore columns and selected business duplicates into sys* where sys* IS NULL.
- Re-run app bootstrap against each PostgreSQL database after deploy.
- Optional: DROP INDEX IF EXISTS "idx_invitation_createdby" if an old index remains;
  new index: idx_invitation_syscreatedby on Invitation(sysCreatedBy).
Tests:
- RBAC integration tests aligned with current GROUP mandate filter and UserMandate-based
  UserConnection GROUP clause; buildRbacWhereClause(..., mandateId=...) must be passed
  explicitly (same as production request context).
2026-03-28 18:13:18 +01:00

590 lines
20 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import { getUserDataCache } from '../utils/userCache';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchRbacRules as fetchRbacRulesApi,
fetchRbacRuleById as fetchRbacRuleByIdApi,
createRbacRule as createRbacRuleApi,
updateRbacRule as updateRbacRuleApi,
deleteRbacRule as deleteRbacRuleApi,
type RbacRule,
type RbacRuleUpdateData,
type PaginationParams
} from '../api/rbacRulesApi';
import { fetchAttributes } from '../api/attributesApi';
import type { AttributeDefinition } from '../api/attributesApi';
import {
isCheckboxType,
isSelectType,
isMultiselectType,
isDateTimeType,
isTextareaType,
type AttributeType
} from '../utils/attributeTypeMapper';
// Re-export types for backward compatibility
export type { RbacRule, RbacRuleUpdateData, AttributeDefinition, PaginationParams };
// RBAC rules hook (list, update, delete) - following mandates pattern
export function useRbacRules() {
const [rbacRules, setRbacRules] = useState<RbacRule[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, RbacRule[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributesData = useCallback(async () => {
try {
const attrs = await fetchAttributes(request, 'AccessRule');
setAttributes(attrs);
return attrs;
} catch (error: any) {
// Don't log 429 errors as errors (they're rate limit warnings)
if (error.response?.status === 429) {
console.warn('Rate limit exceeded while fetching RBAC rule attributes. Please wait.');
} else if (error.response?.status !== 401) {
// Only log non-auth errors (401 is expected when not logged in)
console.error('Error fetching attributes:', error);
}
setAttributes([]);
return [];
}
}, [request]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'RbacRule');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchRbacRules = useCallback(async (params?: PaginationParams) => {
try {
const requestParams: any = {};
// Build pagination object if provided
if (params) {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
const data = await fetchRbacRulesApi(request, params);
// Debug logging for pagination
if (import.meta.env.DEV) {
console.log('📊 useRbacRules: Backend response:', {
hasItems: data && typeof data === 'object' && 'items' in data,
itemsCount: data && typeof data === 'object' && 'items' in data ? (data.items as any[]).length : 'N/A',
pagination: data && typeof data === 'object' && 'pagination' in data ? data.pagination : 'N/A',
fullData: data
});
}
// Handle paginated response
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setRbacRules(items);
if (data.pagination) {
if (import.meta.env.DEV) {
console.log('📊 useRbacRules: Setting pagination:', data.pagination);
}
setPagination(data.pagination);
} else {
if (import.meta.env.DEV) {
console.warn('⚠️ useRbacRules: No pagination object in response');
}
}
} else {
// Handle non-paginated response (backward compatibility)
const items = Array.isArray(data) ? data : [];
setRbacRules(items);
setPagination(null);
}
} catch (error: any) {
// Error is already handled by useApiRequest
setRbacRules([]);
setPagination(null);
}
}, [request]);
// Optimistically remove a RBAC rule from the local state
const removeOptimistically = (ruleId: string) => {
setRbacRules(prevRules => prevRules.filter(rule => rule.id !== ruleId));
};
// Optimistically update a RBAC rule in the local state
const updateOptimistically = (ruleId: string, updateData: Partial<RbacRule>) => {
setRbacRules(prevRules =>
prevRules.map(rule =>
rule.id === ruleId
? { ...rule, ...updateData }
: rule
)
);
};
// Fetch a single RBAC rule by ID
const fetchRbacRuleById = useCallback(async (ruleId: string): Promise<RbacRule | null> => {
return await fetchRbacRuleByIdApi(request, ruleId);
}, [request]);
// Generate edit fields from attributes dynamically using attributeTypeMapper utilities
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
validator?: (value: any) => string | null;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string; // For options that need to be fetched (e.g., "user.role")
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
// Filter out non-editable fields based on readonly/editable flags
if (attr.readonly === true || attr.editable === false) {
return false; // Don't show readonly fields in edit form
}
// Also filter out common non-editable fields
const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
// Map backend attribute type to form field type using attributeTypeMapper utilities
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
const attrType = attr.type as AttributeType;
// Use attributeTypeMapper utilities to determine field type
if (isCheckboxType(attrType)) {
fieldType = 'boolean';
} else if (attrType === 'email') {
fieldType = 'email';
} else if (isDateTimeType(attrType)) {
fieldType = 'date';
} else if (isSelectType(attrType)) {
fieldType = 'enum';
// Handle options - can be array or string reference
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attrOptions === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attrOptions;
}
} else if (isMultiselectType(attrType)) {
fieldType = 'multiselect';
// Handle options - can be array or string reference
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attrOptions === 'string') {
// Options reference (e.g., "user.role", "auth.authority")
optionsReference = attrOptions;
}
} else if (isTextareaType(attrType)) {
fieldType = 'textarea';
} else if (attrType === 'readonly') {
fieldType = 'readonly';
} else {
fieldType = 'string';
}
// Define validators and required fields
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
let minRows: number | undefined = undefined;
let maxRows: number | undefined = undefined;
// Email validation
if (fieldType === 'email') {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty';
}
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format';
}
return null;
};
}
// Textarea settings
else if (fieldType === 'textarea') {
minRows = 4;
maxRows = 8;
if (attr.name.toLowerCase().includes('content')) {
minRows = 6;
maxRows = 12;
}
}
// Multiselect validation
else if (fieldType === 'multiselect' && required) {
validator = (value: any[]) => {
if (!value || !Array.isArray(value) || value.length === 0) {
return `${attr.label} is required`;
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: (attr as any).editable !== false && (attr as any).readonly !== true,
required,
validator,
minRows,
maxRows,
options,
optionsReference
};
});
return editableFields;
}, [attributes]);
// Generate create fields from attributes dynamically using attributeTypeMapper utilities
const generateCreateFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
required?: boolean;
validator?: (value: any) => string | null;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
optionsReference?: string;
placeholder?: string;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const createFields = attributes
.filter(attr => {
// Filter out non-editable fields and auto-generated fields for create forms
if (attr.readonly === true || attr.editable === false) {
return false;
}
// Filter out ID fields and other auto-generated fields
const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
// Map backend attribute type to form field type using attributeTypeMapper utilities
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined = undefined;
let optionsReference: string | undefined = undefined;
const attrType = attr.type as AttributeType;
// Use attributeTypeMapper utilities to determine field type
if (isCheckboxType(attrType)) {
fieldType = 'boolean';
} else if (attrType === 'email') {
fieldType = 'email';
} else if (isDateTimeType(attrType)) {
fieldType = 'date';
} else if (isSelectType(attrType)) {
fieldType = 'enum';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}
} else if (isMultiselectType(attrType)) {
fieldType = 'multiselect';
const attrOptions = (attr as any).options;
if (Array.isArray(attrOptions)) {
options = attrOptions.map((opt: any) => {
const labelValue = typeof opt.label === 'string'
? opt.label
: opt.label?.en || opt.label?.[Object.keys(opt.label || {})[0]] || String(opt.value);
return {
value: opt.value,
label: labelValue
};
});
} else if (typeof attrOptions === 'string') {
optionsReference = attrOptions;
}
} else if (isTextareaType(attrType)) {
fieldType = 'textarea';
} else {
fieldType = 'string';
}
// Define validators and required fields
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
let minRows: number | undefined = undefined;
let maxRows: number | undefined = undefined;
// Email validation
if (fieldType === 'email') {
validator = (value: any) => {
if (required && (!value || (typeof value === 'string' && value.trim() === ''))) {
return 'Email cannot be empty';
}
if (value && typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Invalid email format';
}
return null;
};
}
// Textarea settings
else if (fieldType === 'textarea') {
minRows = 4;
maxRows = 8;
if (attr.name.toLowerCase().includes('content')) {
minRows = 6;
maxRows = 12;
}
}
// Multiselect validation
else if (fieldType === 'multiselect' && required) {
validator = (value: any[]) => {
if (!value || !Array.isArray(value) || value.length === 0) {
return `${attr.label} is required`;
}
return null;
};
}
// String validation for required fields
else if (fieldType === 'string' && required) {
validator = (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return `${attr.label} is required`;
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
required,
validator,
minRows,
maxRows,
options,
optionsReference,
placeholder: (attr as any).placeholder
};
});
return createFields;
}, [attributes]);
// Ensure attributes are loaded - can be called by EditActionButton
const ensureAttributesLoaded = useCallback(async () => {
// Don't fetch attributes if user is not authenticated (prevents 401 errors)
const currentUser = getUserDataCache();
if (!currentUser) {
return [];
}
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributesData();
return fetchedAttributes;
}, [attributes, fetchAttributesData]);
// Fetch attributes and permissions on mount (only if user is authenticated)
useEffect(() => {
const currentUser = getUserDataCache();
if (currentUser) {
fetchAttributesData();
fetchPermissions();
}
}, [fetchAttributesData, fetchPermissions]);
// Initial fetch
useEffect(() => {
fetchRbacRules();
}, [fetchRbacRules]);
return {
data: rbacRules,
loading,
error,
refetch: fetchRbacRules,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchRbacRuleById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
}
// RBAC rule operations hook
export function useRbacRuleOperations() {
const [deletingRbacRules, setDeletingRbacRules] = useState<Set<string>>(new Set());
const [editingRbacRules, setEditingRbacRules] = useState<Set<string>>(new Set());
const [creatingRbacRule, setCreatingRbacRule] = useState(false);
const { request, isLoading } = useApiRequest();
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const handleRbacRuleDelete = async (ruleId: string) => {
setDeleteError(null);
setDeletingRbacRules(prev => new Set(prev).add(ruleId));
try {
await deleteRbacRuleApi(request, ruleId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
setDeleteError(error.message);
return false;
} finally {
setDeletingRbacRules(prev => {
const newSet = new Set(prev);
newSet.delete(ruleId);
return newSet;
});
}
};
const handleRbacRuleCreate = async (ruleData: Partial<RbacRule>) => {
setCreateError(null);
setCreatingRbacRule(true);
try {
const newRule = await createRbacRuleApi(request, ruleData);
return { success: true, ruleData: newRule };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingRbacRule(false);
}
};
const handleRbacRuleUpdate = async (ruleId: string, updateData: RbacRuleUpdateData, _originalData?: any) => {
setUpdateError(null);
setEditingRbacRules(prev => new Set(prev).add(ruleId));
try {
const updatedRule = await updateRbacRuleApi(request, ruleId, updateData);
return { success: true, ruleData: updatedRule };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update RBAC rule';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
} finally {
setEditingRbacRules(prev => {
const newSet = new Set(prev);
newSet.delete(ruleId);
return newSet;
});
}
};
// Generic inline update handler for FormGeneratorTable
const handleInlineUpdate = async (ruleId: string, changes: Partial<RbacRuleUpdateData>) => {
const result = await handleRbacRuleUpdate(ruleId, changes as RbacRuleUpdateData);
if (!result.success) {
throw new Error(result.error || 'Failed to update');
}
return result;
};
return {
deletingRbacRules,
editingRbacRules,
creatingRbacRule,
deleteError,
createError,
updateError,
handleRbacRuleDelete,
handleRbacRuleCreate,
handleRbacRuleUpdate,
handleInlineUpdate,
isLoading
};
}