all in
This commit is contained in:
@@ -0,0 +1,702 @@
|
||||
# 基于路由的权限管理方案
|
||||
|
||||
## 📋 问题描述
|
||||
|
||||
后端在查询用户路由时(`GET /rbac/user/routes`),会为每个路由附加该用户在该路由下的权限列表。
|
||||
|
||||
**示例场景**:
|
||||
- 用户访问 `/prompts` 路由
|
||||
- 后端返回该路由的权限:`['prompt_template:list:read', 'prompt_template:delete:delete']`
|
||||
- **没有返回**:`['prompt_template:detail:read', 'prompt_template:create:write', 'prompt_template:update:write']`
|
||||
- 因此,页面上的"新增"、"编辑"、"查看详情"按钮应该隐藏或禁用
|
||||
|
||||
## 🎯 核心挑战
|
||||
|
||||
1. 如何存储每个路由的权限列表?
|
||||
2. 如何让权限数据在所有页面中快速访问?
|
||||
3. 如何避免每次切换页面都重新请求权限?
|
||||
4. 如何处理嵌套路由的权限继承?
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案
|
||||
|
||||
### 方案概述
|
||||
|
||||
采用**扁平化权限映射表** + **全局状态管理**的方式:
|
||||
|
||||
1. 后端返回路由时,每个路由附带权限列表
|
||||
2. 前端在 `root.tsx` loader 中构建权限映射表
|
||||
3. 将权限映射表通过 `useRouteLoaderData` 暴露给所有子路由
|
||||
4. 更新 `usePermission` Hook 支持从映射表中查询权限
|
||||
|
||||
---
|
||||
|
||||
## 🔧 实施步骤
|
||||
|
||||
### 第一步:更新后端API返回格式
|
||||
|
||||
后端需要在路由接口返回时,为每个路由添加 `permissions` 字段:
|
||||
|
||||
```typescript
|
||||
// 后端API返回格式
|
||||
interface BackendRouteInfo {
|
||||
id: number;
|
||||
route_path: string;
|
||||
route_name: string;
|
||||
route_title: string;
|
||||
icon: string | null;
|
||||
parent_id: number | null;
|
||||
sort_order: number;
|
||||
is_hidden: boolean;
|
||||
permissions?: string[]; // ✅ 新增:该路由下用户拥有的权限列表
|
||||
children?: BackendRouteInfo[];
|
||||
}
|
||||
|
||||
// 返回示例
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "成功",
|
||||
"data": {
|
||||
"user_id": 123,
|
||||
"username": "张三",
|
||||
"routes": [
|
||||
{
|
||||
"id": 10,
|
||||
"route_path": "/prompts",
|
||||
"route_name": "prompts",
|
||||
"route_title": "提示词管理",
|
||||
"icon": "ri-chat-1-line",
|
||||
"parent_id": 5,
|
||||
"sort_order": 3,
|
||||
"is_hidden": false,
|
||||
"permissions": [
|
||||
"prompt_template:list:read", // 有列表查看权限
|
||||
"prompt_template:detail:read", // 有详情查看权限
|
||||
"prompt_template:delete:delete" // 有删除权限
|
||||
// ❌ 没有 create:write, update:write 权限
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"route_path": "/documents",
|
||||
"route_name": "documents",
|
||||
"route_title": "文档列表",
|
||||
"icon": "ri-file-list-3-line",
|
||||
"parent_id": 3,
|
||||
"sort_order": 2,
|
||||
"is_hidden": false,
|
||||
"permissions": [
|
||||
"document:list:read",
|
||||
"document:detail:read",
|
||||
"document:create:write",
|
||||
"document:update:write",
|
||||
"document:delete:delete"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 第二步:更新前端接口定义
|
||||
|
||||
```typescript
|
||||
// app/api/auth/user-routes.ts
|
||||
|
||||
export interface BackendRouteInfo {
|
||||
id: number;
|
||||
route_path: string;
|
||||
route_name: string;
|
||||
component: string;
|
||||
parent_id: number | null;
|
||||
route_title: string;
|
||||
icon: string | null;
|
||||
sort_order: number;
|
||||
is_hidden: boolean;
|
||||
is_cache: boolean;
|
||||
meta: string;
|
||||
permissions?: string[]; // ✅ 新增:该路由的权限列表
|
||||
children?: BackendRouteInfo[];
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
order: number;
|
||||
hideBreadcrumb?: boolean;
|
||||
requiredRole?: string;
|
||||
permissions?: string[]; // ✅ 新增:该菜单项的权限列表
|
||||
children?: MenuItem[];
|
||||
}
|
||||
```
|
||||
|
||||
### 第三步:构建权限映射表
|
||||
|
||||
在 `app/api/auth/user-routes.ts` 中添加工具函数:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 权限映射表类型
|
||||
* key: 路由路径 (如 '/prompts', '/documents')
|
||||
* value: 该路由下的权限列表
|
||||
*/
|
||||
export type PermissionMap = Map<string, string[]>;
|
||||
|
||||
/**
|
||||
* 从路由树中提取权限映射表
|
||||
* @param routes 路由树
|
||||
* @returns 权限映射表 (路径 -> 权限列表)
|
||||
*/
|
||||
export function buildPermissionMap(routes: BackendRouteInfo[]): PermissionMap {
|
||||
const permissionMap = new Map<string, string[]>();
|
||||
|
||||
function traverse(routeList: BackendRouteInfo[]) {
|
||||
for (const route of routeList) {
|
||||
// 存储当前路由的权限
|
||||
if (route.permissions && route.permissions.length > 0) {
|
||||
permissionMap.set(route.route_path, route.permissions);
|
||||
}
|
||||
|
||||
// 递归处理子路由
|
||||
if (route.children && route.children.length > 0) {
|
||||
traverse(route.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(routes);
|
||||
return permissionMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将权限映射表转换为普通对象(用于JSON序列化)
|
||||
*/
|
||||
export function permissionMapToObject(map: PermissionMap): Record<string, string[]> {
|
||||
const obj: Record<string, string[]> = {};
|
||||
map.forEach((value, key) => {
|
||||
obj[key] = value;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从对象恢复权限映射表
|
||||
*/
|
||||
export function objectToPermissionMap(obj: Record<string, string[]>): PermissionMap {
|
||||
const map = new Map<string, string[]>();
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
map.set(key, value);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
```
|
||||
|
||||
更新 `getUserRoutesByRole` 函数:
|
||||
|
||||
```typescript
|
||||
export async function getUserRoutesByRole(
|
||||
roleKey: string,
|
||||
jwt?: string,
|
||||
includeHidden: boolean = false
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: MenuItem[];
|
||||
permissionMap?: Record<string, string[]>; // ✅ 新增:返回权限映射表
|
||||
error?: string;
|
||||
shouldRedirectToHome?: boolean;
|
||||
}> {
|
||||
try {
|
||||
// ... 现有的API请求代码 ...
|
||||
|
||||
const routes = backendResponse.data.routes;
|
||||
|
||||
// ✅ 构建权限映射表
|
||||
const permissionMapObj = permissionMapToObject(buildPermissionMap(routes));
|
||||
|
||||
// 将后端路由格式转换为前端 MenuItem 格式
|
||||
const menuItems = convertBackendRoutesToMenuItems(routes, includeHidden);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: menuItems,
|
||||
permissionMap: permissionMapObj // ✅ 返回权限映射表
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// ... 错误处理 ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 第四步:在 root.tsx 中存储权限映射表
|
||||
|
||||
```typescript
|
||||
// app/root.tsx
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
try {
|
||||
const session = await getUserSession(request);
|
||||
const { userRole, frontendJWT, userInfo } = session;
|
||||
|
||||
// 获取用户路由(包含权限信息)
|
||||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
|
||||
|
||||
if (!routesResult.success) {
|
||||
// 错误处理...
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
userRole,
|
||||
userInfo,
|
||||
frontendJWT,
|
||||
permissionMap: routesResult.permissionMap || {}, // ✅ 传递权限映射表
|
||||
ENV: {
|
||||
// 环境变量...
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// 错误处理...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 第五步:更新 usePermission Hook
|
||||
|
||||
```typescript
|
||||
// app/hooks/usePermission.tsx
|
||||
|
||||
import { useRouteLoaderData, useLocation } from "@remix-run/react";
|
||||
|
||||
interface RootLoaderData {
|
||||
permissions?: string[];
|
||||
permissionMap?: Record<string, string[]>; // ✅ 新增:权限映射表
|
||||
userRole: string;
|
||||
userInfo?: {
|
||||
role_id?: number;
|
||||
role_key?: string;
|
||||
role_name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function usePermission() {
|
||||
const rootData = useRouteLoaderData("root") as RootLoaderData;
|
||||
const location = useLocation();
|
||||
|
||||
// 从root loader获取权限映射表
|
||||
const permissionMap = rootData?.permissionMap || {};
|
||||
const userRole = rootData?.userRole || 'common';
|
||||
|
||||
// 🔑 根据当前路由获取权限列表
|
||||
const currentPath = location.pathname;
|
||||
const currentPermissions = permissionMap[currentPath] || [];
|
||||
|
||||
/**
|
||||
* 检查是否有指定权限
|
||||
* @param permissionKey 权限键,如 "prompt_template:create:write"
|
||||
* @returns boolean
|
||||
*/
|
||||
const hasPermission = (permissionKey: string): boolean => {
|
||||
// 优先使用当前路由的权限列表
|
||||
if (currentPermissions.length > 0) {
|
||||
return currentPermissions.includes(permissionKey);
|
||||
}
|
||||
|
||||
// 降级方案:如果没有当前路由的权限,使用角色判断
|
||||
if (userRole.toLowerCase().includes('provin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 默认只有查看权限
|
||||
if (permissionKey.includes(':read')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否有指定路由的权限
|
||||
* @param path 路由路径,如 "/prompts"
|
||||
* @param permissionKey 权限键
|
||||
* @returns boolean
|
||||
*/
|
||||
const hasRoutePermission = (path: string, permissionKey: string): boolean => {
|
||||
const routePermissions = permissionMap[path] || [];
|
||||
return routePermissions.includes(permissionKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前路由的所有权限
|
||||
* @returns 权限列表
|
||||
*/
|
||||
const getCurrentPermissions = (): string[] => {
|
||||
return currentPermissions;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定路由的所有权限
|
||||
* @param path 路由路径
|
||||
* @returns 权限列表
|
||||
*/
|
||||
const getRoutePermissions = (path: string): string[] => {
|
||||
return permissionMap[path] || [];
|
||||
};
|
||||
|
||||
// ... 其他便捷方法 ...
|
||||
|
||||
return {
|
||||
// 原始数据
|
||||
permissions: currentPermissions,
|
||||
permissionMap,
|
||||
userRole,
|
||||
|
||||
// 基础检查方法
|
||||
hasPermission,
|
||||
hasRoutePermission,
|
||||
getCurrentPermissions,
|
||||
getRoutePermissions,
|
||||
|
||||
// 便捷方法
|
||||
canCreate,
|
||||
canRead,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canList,
|
||||
canView
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 使用示例
|
||||
|
||||
### 示例1:在提示词管理页面中使用
|
||||
|
||||
```typescript
|
||||
// app/routes/prompts._index.tsx
|
||||
import { usePermission } from "~/hooks/usePermission";
|
||||
|
||||
export default function PromptsIndex() {
|
||||
const {
|
||||
hasPermission,
|
||||
getCurrentPermissions,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canDelete
|
||||
} = usePermission();
|
||||
|
||||
// 调试:查看当前路由的所有权限
|
||||
useEffect(() => {
|
||||
console.log('📋 [Prompts] 当前路由权限:', getCurrentPermissions());
|
||||
}, []);
|
||||
|
||||
// 检查各项权限
|
||||
const canCreateTemplate = canCreate('prompt_template');
|
||||
// 等同于: hasPermission('prompt_template:create:write')
|
||||
|
||||
const canEditTemplate = canUpdate('prompt_template');
|
||||
// 等同于: hasPermission('prompt_template:update:write')
|
||||
|
||||
const canDeleteTemplate = canDelete('prompt_template');
|
||||
// 等同于: hasPermission('prompt_template:delete:delete')
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 🔐 新增按钮:需要 create 权限 */}
|
||||
{canCreateTemplate && (
|
||||
<Button onClick={() => navigate("/prompts/new")}>
|
||||
新增模板
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 🔐 编辑按钮:需要 update 权限 */}
|
||||
{canEditTemplate ? (
|
||||
<button onClick={handleEdit}>编辑</button>
|
||||
) : (
|
||||
<button onClick={handleView}>查看</button>
|
||||
)}
|
||||
|
||||
{/* 🔐 删除按钮:需要 delete 权限 */}
|
||||
{canDeleteTemplate && (
|
||||
<button onClick={handleDelete}>删除</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 示例2:跨路由检查权限
|
||||
|
||||
```typescript
|
||||
// 在任意页面检查其他路由的权限
|
||||
import { usePermission } from "~/hooks/usePermission";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { hasRoutePermission, getRoutePermissions } = usePermission();
|
||||
|
||||
// 检查用户是否有访问提示词管理的权限
|
||||
const canAccessPrompts = hasRoutePermission(
|
||||
'/prompts',
|
||||
'prompt_template:list:read'
|
||||
);
|
||||
|
||||
// 获取文档管理路由的所有权限
|
||||
const documentPermissions = getRoutePermissions('/documents');
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canAccessPrompts && (
|
||||
<Link to="/prompts">前往提示词管理</Link>
|
||||
)}
|
||||
|
||||
<div>
|
||||
文档管理权限:{documentPermissions.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 权限映射表示例
|
||||
|
||||
```typescript
|
||||
// permissionMap 的数据结构
|
||||
{
|
||||
"/prompts": [
|
||||
"prompt_template:list:read",
|
||||
"prompt_template:detail:read",
|
||||
"prompt_template:delete:delete"
|
||||
// ❌ 没有 create 和 update 权限
|
||||
],
|
||||
"/documents": [
|
||||
"document:list:read",
|
||||
"document:detail:read",
|
||||
"document:create:write",
|
||||
"document:update:write",
|
||||
"document:delete:delete"
|
||||
],
|
||||
"/rule-groups": [
|
||||
"rule_group:list:read",
|
||||
"rule_group:detail:read"
|
||||
// ❌ 没有任何写权限
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 性能优化
|
||||
|
||||
### 1. 缓存策略
|
||||
|
||||
```typescript
|
||||
// app/root.tsx
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getUserSession(request);
|
||||
|
||||
// 检查 session 中是否已有缓存的权限映射表
|
||||
const cachedPermissions = session.get("permissionMap");
|
||||
const cacheTimestamp = session.get("permissionMapTimestamp");
|
||||
|
||||
// 如果缓存未过期(比如5分钟内),直接使用缓存
|
||||
const now = Date.now();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
if (cachedPermissions && cacheTimestamp && (now - cacheTimestamp < CACHE_TTL)) {
|
||||
return Response.json({
|
||||
// ... 其他数据
|
||||
permissionMap: cachedPermissions
|
||||
});
|
||||
}
|
||||
|
||||
// 否则重新获取
|
||||
const routesResult = await getUserRoutesByRole(userRole, frontendJWT, true);
|
||||
|
||||
// 缓存权限映射表到 session
|
||||
session.set("permissionMap", routesResult.permissionMap);
|
||||
session.set("permissionMapTimestamp", now);
|
||||
|
||||
return Response.json({
|
||||
// ... 数据
|
||||
permissionMap: routesResult.permissionMap
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. LocalStorage 备份
|
||||
|
||||
```typescript
|
||||
// app/hooks/usePermission.tsx
|
||||
|
||||
export function usePermission() {
|
||||
const rootData = useRouteLoaderData("root") as RootLoaderData;
|
||||
const location = useLocation();
|
||||
|
||||
// 从 root 或 localStorage 获取权限映射表
|
||||
let permissionMap = rootData?.permissionMap || {};
|
||||
|
||||
// 如果 root 中没有,尝试从 localStorage 读取
|
||||
if (Object.keys(permissionMap).length === 0 && typeof window !== 'undefined') {
|
||||
const cached = localStorage.getItem('permissionMap');
|
||||
if (cached) {
|
||||
try {
|
||||
permissionMap = JSON.parse(cached);
|
||||
} catch (e) {
|
||||
console.error('解析权限缓存失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 root 中有新数据,更新 localStorage
|
||||
useEffect(() => {
|
||||
if (rootData?.permissionMap && Object.keys(rootData.permissionMap).length > 0) {
|
||||
localStorage.setItem('permissionMap', JSON.stringify(rootData.permissionMap));
|
||||
}
|
||||
}, [rootData?.permissionMap]);
|
||||
|
||||
// ... 其他代码
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全考虑
|
||||
|
||||
### 1. 前端验证 + 后端验证
|
||||
|
||||
前端权限检查只是UI优化,后端必须再次验证:
|
||||
|
||||
```typescript
|
||||
// 后端API验证示例
|
||||
app.post('/api/v3/prompt-templates', authenticate, async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const routePath = '/prompts';
|
||||
|
||||
// 查询用户在该路由下的权限
|
||||
const userPermissions = await getUserRoutePermissions(userId, routePath);
|
||||
|
||||
// 验证是否有创建权限
|
||||
if (!userPermissions.includes('prompt_template:create:write')) {
|
||||
return res.status(403).json({
|
||||
code: 403,
|
||||
msg: '权限不足:您没有创建提示词模板的权限'
|
||||
});
|
||||
}
|
||||
|
||||
// 执行创建逻辑...
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 权限变更实时性
|
||||
|
||||
权限变更后的处理:
|
||||
- **立即生效**:清除前端缓存,强制重新请求路由
|
||||
- **下次登录生效**:不清除缓存,等待自然过期
|
||||
|
||||
```typescript
|
||||
// 管理员修改用户权限后调用
|
||||
export async function refreshUserPermissions() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 清除 localStorage 缓存
|
||||
localStorage.removeItem('permissionMap');
|
||||
|
||||
// 刷新页面,重新加载权限
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 最佳实践
|
||||
|
||||
### 1. 权限粒度
|
||||
|
||||
建议权限粒度:
|
||||
```
|
||||
module:resource:action
|
||||
├── module: 模块名(如 prompt_template, document)
|
||||
├── resource: 资源类型(如 list, detail, create, update, delete)
|
||||
└── action: 操作类型(如 read, write, delete)
|
||||
```
|
||||
|
||||
示例:
|
||||
- `prompt_template:list:read` - 查看提示词列表
|
||||
- `prompt_template:create:write` - 创建提示词
|
||||
- `prompt_template:update:write` - 更新提示词
|
||||
|
||||
### 2. 降级策略
|
||||
|
||||
始终提供降级方案,避免权限加载失败导致页面不可用:
|
||||
|
||||
```typescript
|
||||
const hasPermission = (key: string): boolean => {
|
||||
// 1. 优先使用路由权限
|
||||
if (currentPermissions.length > 0) {
|
||||
return currentPermissions.includes(key);
|
||||
}
|
||||
|
||||
// 2. 降级到角色判断
|
||||
if (userRole.toLowerCase().includes('provin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 默认只读权限
|
||||
if (key.includes(':read')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
```
|
||||
|
||||
### 3. 调试工具
|
||||
|
||||
开发时在控制台输出权限信息:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.group('🔐 权限调试信息');
|
||||
console.log('当前路由:', location.pathname);
|
||||
console.log('当前权限:', getCurrentPermissions());
|
||||
console.log('完整权限映射表:', permissionMap);
|
||||
console.groupEnd();
|
||||
}
|
||||
}, [location.pathname]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
### 优势
|
||||
|
||||
✅ **集中管理**:权限数据在 root loader 中统一获取和管理
|
||||
✅ **快速访问**:权限映射表在内存中,访问速度快
|
||||
✅ **灵活查询**:可以查询当前路由或任意路由的权限
|
||||
✅ **缓存优化**:支持 session 和 localStorage 多级缓存
|
||||
✅ **降级方案**:即使权限加载失败也能正常运行
|
||||
|
||||
### 注意事项
|
||||
|
||||
⚠️ **前端只做UI控制**:真正的权限验证必须在后端进行
|
||||
⚠️ **缓存同步**:权限变更后要及时清除缓存
|
||||
⚠️ **路由匹配**:注意动态路由的权限映射(如 `/documents/:id`)
|
||||
⚠️ **嵌套路由**:子路由可能需要继承父路由的权限
|
||||
|
||||
---
|
||||
|
||||
**文档更新日期**: 2025-11-27
|
||||
**作者**: Claude Code
|
||||
**相关文件**:
|
||||
- `app/api/auth/user-routes.ts`
|
||||
- `app/hooks/usePermission.tsx`
|
||||
- `app/root.tsx`
|
||||
Reference in New Issue
Block a user