frontend_nyla/src/hooks/useTrusteeRoles.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

399 lines
12 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import { useInstanceId } from './useCurrentInstance';
import {
fetchRoles as fetchRolesApi,
fetchRoleById as fetchRoleByIdApi,
createRole as createRoleApi,
updateRole as updateRoleApi,
deleteRole as deleteRoleApi,
type TrusteeRole,
type PaginationParams
} from '../api/trusteeApi';
export interface AttributeDefinition {
name: string;
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea' | 'timestamp' | 'file';
label: string;
description?: string;
required?: boolean;
default?: any;
options?: any[] | string;
readonly?: boolean;
editable?: boolean;
visible?: boolean;
order?: number;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
dependsOn?: string;
}
// Re-export types
export type { TrusteeRole, PaginationParams };
// Roles list hook
export function useTrusteeRoles() {
const instanceId = useInstanceId();
const [roles, setRoles] = useState<TrusteeRole[]>([]);
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, TrusteeRole[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
if (!instanceId) return [];
try {
const response = await api.get(`/api/trustee/${instanceId}/attributes/TrusteeRole`);
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching attributes:', error);
setAttributes([]);
return [];
}
}, [instanceId]);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const objectKey = 'data.feature.trustee.TrusteeRole';
const perms = await checkPermission('DATA', objectKey);
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 fetchRoles = useCallback(async (params?: PaginationParams) => {
if (!instanceId) {
setRoles([]);
return;
}
try {
const data = await fetchRolesApi(request, instanceId, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setRoles(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setRoles(items);
setPagination(null);
}
} catch (error: any) {
setRoles([]);
setPagination(null);
}
}, [request, instanceId]);
// Optimistically remove a role
const removeOptimistically = (roleId: string) => {
setRoles(prevRoles => prevRoles.filter(role => role.id !== roleId));
};
// Optimistically update a role
const updateOptimistically = (roleId: string, updateData: Partial<TrusteeRole>) => {
setRoles(prevRoles =>
prevRoles.map(role =>
role.id === roleId
? { ...role, ...updateData }
: role
)
);
};
// Fetch a single role by ID
const fetchRoleById = useCallback(async (roleId: string): Promise<TrusteeRole | null> => {
if (!instanceId) return null;
return await fetchRoleByIdApi(request, instanceId, roleId);
}, [request, instanceId]);
// Generate edit fields from attributes dynamically
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;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
const editableFields = attributes
.filter(attr => {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
const isDescField = attr.name === 'desc' || attr.name.toLowerCase().includes('description');
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;
let minRows: number | undefined = undefined;
let maxRows: number | undefined = undefined;
if (isDescField) {
fieldType = 'textarea';
minRows = 3;
maxRows = 8;
} else if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'email') {
fieldType = 'email';
} else if (attr.type === 'date') {
fieldType = 'date';
} else if (attr.type === 'select') {
fieldType = 'enum';
if (Array.isArray(attr.options)) {
options = attr.options.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 attr.options === 'string') {
optionsReference = attr.options;
}
} else if (attr.type === 'textarea') {
fieldType = 'textarea';
minRows = minRows || 3;
maxRows = maxRows || 8;
}
let required = attr.required === true;
let validator: ((value: any) => string | null) | undefined = undefined;
if (attr.name === 'id') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
return 'Role ID cannot be empty';
}
if (!/^[a-zA-Z0-9_-]{3,50}$/.test(value)) {
return 'ID must be 3-50 characters long (alphanumeric, dash, underscore only)';
}
return null;
};
} else if (attr.name === 'desc') {
required = true;
validator = (value: string) => {
if (!value || value.trim() === '') {
return 'Description cannot be empty';
}
return null;
};
}
return {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false && attr.readonly !== true,
required,
validator,
minRows,
maxRows,
options,
optionsReference
};
});
return editableFields;
}, [attributes]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes && attributes.length > 0) {
return attributes;
}
const fetchedAttributes = await fetchAttributes();
return fetchedAttributes;
}, [attributes, fetchAttributes]);
// Fetch data when instanceId is available
useEffect(() => {
if (instanceId) {
fetchAttributes();
fetchPermissions();
fetchRoles();
}
}, [instanceId, fetchAttributes, fetchPermissions, fetchRoles]);
return {
roles,
loading,
error,
refetch: fetchRoles,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchRoleById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded,
instanceId
};
}
// Role operations hook
export function useTrusteeRoleOperations() {
const instanceId = useInstanceId();
const [deletingRoles, setDeletingRoles] = useState<Set<string>>(new Set());
const [creatingRole, setCreatingRole] = 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 handleRoleDelete = async (roleId: string) => {
if (!instanceId) {
setDeleteError('No instance context');
return false;
}
setDeleteError(null);
setDeletingRoles(prev => new Set(prev).add(roleId));
try {
await deleteRoleApi(request, instanceId, roleId);
await new Promise(resolve => setTimeout(resolve, 300));
return true;
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.message || 'Failed to delete role';
// Backend returns error if role is in use
setDeleteError(errorMessage);
return false;
} finally {
setDeletingRoles(prev => {
const newSet = new Set(prev);
newSet.delete(roleId);
return newSet;
});
}
};
const handleRoleCreate = async (roleData: Partial<TrusteeRole>) => {
if (!instanceId) {
setCreateError('No instance context');
return { success: false, error: 'No instance context' };
}
setCreateError(null);
setCreatingRole(true);
try {
const newRole = await createRoleApi(request, instanceId, roleData);
return { success: true, roleData: newRole };
} catch (error: any) {
setCreateError(error.message);
return { success: false, error: error.message };
} finally {
setCreatingRole(false);
}
};
const handleRoleUpdate = async (
roleId: string,
updateData: Partial<TrusteeRole>,
_originalData?: any
) => {
if (!instanceId) {
setUpdateError('No instance context');
return { success: false, error: 'No instance context' };
}
setUpdateError(null);
try {
const updatedRole = await updateRoleApi(request, instanceId, roleId, updateData);
return { success: true, roleData: updatedRole };
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'Failed to update role';
const statusCode = error.response?.status;
setUpdateError(errorMessage);
return {
success: false,
error: errorMessage,
statusCode,
isPermissionError: statusCode === 403,
isValidationError: statusCode === 400
};
}
};
return {
deletingRoles,
creatingRole,
deleteError,
createError,
updateError,
handleRoleDelete,
handleRoleCreate,
handleRoleUpdate,
isLoading,
instanceId
};
}