diff --git a/app/api/axios-client.ts b/app/api/axios-client.ts index 30f5ba2..c917180 100644 --- a/app/api/axios-client.ts +++ b/app/api/axios-client.ts @@ -80,6 +80,12 @@ function isInErrorTolerantWhitelist(url?: string): boolean { */ axiosInstance.interceptors.request.use( (config) => { + // ⭐ 检测 FormData,删除默认的 Content-Type,让 axios 自动处理 + if (config.data instanceof FormData) { + console.log('📦 [Request Interceptor] 检测到FormData,删除Content-Type让axios自动处理'); + delete config.headers['Content-Type']; + } + // 检查是否在白名单中 if (isInAuthWhitelist(config.url)) { console.log('🔓 [Request Interceptor] URL在白名单中,跳过Authorization:', config.url); diff --git a/app/api/entry-modules/entry-modules.ts b/app/api/entry-modules/entry-modules.ts index e729d8d..976905e 100644 --- a/app/api/entry-modules/entry-modules.ts +++ b/app/api/entry-modules/entry-modules.ts @@ -71,7 +71,9 @@ interface ListResponse { * @param searchParams 搜索参数 * @returns 入口模块列表和总数 * - * 注意:不需要传递 JWT,axios-client 会自动从 localStorage 读取并添加 + * 注意: + * 1. 不需要传递 JWT,axios-client 会自动从 localStorage 读取并添加 + * 2. 后端返回的 data.items 会被转换为 data.modules(语义化重命名) */ export async function getEntryModules( searchParams: EntryModuleSearchParams = {} @@ -254,6 +256,27 @@ export async function createEntryModule( * @param module 更新的入口模块数据 * @returns 更新的入口模块 * + * ⚠️ 重要:areas 字段是完全替换,不是增量更新! + * 如果只想修改某个地区的状态,需要: + * 1. 先调用 getEntryModuleById 获取完整的 areas 数组 + * 2. 在前端修改 areas 数组中的目标项 + * 3. 将完整的 areas 数组传给此接口 + * + * 示例: + * ```typescript + * // 错误做法:只传要修改的地区(会覆盖其他地区配置) + * await updateEntryModule(1, { + * areas: [{ area: "梅州", enabled: false, sort_order: 1 }] + * }); + * + * // 正确做法:先获取完整数据,修改后整体更新 + * const { data: module } = await getEntryModuleById(1); + * const updatedAreas = module!.areas!.map(config => + * config.area === "梅州" ? { ...config, enabled: false } : config + * ); + * await updateEntryModule(1, { areas: updatedAreas }); + * ``` + * * 注意:不需要传递 JWT,axios-client 会自动从 localStorage 读取并添加 */ export async function updateEntryModule( @@ -324,6 +347,11 @@ export async function updateEntryModule( * @param id 入口模块ID * @returns 是否成功 * + * ⚠️ 警告: + * 1. 删除操作是物理删除,数据库记录直接删除,不可恢复 + * 2. MinIO中的图标文件不会自动删除,需要手动清理或通过定时任务清理孤立文件 + * 3. 如果其他表有外键引用,可能会触发级联删除或报错(取决于外键约束设置) + * * 注意:不需要传递 JWT,axios-client 会自动从 localStorage 读取并添加 */ export async function deleteEntryModule( @@ -331,7 +359,7 @@ export async function deleteEntryModule( ): Promise<{ success: boolean; error?: string }> { try { // 调用 API - const response = await del>( + const response = await del>( `/api/v3/entry-modules/${id}` ); @@ -355,3 +383,89 @@ export async function deleteEntryModule( }; } } + +/** + * 图片上传响应数据 + */ +export interface ImageUploadResponse { + module_id: number; + path: string; + url: string; + message: string; +} + +/** + * 上传入口模块图标 + * @param id 入口模块ID + * @param file 图片文件 + * @returns 上传结果(包含图片路径和访问URL) + * + * ⚠️ 重要注意事项: + * 1. FormData字段名**必须**是 "file",不能是 "image" 或 "picture" + * 2. **不要**手动设置 Content-Type,axios会自动处理multipart/form-data的boundary + * 3. 上传成功后,后端会自动更新数据库中的 path 字段,前端无需手动调用更新接口 + * 4. 支持的文件格式:.jpg, .jpeg, .png, .gif, .webp, .svg + * 5. 每次上传会覆盖该模块的旧图标(相同格式直接覆盖,不同格式会删除旧文件) + * 6. 不需要传递 JWT,axios-client 会自动从 localStorage 读取并添加 + * + * 常见错误: + * - "Field required - body.file" → 检查FormData字段名是否为 "file" + * - 上传失败但无明确错误 → 检查是否手动设置了Content-Type(应该让axios自动处理) + * - 图片无法访问(401) → 旧版本问题,现已修复,MinIO路径已加入JWT白名单 + * + * 使用示例: + * ```typescript + * const formData = new FormData(); + * formData.append('file', imageFile); // ✅ 字段名必须是 "file" + * const result = await uploadEntryModuleImage(moduleId, imageFile); + * console.log('图片URL:', result.data?.url); + * ``` + */ +export async function uploadEntryModuleImage( + id: number, + file: File +): Promise<{ data?: ImageUploadResponse; error?: string }> { + try { + // 构建 FormData + const formData = new FormData(); + formData.append('file', file); + + console.log('📤 [uploadEntryModuleImage] 开始上传:', { + moduleId: id, + fileName: file.name, + fileSize: file.size, + fileType: file.type + }); + + // 调用 API + // 重要:不要手动设置 Content-Type,让 axios 自动处理 + // axios 会自动将 FormData 的 Content-Type 设置为 multipart/form-data 并添加 boundary + const response = await post>( + `/api/v3/entry-modules/${id}/image`, + formData + ); + + if (response.error) { + console.error('❌ [uploadEntryModuleImage] API错误:', response.error); + return { error: response.error }; + } + + const backendResponse = response.data; + if (!backendResponse || backendResponse.code !== 0) { + console.error('❌ [uploadEntryModuleImage] 上传失败:', backendResponse?.msg); + return { error: backendResponse?.msg || '上传失败' }; + } + + const uploadResult = backendResponse.data; + if (!uploadResult) { + console.error('❌ [uploadEntryModuleImage] 响应数据格式错误'); + return { error: '上传失败:响应数据格式错误' }; + } + + console.log('✅ [uploadEntryModuleImage] 上传成功:', uploadResult); + return { data: uploadResult }; + } catch (error) { + console.error("❌ [uploadEntryModuleImage] 上传入口模块图标失败:", error); + return { error: error instanceof Error ? error.message : "上传入口模块图标失败" }; + } +} diff --git a/app/api/role-permissions/role-permissions.ts b/app/api/role-permissions/role-permissions.ts index bf0c740..165de95 100644 --- a/app/api/role-permissions/role-permissions.ts +++ b/app/api/role-permissions/role-permissions.ts @@ -450,7 +450,9 @@ export async function saveRoleApiPermissions( data_scope: 'ALL' })); - const response = await post(`/api/v3/rbac/roles/${roleId}/permissions`, { + // v3.4: 使用文档规范的API路径(查询参数方式) + const response = await post('/api/v3/rbac/role-permissions', { + role_id: roleId, permissions, replace: true // 替换模式:先删除现有权限,再插入新权限 }); @@ -621,8 +623,10 @@ export async function getAllUsers(params?: { if (params?.page_size) queryParams.page_size = params.page_size; if (params?.username) queryParams.search = params.username; - // 使用 axios-client 的 get 函数,会自动添加 baseURL 和 Authorization - const response = await get('/admin/users/users', queryParams); + // v3.3: 使用标准 RBAC API,后端会自动根据用户角色进行地区过滤 + // 省级管理员: 返回所有地区用户 + // 市级管理员: 只返回同地区用户 + const response = await get('/api/v2/users', queryParams); if (response.error) { throw new Error(response.error); @@ -643,6 +647,8 @@ export async function getAllUsers(params?: { } } + console.log('获取的用户列表', users ) + const userList = users.map(user => ({ id: user.user_id || user.id, // 优先使用 user_id,兼容不同的后端响应格式 @@ -685,6 +691,15 @@ export async function assignUserRoles( throw new Error(response.error); } + // v3.4: 检查后端返回的code(200表示成功) + if (response.data && response.data.code && response.data.code !== 200) { + // 后端返回错误,如权限不足(403)等 + return { + success: false, + message: response.data.message || '分配失败' + }; + } + // 后端响应格式: { code: 200, message: "角色分配成功", data: { user_id, roles: [...] } } let message = '用户角色分配成功'; if (response.data && response.data.message) { @@ -995,7 +1010,10 @@ export async function getRolePermissions(roleId: number): Promise(`/api/v3/rbac/roles/${roleId}/permissions`); + // v3.4: 使用文档规范的API路径(查询参数方式) + const response = await get('/api/v3/rbac/role-permissions', { + role_id: roleId + }); if (response.error) { throw new Error(response.error); @@ -1039,7 +1057,9 @@ export async function assignPermissionsToRole( replace = false ): Promise<{ success: boolean; message: string }> { try { - const response = await post(`${RBAC_API_BASE}/roles/${roleId}/permissions`, { + // v3.4: 使用文档规范的API路径(查询参数方式) + const response = await post(`${RBAC_API_BASE}/role-permissions`, { + role_id: roleId, permissions: permissions.map(p => ({ permission_id: p.permission_id, grant_type: p.grant_type || 'GRANT', @@ -1072,8 +1092,9 @@ export async function updateRolePermission( config: Partial ): Promise<{ success: boolean; message: string }> { try { + // v3.4: 使用文档规范的API路径(查询参数方式) const response = await put( - `${RBAC_API_BASE}/roles/${roleId}/permissions/${permissionId}`, + `${RBAC_API_BASE}/role-permissions?role_id=${roleId}&permission_id=${permissionId}`, config ); handleApiResponse(response); @@ -1098,7 +1119,8 @@ export async function revokeRolePermission( permissionId: number ): Promise<{ success: boolean; message: string }> { try { - const response = await del(`${RBAC_API_BASE}/roles/${roleId}/permissions/${permissionId}`); + // v3.4: 使用文档规范的API路径(查询参数方式) + const response = await del(`${RBAC_API_BASE}/role-permissions?role_id=${roleId}&permission_id=${permissionId}`); handleApiResponse(response); return { success: true, message: '权限移除成功' }; diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx index 7b34c45..f5ab98a 100644 --- a/app/components/ui/Button.tsx +++ b/app/components/ui/Button.tsx @@ -10,6 +10,7 @@ interface ButtonProps { size?: ButtonSize; to?: string; icon?: string; + loading?: boolean; disabled?: boolean; className?: string; onClick?: (e: React.MouseEvent) => void; @@ -21,6 +22,7 @@ export function Button({ size = 'medium', to, icon, + loading = false, disabled = false, className = '', onClick, @@ -44,31 +46,34 @@ export function Button({ baseClasses, typeClasses[type], sizeClasses[size], - disabled ? 'opacity-50 cursor-not-allowed' : '', + (disabled || loading) ? 'opacity-50 cursor-not-allowed' : '', className ].filter(Boolean).join(' '); - + + // 显示的图标:loading 时显示加载图标,否则显示传入的图标 + const displayIcon = loading ? 'ri-loader-4-line animate-spin' : icon; + if (to) { return ( - - {icon && } + {displayIcon && } {children} ); } - + return ( ); diff --git a/app/root.tsx b/app/root.tsx index 0081a25..f3d5c20 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -171,7 +171,7 @@ export async function loader({ request }: LoaderFunctionArgs) { // ✅ 保存权限映射表 if (routesResult.permissionMap) { permissionMap = routesResult.permissionMap; - console.log("🔑 [Root Loader] 权限映射表:", permissionMap); + // console.log("🔑 [Root Loader] 权限映射表:", permissionMap); } // 检查当前路径是否在允许列表中 diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 8cfe0cb..bf05f6a 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -5,6 +5,7 @@ import styles from "~/styles/pages/home.css?url"; import dayjs from 'dayjs'; import { getUserSession, logout } from "~/api/login/auth.server"; import { toastService } from '~/components/ui'; +import { DOCUMENT_URL } from '~/config/api-config'; export const links = () => [ { rel: "stylesheet", href: styles } @@ -227,14 +228,17 @@ export default function Index() { // 获取模块图标(根据模块 path 或 id) const getModuleIcon = (module: typeof loaderData.entryModules[0]) => { // 根据 path 判断图标 - if (module.path?.includes('ht')) { - return '/images/icon_hetong.png'; - } else if (module.path?.includes('aj')) { - return '/images/icon_anjuan.png'; - } else if (module.path?.includes('nw')) { - return '/images/icon_assistant.png'; - } + // if (module.path?.includes('ht')) { + // return '/images/icon_hetong.png'; + // } else if (module.path?.includes('aj')) { + // return '/images/icon_anjuan.png'; + // } else if (module.path?.includes('nw')) { + // return '/images/icon_assistant.png'; + // } // 默认图标 + if (module.path){ + return `${DOCUMENT_URL}${module.path}` + } return '/images/icon_assistant.png'; }; @@ -388,7 +392,7 @@ export default function Index() { aria-label="交叉评查" > 交叉评查 { @@ -418,7 +422,7 @@ export default function Index() { aria-label={module.name} > {module.name} diff --git a/app/routes/document-types._index.tsx b/app/routes/document-types._index.tsx index a1c75bc..37bf8f3 100644 --- a/app/routes/document-types._index.tsx +++ b/app/routes/document-types._index.tsx @@ -299,7 +299,7 @@ export default function DocumentTypesList() { { title: "文档类型名称", key: "name", - width: "180px", + width: "200px", render: (_: unknown, record: DocumentTypeUI) => (
@@ -454,7 +454,7 @@ export default function DocumentTypesList() { placeholder="请输入文档类型名称" value={name} onSearch={handleNameSearch} - className="flex-1 min-w-[200px]" + className="min-w-[400px]" instantSearch={true} /> diff --git a/app/routes/entry-modules._index.tsx b/app/routes/entry-modules._index.tsx index 96fc298..edf60ef 100644 --- a/app/routes/entry-modules._index.tsx +++ b/app/routes/entry-modules._index.tsx @@ -110,6 +110,9 @@ export default function EntryModulesList() { const [isDeleting, setIsDeleting] = useState(false); const revalidator = useRevalidator(); + // 记录加载失败的图片ID + const [failedImages, setFailedImages] = useState>(new Set()); + // 获取加载器数据 const loaderData = useLoaderData(); const { modules, total, error } = loaderData; @@ -259,26 +262,41 @@ export default function EntryModulesList() { if (!record.path) { return 未上传; } + const logoUrl = `${DOCUMENT_URL}${record.path}`; + const hasFailed = failedImages.has(record.id!); + return (
- {record.name} { - (e.target as HTMLImageElement).style.display = 'none'; - (e.target as HTMLImageElement).parentElement!.innerHTML = '加载失败'; - }} - /> - - 查看 - + {!hasFailed ? ( + <> + +
+ {record.name} { + // 使用 React 状态管理加载失败的图片 + setFailedImages(prev => new Set(prev).add(record.id!)); + }} + /> +
+
+ + ) : ( +
+
+ +
+ 加载失败 +
+ )}
); } @@ -396,10 +414,11 @@ export default function EntryModulesList() { {/* 分页 */} {total > 0 && ( )} diff --git a/app/routes/entry-modules.new.tsx b/app/routes/entry-modules.new.tsx index 71fea4e..e7efb0e 100644 --- a/app/routes/entry-modules.new.tsx +++ b/app/routes/entry-modules.new.tsx @@ -1,19 +1,18 @@ import { useState, useEffect, useRef } from "react"; -import { useNavigate, useSearchParams, useLoaderData } from "@remix-run/react"; -import { ClientLoaderFunctionArgs, MetaFunction } from "@remix-run/react"; +import { useNavigate, useSearchParams, useLoaderData, ClientLoaderFunctionArgs, MetaFunction } from "@remix-run/react"; import { Card } from "~/components/ui/Card"; import { Button } from "~/components/ui/Button"; import { toastService } from "~/components/ui/Toast"; -import { Modal } from "~/components/ui/Modal"; import { usePermission } from "~/hooks/usePermission"; import { getEntryModuleById, createEntryModule, updateEntryModule, + uploadEntryModuleImage, type EntryModule, type AreaConfig } from "~/api/entry-modules/entry-modules"; -import { API_BASE_URL, DOCUMENT_URL } from "~/config/api-config"; +import { DOCUMENT_URL } from "~/config/api-config"; import entryModulesStyles from "~/styles/pages/entry-modules.css?url"; // 引入CSS样式 @@ -110,7 +109,6 @@ export default function EntryModuleNew() { module?.path ? `${DOCUMENT_URL}${module.path}` : null ); const [isSubmitting, setIsSubmitting] = useState(false); - const [showConfirmModal, setShowConfirmModal] = useState(false); const fileInputRef = useRef(null); @@ -185,48 +183,6 @@ export default function EntryModuleNew() { return true; }; - // 上传logo图片 - const uploadLogo = async (): Promise => { - if (!logoFile) return module?.path || null; - - try { - const formData = new FormData(); - formData.append('file', logoFile); - formData.append('folder', 'entryModule'); - - // ✅ 不需要手动添加 Authorization 头 - // fetch 可以自动使用浏览器的认证信息,或者我们也可以使用 axios - const token = localStorage.getItem('access_token'); - - const response = await fetch(`${API_BASE_URL}/admin/upload`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}` - }, - body: formData - }); - - if (!response.ok) { - throw new Error('图片上传失败'); - } - - const result = await response.json(); - console.log('图片上传结果:', result); - - // 根据后端返回的数据结构提取路径 - if (result.data?.path) { - return result.data.path; - } else if (result.path) { - return result.path; - } else { - throw new Error('未获取到图片路径'); - } - } catch (error) { - console.error('上传logo失败:', error); - throw error; - } - }; - // 处理表单提交 const handleSubmit = async () => { // ✅ Runtime permission check @@ -245,27 +201,26 @@ export default function EntryModuleNew() { setIsSubmitting(true); try { - // 上传logo - let logoPath = module?.path || null; - if (logoFile) { - logoPath = await uploadLogo(); - } - - // 🔑 准备提交数据 + // 🔑 步骤1: 先创建或更新模块(不包含图片上传) // areas 字段会在 API 层自动转换为 AreaConfig[] 格式 const moduleData = { name: name.trim(), description: description.trim() || undefined, - path: logoPath, + // 创建时 path 为 null,编辑时保持原 path(图片上传接口会自动更新) + path: isEditMode ? module?.path : null, areas: selectedAreas // 字符串数组,API会自动转换 }; // ✅ 不需要传递 JWT,axios-client 会自动处理 let result; + let moduleId: number; + if (isEditMode) { result = await updateEntryModule(parseInt(id!), moduleData); + moduleId = parseInt(id!); } else { result = await createEntryModule(moduleData); + moduleId = result.data!.id!; } if (result.error) { @@ -273,6 +228,19 @@ export default function EntryModuleNew() { return; } + // 🔑 步骤2: 如果有新的logo文件,调用专用的图片上传接口 + // 后端会自动更新数据库中的 path 字段,无需再手动调用 update + if (logoFile) { + const uploadResult = await uploadEntryModuleImage(moduleId, logoFile); + + if (uploadResult.error) { + toastService.error(`模块${isEditMode ? '更新' : '创建'}成功,但图片上传失败: ${uploadResult.error}`); + return; + } + + console.log('✅ 图片上传成功,访问URL:', uploadResult.data?.url); + } + toastService.success(isEditMode ? '更新成功!' : '创建成功!'); setTimeout(() => { navigate('/entry-modules'); @@ -287,14 +255,10 @@ export default function EntryModuleNew() { // 处理取消 const handleCancel = () => { - setShowConfirmModal(true); - }; - - // 确认取消 - const confirmCancel = () => { navigate('/entry-modules'); }; + return (
@@ -429,33 +393,6 @@ export default function EntryModuleNew() { )}
- - {/* 取消确认模态框 */} - setShowConfirmModal(false)} - title="确认取消" - size="small" - footer={ -
- - -
- } - > -

确定要取消吗?未保存的更改将丢失。

-
); } diff --git a/app/routes/files.upload.tsx b/app/routes/files.upload.tsx index 9a5bd06..4f90458 100644 --- a/app/routes/files.upload.tsx +++ b/app/routes/files.upload.tsx @@ -34,11 +34,13 @@ export function links() { } // 面包屑导航 -// export const handle = { -// breadcrumb: () => { -// return '上传文件' -// } -// } +export const handle = { + breadcrumb: "文档列表", + previousRoute: { + title: "文档列表", + to: "/documents/list" + } +}; export const meta: MetaFunction = () => { return [ diff --git a/app/routes/role-permissions._index.tsx b/app/routes/role-permissions._index.tsx index 635cad2..9fd0884 100644 --- a/app/routes/role-permissions._index.tsx +++ b/app/routes/role-permissions._index.tsx @@ -50,7 +50,7 @@ export const meta = () => { function getDataScopeLabel(scope: string): string { const map: Record = { 'ALL': '全部数据', - 'DEPT': '部门数据', + 'DEPT': '地市级数据', 'SELF': '仅本人数据' }; return map[scope] || scope; @@ -314,13 +314,13 @@ function CreateRoleModal({ isOpen, onClose, onSuccess }: CreateRoleModalProps) { disabled={loading} > - + 控制该角色可访问的数据范围 -
+ {/*
数字越小优先级越高,范围 1-100 -
+
*/} ); @@ -495,7 +495,7 @@ function EditRoleModal({ isOpen, onClose, onSuccess, role }: EditRoleModalProps) disabled={loading || (role.is_system_role && role.role_key === 'provincial_admin')} > - + {role.is_system_role && role.role_key === 'provincial_admin' && ( @@ -503,7 +503,7 @@ function EditRoleModal({ isOpen, onClose, onSuccess, role }: EditRoleModalProps) )} -
+ {/*
数字越小优先级越高,范围 1-100 -
+
*/} ); @@ -632,8 +632,19 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren assignUserRoles(userId, [role.id]) ); - await Promise.all(promises); + const results = await Promise.all(promises); + // v3.4: 检查是否有失败的 + const failedResults = results.filter(r => !r.success); + if (failedResults.length > 0) { + // 如果有失败的,显示第一个错误消息 + const firstError = failedResults[0]; + toastService.error(firstError.message); + setLoading(false); + return; + } + + // 全部成功 toastService.success(`成功为 ${selectedUserIds.length} 个用户分配角色`); resetState(); onSuccess(); @@ -733,7 +744,7 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren 加载用户中... ) : ( -
+
{filteredUsers.length > 0 ? ( filteredUsers.map(user => { const userRoles = userRolesMap.get(user.id) || []; @@ -778,6 +789,7 @@ function AssignUserModal({ isOpen, onClose, onSuccess, role, isCityAdmin, curren
@{user.username} • {user.ou_name} + {user.area && ` • ${user.area}`} {user.phone_number && ` • ${user.phone_number}`}
@@ -900,18 +912,19 @@ export default function RolePermissions() { const filteredRoles = rolesData; // v3.3: 根据用户地区过滤可见的用户列表 - let filteredUsers = usersData; - if (isCityAdmin && currentUserArea) { - // 市级管理员只能看到同地区的用户(使用 area 字段) - filteredUsers = usersData.filter(user => - user.area === currentUserArea - ); - console.log('🔒 [RolePermissions v3.3] 市级管理员用户过滤:', { - 当前地区: currentUserArea, - 原始用户数: usersData.length, - 过滤后用户数: filteredUsers.length - }); - } + const filteredUsers = usersData; + // let filteredUsers = usersData; + // if (isCityAdmin && currentUserArea) { + // // 市级管理员只能看到同地区的用户(使用 area 字段) + // filteredUsers = usersData.filter(user => + // user.area === currentUserArea + // ); + // console.log('🔒 [RolePermissions v3.3] 市级管理员用户过滤:', { + // 当前地区: currentUserArea, + // 原始用户数: usersData.length, + // 过滤后用户数: filteredUsers.length + // }); + // } setRoles(filteredRoles); setRoutes(routesData); @@ -1507,10 +1520,10 @@ export default function RolePermissions() { {getDataScopeLabel(role.data_scope)} - + {/* 优先级: {role.priority} - + */} {!role.is_system_role && ( @@ -1635,7 +1648,11 @@ export default function RolePermissions() { )}
@{user.username}
-
{user.ou_name}
+
+ {/* {JSON.stringify(user)} */} + {user.ou_name} + {user.area && • {user.area}} +
{user.phone_number && (