mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 23:57:19 +08:00
feat: add user and role management pages with authentication settings
- Implemented AuthSettingsTab for managing authentication settings including user registration and default roles. - Created UsersPage for managing users and roles, including user creation, editing, and deletion functionalities. - Added components for user and role management: UserEditorDrawer, RoleEditorDrawer, UsersTable, RolesTable, and PathRuleEditorDrawer. - Introduced QuickCreateRoleModal for quick role creation within user management. - Implemented permission management within roles, including path rules and user assignments. - Enhanced user experience with loading states and error handling in API interactions.
This commit is contained in:
+3
-3
@@ -18,16 +18,16 @@ from .types import (
|
|||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", summary="注册第一个管理员用户")
|
@router.post("/register", summary="注册用户(首个用户为管理员)")
|
||||||
@audit(
|
@audit(
|
||||||
action=AuditAction.REGISTER,
|
action=AuditAction.REGISTER,
|
||||||
description="注册管理员",
|
description="注册用户",
|
||||||
body_fields=["username", "email", "full_name"],
|
body_fields=["username", "email", "full_name"],
|
||||||
redact_fields=["password"],
|
redact_fields=["password"],
|
||||||
)
|
)
|
||||||
async def register(request: Request, data: RegisterRequest):
|
async def register(request: Request, data: RegisterRequest):
|
||||||
user = await AuthService.register_user(data)
|
user = await AuthService.register_user(data)
|
||||||
return success({"username": user.username}, msg="初始用户注册成功")
|
return success({"username": user.username}, msg="注册成功")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
|
|||||||
+45
-6
@@ -12,7 +12,7 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|||||||
from jwt.exceptions import InvalidTokenError
|
from jwt.exceptions import InvalidTokenError
|
||||||
|
|
||||||
from domain.config import ConfigService
|
from domain.config import ConfigService
|
||||||
from models.database import UserAccount
|
from models.database import Role, UserAccount, UserRole
|
||||||
from .types import (
|
from .types import (
|
||||||
PasswordResetConfirm,
|
PasswordResetConfirm,
|
||||||
PasswordResetRequest,
|
PasswordResetRequest,
|
||||||
@@ -161,21 +161,60 @@ class AuthService:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def register_user(cls, payload: RegisterRequest):
|
async def register_user(cls, payload: RegisterRequest):
|
||||||
if await cls.has_users():
|
has_users = await cls.has_users()
|
||||||
raise HTTPException(status_code=403, detail="系统已初始化,不允许注册新用户")
|
normalized_email = cls._normalize_email(payload.email)
|
||||||
|
if not normalized_email:
|
||||||
|
raise HTTPException(status_code=400, detail="邮箱不能为空")
|
||||||
|
|
||||||
|
if has_users:
|
||||||
|
allow_register = str(await ConfigService.get("AUTH_ALLOW_REGISTER", "false") or "").strip().lower()
|
||||||
|
if allow_register not in ("1", "true", "yes", "on"):
|
||||||
|
raise HTTPException(status_code=403, detail="系统未开放注册")
|
||||||
|
|
||||||
|
default_role_id_raw = str(await ConfigService.get("AUTH_DEFAULT_REGISTER_ROLE_ID", "") or "").strip()
|
||||||
|
if not default_role_id_raw:
|
||||||
|
raise HTTPException(status_code=400, detail="未配置默认注册角色")
|
||||||
|
try:
|
||||||
|
default_role_id = int(default_role_id_raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="默认注册角色配置错误") from exc
|
||||||
|
|
||||||
|
role = await Role.get_or_none(id=default_role_id)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(status_code=400, detail="默认注册角色不存在")
|
||||||
|
|
||||||
exists = await UserAccount.get_or_none(username=payload.username)
|
exists = await UserAccount.get_or_none(username=payload.username)
|
||||||
if exists:
|
if exists:
|
||||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||||
|
|
||||||
|
existing_email = await UserAccount.get_or_none(email=normalized_email)
|
||||||
|
if existing_email:
|
||||||
|
raise HTTPException(status_code=400, detail="邮箱已被使用")
|
||||||
|
|
||||||
hashed = cls.get_password_hash(payload.password)
|
hashed = cls.get_password_hash(payload.password)
|
||||||
# 第一个用户自动成为超级管理员
|
|
||||||
|
# 第一个用户自动成为超级管理员(不受开放注册开关影响)
|
||||||
|
if not has_users:
|
||||||
|
user = await UserAccount.create(
|
||||||
|
username=payload.username,
|
||||||
|
email=normalized_email,
|
||||||
|
full_name=payload.full_name,
|
||||||
|
hashed_password=hashed,
|
||||||
|
disabled=False,
|
||||||
|
is_admin=True,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
# 系统已初始化:按默认角色创建普通用户
|
||||||
user = await UserAccount.create(
|
user = await UserAccount.create(
|
||||||
username=payload.username,
|
username=payload.username,
|
||||||
email=payload.email,
|
email=normalized_email,
|
||||||
full_name=payload.full_name,
|
full_name=payload.full_name,
|
||||||
hashed_password=hashed,
|
hashed_password=hashed,
|
||||||
disabled=False,
|
disabled=False,
|
||||||
is_admin=True, # 第一个用户是超级管理员
|
is_admin=False,
|
||||||
)
|
)
|
||||||
|
await UserRole.create(user_id=user.id, role_id=default_role_id)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class UserInDB(User):
|
|||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
email: str | None = None
|
email: str
|
||||||
full_name: str | None = None
|
full_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,21 @@ from fastapi import APIRouter, Depends, Form, Request
|
|||||||
from api.response import success
|
from api.response import success
|
||||||
from domain.audit import AuditAction, audit
|
from domain.audit import AuditAction, audit
|
||||||
from domain.auth import User, get_current_active_user
|
from domain.auth import User, get_current_active_user
|
||||||
|
from domain.permission.service import PermissionService
|
||||||
|
from domain.permission.types import SystemPermission
|
||||||
from .service import ConfigService
|
from .service import ConfigService
|
||||||
from .types import ConfigItem
|
from .types import ConfigItem
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||||
|
|
||||||
|
PUBLIC_CONFIG_KEYS = [
|
||||||
|
"THEME_MODE",
|
||||||
|
"THEME_PRIMARY_COLOR",
|
||||||
|
"THEME_BORDER_RADIUS",
|
||||||
|
"THEME_CUSTOM_TOKENS",
|
||||||
|
"THEME_CUSTOM_CSS",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
@audit(action=AuditAction.READ, description="获取配置")
|
@audit(action=AuditAction.READ, description="获取配置")
|
||||||
@@ -18,6 +28,9 @@ async def get_config(
|
|||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
key: str,
|
key: str,
|
||||||
):
|
):
|
||||||
|
await PermissionService.require_system_permission(
|
||||||
|
current_user.id, SystemPermission.CONFIG_EDIT
|
||||||
|
)
|
||||||
value = await ConfigService.get(key)
|
value = await ConfigService.get(key)
|
||||||
return success(ConfigItem(key=key, value=value).model_dump())
|
return success(ConfigItem(key=key, value=value).model_dump())
|
||||||
|
|
||||||
@@ -30,6 +43,9 @@ async def set_config(
|
|||||||
key: str = Form(...),
|
key: str = Form(...),
|
||||||
value: str = Form(""),
|
value: str = Form(""),
|
||||||
):
|
):
|
||||||
|
await PermissionService.require_system_permission(
|
||||||
|
current_user.id, SystemPermission.CONFIG_EDIT
|
||||||
|
)
|
||||||
await ConfigService.set(key, value)
|
await ConfigService.set(key, value)
|
||||||
return success(ConfigItem(key=key, value=value).model_dump())
|
return success(ConfigItem(key=key, value=value).model_dump())
|
||||||
|
|
||||||
@@ -40,9 +56,25 @@ async def get_all_config(
|
|||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
):
|
):
|
||||||
|
await PermissionService.require_system_permission(
|
||||||
|
current_user.id, SystemPermission.CONFIG_EDIT
|
||||||
|
)
|
||||||
configs = await ConfigService.get_all()
|
configs = await ConfigService.get_all()
|
||||||
return success(configs)
|
return success(configs)
|
||||||
|
|
||||||
|
@router.get("/public")
|
||||||
|
@audit(action=AuditAction.READ, description="获取公开配置")
|
||||||
|
async def get_public_config(
|
||||||
|
request: Request,
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
data = {}
|
||||||
|
for key in PUBLIC_CONFIG_KEYS:
|
||||||
|
value = await ConfigService.get(key)
|
||||||
|
if value is not None:
|
||||||
|
data[key] = value
|
||||||
|
return success(data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
@audit(action=AuditAction.READ, description="获取系统状态")
|
@audit(action=AuditAction.READ, description="获取系统状态")
|
||||||
|
|||||||
+2
-2
@@ -8,7 +8,7 @@ export interface LoginPayload {
|
|||||||
export interface RegisterPayload {
|
export interface RegisterPayload {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
email?: string;
|
email: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export interface PasswordResetConfirmPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
register: async (username: string, password: string, email?: string, full_name?: string): Promise<any> => {
|
register: async (username: string, password: string, email: string, full_name?: string): Promise<any> => {
|
||||||
return request('/auth/register', {
|
return request('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
json: { username, password, email, full_name },
|
json: { username, password, email, full_name },
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export async function getAllConfig() {
|
|||||||
return request<Record<string, string>>('/config/all');
|
return request<Record<string, string>>('/config/all');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPublicConfig() {
|
||||||
|
return request<Record<string, string>>('/config/public');
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemStatus {
|
export interface SystemStatus {
|
||||||
version: string;
|
version: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface AuthContextType {
|
|||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
register: (username: string, password: string, email?: string, full_name?: string) => Promise<void>;
|
register: (username: string, password: string, email: string, full_name?: string) => Promise<void>;
|
||||||
user: MeResponse | null;
|
user: MeResponse | null;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (username: string, password: string, email?: string, full_name?: string) => {
|
const register = async (username: string, password: string, email: string, full_name?: string) => {
|
||||||
await authApi.register(username, password, email, full_name);
|
await authApi.register(username, password, email, full_name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ConfigProvider, theme as antdTheme } from 'antd';
|
|||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import enUS from 'antd/locale/en_US';
|
import enUS from 'antd/locale/en_US';
|
||||||
import type { ThemeConfig } from 'antd/es/config-provider/context';
|
import type { ThemeConfig } from 'antd/es/config-provider/context';
|
||||||
import { getAllConfig } from '../api/config';
|
import { getPublicConfig } from '../api/config';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import baseTheme from '../theme';
|
import baseTheme from '../theme';
|
||||||
import { useI18n } from '../i18n';
|
import { useI18n } from '../i18n';
|
||||||
@@ -149,7 +149,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const cfg = await getAllConfig();
|
const cfg = await getPublicConfig();
|
||||||
const mode = (cfg[CONFIG_KEYS.MODE] as ThemeMode) || 'light';
|
const mode = (cfg[CONFIG_KEYS.MODE] as ThemeMode) || 'light';
|
||||||
const primary = (cfg[CONFIG_KEYS.PRIMARY] as string) || null;
|
const primary = (cfg[CONFIG_KEYS.PRIMARY] as string) || null;
|
||||||
const radiusStr = cfg[CONFIG_KEYS.RADIUS];
|
const radiusStr = cfg[CONFIG_KEYS.RADIUS];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"Adapters": "Adapters",
|
"Adapters": "Adapters",
|
||||||
"Plugins": "App Center",
|
"Plugins": "App Center",
|
||||||
"System Settings": "System Settings",
|
"System Settings": "System Settings",
|
||||||
|
"Registration Settings": "Registration Settings",
|
||||||
"Backup & Restore": "Backup & Restore",
|
"Backup & Restore": "Backup & Restore",
|
||||||
"System Logs": "System Logs",
|
"System Logs": "System Logs",
|
||||||
"Audit Logs": "Audit Logs",
|
"Audit Logs": "Audit Logs",
|
||||||
@@ -15,6 +16,16 @@
|
|||||||
"Search files / tags / types": "Search files / tags / types",
|
"Search files / tags / types": "Search files / tags / types",
|
||||||
"Log Out": "Log Out",
|
"Log Out": "Log Out",
|
||||||
"Admin": "Admin",
|
"Admin": "Admin",
|
||||||
|
"Enable Registration": "Enable Registration",
|
||||||
|
"Default Role for New Registrations": "Default Role for New Registrations",
|
||||||
|
"Please select default role": "Please select default role",
|
||||||
|
"Enabling registration allows new users to sign up and assigns them the default role": "Enabling registration allows new users to sign up and assigns them the default role",
|
||||||
|
"Create Account": "Create Account",
|
||||||
|
"Sign Up": "Sign Up",
|
||||||
|
"Sign up to your Foxel account": "Sign up to your Foxel account",
|
||||||
|
"Already have an account?": "Already have an account?",
|
||||||
|
"Register failed": "Register failed",
|
||||||
|
"Please input email!": "Please input email!",
|
||||||
"Profile": "Profile",
|
"Profile": "Profile",
|
||||||
"Account Settings": "Account Settings",
|
"Account Settings": "Account Settings",
|
||||||
"Language": "Language",
|
"Language": "Language",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"Adapters": "存储挂载",
|
"Adapters": "存储挂载",
|
||||||
"Plugins": "应用中心",
|
"Plugins": "应用中心",
|
||||||
"System Settings": "系统设置",
|
"System Settings": "系统设置",
|
||||||
|
"Registration Settings": "注册设置",
|
||||||
"Backup & Restore": "备份恢复",
|
"Backup & Restore": "备份恢复",
|
||||||
"System Logs": "系统日志",
|
"System Logs": "系统日志",
|
||||||
"Audit Logs": "审计日志",
|
"Audit Logs": "审计日志",
|
||||||
@@ -38,6 +39,16 @@
|
|||||||
"Search files / tags / types": "搜索文件 / 标签 / 类型",
|
"Search files / tags / types": "搜索文件 / 标签 / 类型",
|
||||||
"Log Out": "退出登录",
|
"Log Out": "退出登录",
|
||||||
"Admin": "管理员",
|
"Admin": "管理员",
|
||||||
|
"Enable Registration": "开启注册",
|
||||||
|
"Default Role for New Registrations": "默认注册角色",
|
||||||
|
"Please select default role": "请选择默认注册角色",
|
||||||
|
"Enabling registration allows new users to sign up and assigns them the default role": "开启后,用户可自行注册,并自动分配为默认角色",
|
||||||
|
"Create Account": "创建账号",
|
||||||
|
"Sign Up": "注册",
|
||||||
|
"Sign up to your Foxel account": "注册 Foxel 账号",
|
||||||
|
"Already have an account?": "已有账号?",
|
||||||
|
"Register failed": "注册失败",
|
||||||
|
"Please input email!": "请输入邮箱!",
|
||||||
"Profile": "个人资料",
|
"Profile": "个人资料",
|
||||||
"Account Settings": "账户设置",
|
"Account Settings": "账户设置",
|
||||||
"Language": "语言",
|
"Language": "语言",
|
||||||
@@ -758,6 +769,7 @@
|
|||||||
"Create User": "创建用户",
|
"Create User": "创建用户",
|
||||||
"Create Role": "创建角色",
|
"Create Role": "创建角色",
|
||||||
"Edit": "编辑",
|
"Edit": "编辑",
|
||||||
|
"Submit": "提交",
|
||||||
"Super Admin": "超级管理员",
|
"Super Admin": "超级管理员",
|
||||||
"Disabled": "已禁用",
|
"Disabled": "已禁用",
|
||||||
"Active": "已启用",
|
"Active": "已启用",
|
||||||
@@ -773,6 +785,7 @@
|
|||||||
"Add Path Rule": "添加路径规则",
|
"Add Path Rule": "添加路径规则",
|
||||||
"Edit Path Rule": "编辑路径规则",
|
"Edit Path Rule": "编辑路径规则",
|
||||||
"Path Pattern": "路径模式",
|
"Path Pattern": "路径模式",
|
||||||
|
"Regex": "正则",
|
||||||
"Is Regex": "正则表达式",
|
"Is Regex": "正则表达式",
|
||||||
"Priority": "优先级",
|
"Priority": "优先级",
|
||||||
"Higher value = higher priority": "数值越大优先级越高",
|
"Higher value = higher priority": "数值越大优先级越高",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
title: 'System',
|
title: 'System',
|
||||||
children: [
|
children: [
|
||||||
{ key: 'users', icon: React.createElement(UserOutlined), label: 'User Management', adminOnly: true },
|
{ key: 'users', icon: React.createElement(UserOutlined), label: 'User Management', adminOnly: true },
|
||||||
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings' },
|
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings', adminOnly: true },
|
||||||
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
|
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
|
||||||
{ key: 'audit', icon: React.createElement(BugOutlined), label: 'Audit Logs' }
|
{ key: 'audit', icon: React.createElement(BugOutlined), label: 'Audit Logs' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
import { memo, useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Table, Button, Space, Drawer, Form, Input, Switch, message,
|
|
||||||
Tag, Popconfirm, Checkbox, Collapse, Typography, InputNumber, Divider
|
|
||||||
} from 'antd';
|
|
||||||
import { LockOutlined, FolderOutlined } from '@ant-design/icons';
|
|
||||||
import PageCard from '../../components/PageCard';
|
|
||||||
import { rolesApi, type PathRuleCreate, type PathRuleInfo, type RoleDetail, type RoleInfo } from '../../api/roles';
|
|
||||||
import { permissionsApi, type PermissionInfo } from '../../api/permissions';
|
|
||||||
import { useI18n } from '../../i18n';
|
|
||||||
|
|
||||||
const RolesPage = memo(function RolesPage() {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
|
||||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<RoleDetail | null>(null);
|
|
||||||
const [pathRules, setPathRules] = useState<PathRuleInfo[]>([]);
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [ruleForm] = Form.useForm();
|
|
||||||
const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false);
|
|
||||||
const [editingRule, setEditingRule] = useState<PathRuleInfo | null>(null);
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [roleList, permList] = await Promise.all([
|
|
||||||
rolesApi.list(),
|
|
||||||
permissionsApi.listAll(),
|
|
||||||
]);
|
|
||||||
setRoles(roleList);
|
|
||||||
setPermissions(permList);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Load failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditing(null);
|
|
||||||
setPathRules([]);
|
|
||||||
form.resetFields();
|
|
||||||
form.setFieldsValue({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
permissions: [],
|
|
||||||
});
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = async (rec: RoleInfo) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [detail, rules] = await Promise.all([
|
|
||||||
rolesApi.get(rec.id),
|
|
||||||
rolesApi.getPathRules(rec.id),
|
|
||||||
]);
|
|
||||||
setEditing(detail);
|
|
||||||
setPathRules(rules);
|
|
||||||
form.resetFields();
|
|
||||||
form.setFieldsValue({
|
|
||||||
name: detail.name,
|
|
||||||
description: detail.description || '',
|
|
||||||
permissions: detail.permissions,
|
|
||||||
});
|
|
||||||
setOpen(true);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Load failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (editing) {
|
|
||||||
// 更新角色
|
|
||||||
await rolesApi.update(editing.id, {
|
|
||||||
name: values.name.trim(),
|
|
||||||
description: values.description || null,
|
|
||||||
});
|
|
||||||
// 更新权限
|
|
||||||
await rolesApi.setPermissions(editing.id, values.permissions || []);
|
|
||||||
message.success(t('Updated successfully'));
|
|
||||||
} else {
|
|
||||||
// 创建角色
|
|
||||||
const newRole = await rolesApi.create({
|
|
||||||
name: values.name.trim(),
|
|
||||||
description: values.description || null,
|
|
||||||
});
|
|
||||||
// 设置权限
|
|
||||||
if (values.permissions?.length) {
|
|
||||||
await rolesApi.setPermissions(newRole.id, values.permissions);
|
|
||||||
}
|
|
||||||
message.success(t('Created successfully'));
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
setEditing(null);
|
|
||||||
fetchData();
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.errorFields) return;
|
|
||||||
message.error(e.message || t('Operation failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doDelete = async (rec: RoleInfo) => {
|
|
||||||
try {
|
|
||||||
await rolesApi.remove(rec.id);
|
|
||||||
message.success(t('Deleted'));
|
|
||||||
fetchData();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Delete failed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 路径规则管理
|
|
||||||
const openAddRule = () => {
|
|
||||||
setEditingRule(null);
|
|
||||||
ruleForm.resetFields();
|
|
||||||
ruleForm.setFieldsValue({
|
|
||||||
path_pattern: '/',
|
|
||||||
is_regex: false,
|
|
||||||
can_read: true,
|
|
||||||
can_write: false,
|
|
||||||
can_delete: false,
|
|
||||||
can_share: false,
|
|
||||||
priority: 0,
|
|
||||||
});
|
|
||||||
setRuleDrawerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditRule = (rule: PathRuleInfo) => {
|
|
||||||
setEditingRule(rule);
|
|
||||||
ruleForm.resetFields();
|
|
||||||
ruleForm.setFieldsValue({
|
|
||||||
path_pattern: rule.path_pattern,
|
|
||||||
is_regex: rule.is_regex,
|
|
||||||
can_read: rule.can_read,
|
|
||||||
can_write: rule.can_write,
|
|
||||||
can_delete: rule.can_delete,
|
|
||||||
can_share: rule.can_share,
|
|
||||||
priority: rule.priority,
|
|
||||||
});
|
|
||||||
setRuleDrawerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitRule = async () => {
|
|
||||||
if (!editing) return;
|
|
||||||
try {
|
|
||||||
const values = await ruleForm.validateFields();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const ruleData: PathRuleCreate = {
|
|
||||||
path_pattern: values.path_pattern,
|
|
||||||
is_regex: values.is_regex,
|
|
||||||
can_read: values.can_read,
|
|
||||||
can_write: values.can_write,
|
|
||||||
can_delete: values.can_delete,
|
|
||||||
can_share: values.can_share,
|
|
||||||
priority: values.priority,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingRule) {
|
|
||||||
await rolesApi.updatePathRule(editingRule.id, ruleData);
|
|
||||||
message.success(t('Updated successfully'));
|
|
||||||
} else {
|
|
||||||
await rolesApi.addPathRule(editing.id, ruleData);
|
|
||||||
message.success(t('Created successfully'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新规则列表
|
|
||||||
const rules = await rolesApi.getPathRules(editing.id);
|
|
||||||
setPathRules(rules);
|
|
||||||
setRuleDrawerOpen(false);
|
|
||||||
setEditingRule(null);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.errorFields) return;
|
|
||||||
message.error(e.message || t('Operation failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteRule = async (rule: PathRuleInfo) => {
|
|
||||||
if (!editing) return;
|
|
||||||
try {
|
|
||||||
await rolesApi.deletePathRule(rule.id);
|
|
||||||
message.success(t('Deleted'));
|
|
||||||
const rules = await rolesApi.getPathRules(editing.id);
|
|
||||||
setPathRules(rules);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Delete failed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 按分类分组权限
|
|
||||||
const groupedPermissions = permissions.reduce((acc, p) => {
|
|
||||||
if (!acc[p.category]) acc[p.category] = [];
|
|
||||||
acc[p.category].push(p);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, PermissionInfo[]>);
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: t('Role Name'),
|
|
||||||
dataIndex: 'name',
|
|
||||||
render: (value: string, rec: RoleInfo) => (
|
|
||||||
<Space>
|
|
||||||
<LockOutlined />
|
|
||||||
{value}
|
|
||||||
{rec.is_system && <Tag color="blue">{t('System')}</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: t('Description'), dataIndex: 'description', render: (v: string | null) => v || '-' },
|
|
||||||
{
|
|
||||||
title: t('Created At'),
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
width: 180,
|
|
||||||
render: (v: string) => new Date(v).toLocaleString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Actions'),
|
|
||||||
width: 160,
|
|
||||||
render: (_: any, rec: RoleInfo) => (
|
|
||||||
<Space size="small">
|
|
||||||
<Button size="small" onClick={() => openEdit(rec)}>{t('Edit')}</Button>
|
|
||||||
{!rec.is_system && (
|
|
||||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDelete(rec)}>
|
|
||||||
<Button size="small" danger>{t('Delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ruleColumns = [
|
|
||||||
{
|
|
||||||
title: t('Path Pattern'),
|
|
||||||
dataIndex: 'path_pattern',
|
|
||||||
render: (v: string, rec: PathRuleInfo) => (
|
|
||||||
<Space>
|
|
||||||
<FolderOutlined />
|
|
||||||
<code>{v}</code>
|
|
||||||
{rec.is_regex && <Tag color="purple">Regex</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Permissions'),
|
|
||||||
render: (_: any, rec: PathRuleInfo) => (
|
|
||||||
<Space size={[0, 4]} wrap>
|
|
||||||
{rec.can_read && <Tag color="green">{t('Read')}</Tag>}
|
|
||||||
{rec.can_write && <Tag color="blue">{t('Write')}</Tag>}
|
|
||||||
{rec.can_delete && <Tag color="red">{t('Delete')}</Tag>}
|
|
||||||
{rec.can_share && <Tag color="orange">{t('Share')}</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Priority'),
|
|
||||||
dataIndex: 'priority',
|
|
||||||
width: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Actions'),
|
|
||||||
width: 140,
|
|
||||||
render: (_: any, rec: PathRuleInfo) => (
|
|
||||||
<Space size="small">
|
|
||||||
<Button size="small" onClick={() => openEditRule(rec)}>{t('Edit')}</Button>
|
|
||||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => deleteRule(rec)}>
|
|
||||||
<Button size="small" danger>{t('Delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageCard
|
|
||||||
title={t('Role Management')}
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button>
|
|
||||||
<Button type="primary" onClick={openCreate}>{t('Create Role')}</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
dataSource={roles}
|
|
||||||
columns={columns as any}
|
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
|
||||||
style={{ marginBottom: 0 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 角色编辑抽屉 */}
|
|
||||||
<Drawer
|
|
||||||
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Role')}
|
|
||||||
width={600}
|
|
||||||
open={open}
|
|
||||||
onClose={() => { setOpen(false); setEditing(null); }}
|
|
||||||
destroyOnHidden
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
|
|
||||||
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label={t('Role Name')}
|
|
||||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
|
|
||||||
>
|
|
||||||
<Input placeholder={t('Role Name')} disabled={editing?.is_system} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="description" label={t('Description')}>
|
|
||||||
<Input.TextArea placeholder={t('Description')} rows={2} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Divider>{t('System Permissions')}</Divider>
|
|
||||||
<Form.Item name="permissions" label={t('Permissions')}>
|
|
||||||
<Checkbox.Group style={{ width: '100%' }}>
|
|
||||||
<Collapse
|
|
||||||
items={Object.entries(groupedPermissions).map(([category, perms]) => ({
|
|
||||||
key: category,
|
|
||||||
label: t(`permission.category.${category}`) === `permission.category.${category}`
|
|
||||||
? category.charAt(0).toUpperCase() + category.slice(1)
|
|
||||||
: t(`permission.category.${category}`),
|
|
||||||
children: (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
|
||||||
{perms.map(p => (
|
|
||||||
<Checkbox key={p.code} value={p.code}>
|
|
||||||
{p.name}
|
|
||||||
{p.description && (
|
|
||||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
|
||||||
{p.description}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</Checkbox>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Checkbox.Group>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{editing && (
|
|
||||||
<>
|
|
||||||
<Divider>{t('Path Rules')}</Divider>
|
|
||||||
<Space style={{ marginBottom: 16 }}>
|
|
||||||
<Button type="primary" size="small" onClick={openAddRule}>
|
|
||||||
{t('Add Path Rule')}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
dataSource={pathRules}
|
|
||||||
columns={ruleColumns as any}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
{/* 路径规则编辑抽屉 */}
|
|
||||||
<Drawer
|
|
||||||
title={editingRule ? t('Edit Path Rule') : t('Add Path Rule')}
|
|
||||||
width={400}
|
|
||||||
open={ruleDrawerOpen}
|
|
||||||
onClose={() => { setRuleDrawerOpen(false); setEditingRule(null); }}
|
|
||||||
destroyOnHidden
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={() => { setRuleDrawerOpen(false); setEditingRule(null); }}>{t('Cancel')}</Button>
|
|
||||||
<Button type="primary" onClick={submitRule} loading={loading}>{t('Submit')}</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Form form={ruleForm} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
name="path_pattern"
|
|
||||||
label={t('Path Pattern')}
|
|
||||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Path Pattern') }) }]}
|
|
||||||
extra={t('Use * for single level, ** for any level. Example: /photos/** matches all files in photos folder.')}
|
|
||||||
>
|
|
||||||
<Input placeholder="/photos/**" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="is_regex" label={t('Is Regex')} valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="priority" label={t('Priority')} extra={t('Higher value = higher priority')}>
|
|
||||||
<InputNumber style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Divider>{t('Permissions')}</Divider>
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
|
||||||
<Form.Item name="can_read" valuePropName="checked" noStyle>
|
|
||||||
<Checkbox>{t('Read')} - {t('Download and preview files')}</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="can_write" valuePropName="checked" noStyle>
|
|
||||||
<Checkbox>{t('Write')} - {t('Upload and modify files')}</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="can_delete" valuePropName="checked" noStyle>
|
|
||||||
<Checkbox>{t('Delete')} - {t('Delete files and folders')}</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="can_share" valuePropName="checked" noStyle>
|
|
||||||
<Checkbox>{t('Share')} - {t('Create share links')}</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
</Space>
|
|
||||||
</Form>
|
|
||||||
</Drawer>
|
|
||||||
</PageCard>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default RolesPage;
|
|
||||||
@@ -1,863 +0,0 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Collapse,
|
|
||||||
Divider,
|
|
||||||
Drawer,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
Modal,
|
|
||||||
Popconfirm,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
Tabs,
|
|
||||||
Tag,
|
|
||||||
Typography,
|
|
||||||
message,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
CrownOutlined,
|
|
||||||
FolderOutlined,
|
|
||||||
LockOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import PageCard from '../../components/PageCard';
|
|
||||||
import { usersApi, type UserDetail, type UserInfo } from '../../api/users';
|
|
||||||
import {
|
|
||||||
rolesApi,
|
|
||||||
type PathRuleCreate,
|
|
||||||
type PathRuleInfo,
|
|
||||||
type RoleDetail,
|
|
||||||
type RoleInfo,
|
|
||||||
} from '../../api/roles';
|
|
||||||
import { permissionsApi, type PermissionInfo } from '../../api/permissions';
|
|
||||||
import { useI18n } from '../../i18n';
|
|
||||||
|
|
||||||
type TabKey = 'users' | 'roles';
|
|
||||||
|
|
||||||
const UsersPage = memo(function UsersPage() {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('users');
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
|
|
||||||
const [users, setUsers] = useState<UserInfo[]>([]);
|
|
||||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
|
||||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
|
||||||
|
|
||||||
const [userDrawerOpen, setUserDrawerOpen] = useState(false);
|
|
||||||
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
|
|
||||||
const [userForm] = Form.useForm();
|
|
||||||
|
|
||||||
const [roleDrawerOpen, setRoleDrawerOpen] = useState(false);
|
|
||||||
const [editingRole, setEditingRole] = useState<RoleDetail | null>(null);
|
|
||||||
const [pathRules, setPathRules] = useState<PathRuleInfo[]>([]);
|
|
||||||
const [roleUsers, setRoleUsers] = useState<UserInfo[]>([]);
|
|
||||||
const [roleForm] = Form.useForm();
|
|
||||||
|
|
||||||
const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false);
|
|
||||||
const [editingRule, setEditingRule] = useState<PathRuleInfo | null>(null);
|
|
||||||
const [ruleForm] = Form.useForm();
|
|
||||||
|
|
||||||
const [quickRoleModalOpen, setQuickRoleModalOpen] = useState(false);
|
|
||||||
const [quickRoleForm] = Form.useForm();
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [userList, roleList, permList] = await Promise.all([
|
|
||||||
usersApi.list(),
|
|
||||||
rolesApi.list(),
|
|
||||||
permissionsApi.listAll(),
|
|
||||||
]);
|
|
||||||
setUsers(userList);
|
|
||||||
setRoles(roleList);
|
|
||||||
setPermissions(permList);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Load failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const normalizedSearch = searchText.trim().toLowerCase();
|
|
||||||
const filteredUsers = useMemo(() => {
|
|
||||||
if (!normalizedSearch) return users;
|
|
||||||
return users.filter((u) => {
|
|
||||||
const haystacks = [
|
|
||||||
u.username,
|
|
||||||
u.email ?? '',
|
|
||||||
u.full_name ?? '',
|
|
||||||
].map(v => v.toLowerCase());
|
|
||||||
return haystacks.some(v => v.includes(normalizedSearch));
|
|
||||||
});
|
|
||||||
}, [normalizedSearch, users]);
|
|
||||||
const filteredRoles = useMemo(() => {
|
|
||||||
if (!normalizedSearch) return roles;
|
|
||||||
return roles.filter((r) => {
|
|
||||||
const haystacks = [
|
|
||||||
r.name,
|
|
||||||
r.description ?? '',
|
|
||||||
].map(v => v.toLowerCase());
|
|
||||||
return haystacks.some(v => v.includes(normalizedSearch));
|
|
||||||
});
|
|
||||||
}, [normalizedSearch, roles]);
|
|
||||||
|
|
||||||
const groupedPermissions = useMemo(() => {
|
|
||||||
return permissions.reduce((acc, p) => {
|
|
||||||
if (!acc[p.category]) acc[p.category] = [];
|
|
||||||
acc[p.category].push(p);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, PermissionInfo[]>);
|
|
||||||
}, [permissions]);
|
|
||||||
|
|
||||||
// --- User ops ---
|
|
||||||
const openCreateUser = () => {
|
|
||||||
setEditingUser(null);
|
|
||||||
userForm.resetFields();
|
|
||||||
userForm.setFieldsValue({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
email: '',
|
|
||||||
full_name: '',
|
|
||||||
is_admin: false,
|
|
||||||
disabled: false,
|
|
||||||
role_ids: [],
|
|
||||||
});
|
|
||||||
setUserDrawerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditUser = async (rec: UserInfo) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const detail = await usersApi.get(rec.id);
|
|
||||||
setEditingUser(detail);
|
|
||||||
userForm.resetFields();
|
|
||||||
const roleIds = roles
|
|
||||||
.filter(r => detail.roles.includes(r.name))
|
|
||||||
.map(r => r.id);
|
|
||||||
userForm.setFieldsValue({
|
|
||||||
username: detail.username,
|
|
||||||
password: '',
|
|
||||||
email: detail.email || '',
|
|
||||||
full_name: detail.full_name || '',
|
|
||||||
is_admin: detail.is_admin,
|
|
||||||
disabled: detail.disabled,
|
|
||||||
role_ids: roleIds,
|
|
||||||
});
|
|
||||||
setUserDrawerOpen(true);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Load failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitUser = async () => {
|
|
||||||
try {
|
|
||||||
const values = await userForm.validateFields();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (editingUser) {
|
|
||||||
const updateData: any = {
|
|
||||||
email: values.email || null,
|
|
||||||
full_name: values.full_name || null,
|
|
||||||
is_admin: values.is_admin,
|
|
||||||
disabled: values.disabled,
|
|
||||||
};
|
|
||||||
if (values.password) updateData.password = values.password;
|
|
||||||
await usersApi.update(editingUser.id, updateData);
|
|
||||||
await usersApi.setRoles(editingUser.id, values.role_ids || []);
|
|
||||||
message.success(t('Updated successfully'));
|
|
||||||
} else {
|
|
||||||
await usersApi.create({
|
|
||||||
username: values.username.trim(),
|
|
||||||
password: values.password,
|
|
||||||
email: values.email || null,
|
|
||||||
full_name: values.full_name || null,
|
|
||||||
is_admin: values.is_admin,
|
|
||||||
disabled: values.disabled,
|
|
||||||
role_ids: values.role_ids || [],
|
|
||||||
});
|
|
||||||
message.success(t('Created successfully'));
|
|
||||||
}
|
|
||||||
|
|
||||||
setUserDrawerOpen(false);
|
|
||||||
setEditingUser(null);
|
|
||||||
await fetchData();
|
|
||||||
|
|
||||||
if (editingRole) {
|
|
||||||
const nextRoleUsers = await rolesApi.getUsers(editingRole.id);
|
|
||||||
setRoleUsers(nextRoleUsers);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.errorFields) return;
|
|
||||||
message.error(e.message || t('Operation failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doDeleteUser = async (rec: UserInfo) => {
|
|
||||||
try {
|
|
||||||
await usersApi.remove(rec.id);
|
|
||||||
message.success(t('Deleted'));
|
|
||||||
fetchData();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Delete failed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleDisabled = async (rec: UserInfo, disabled: boolean) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await usersApi.update(rec.id, { disabled });
|
|
||||||
message.success(t('Status updated'));
|
|
||||||
fetchData();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Update failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Quick create role (for user drawer) ---
|
|
||||||
const openQuickCreateRole = () => {
|
|
||||||
quickRoleForm.resetFields();
|
|
||||||
quickRoleForm.setFieldsValue({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
});
|
|
||||||
setQuickRoleModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitQuickRole = async () => {
|
|
||||||
try {
|
|
||||||
const values = await quickRoleForm.validateFields();
|
|
||||||
setLoading(true);
|
|
||||||
const newRole = await rolesApi.create({
|
|
||||||
name: values.name.trim(),
|
|
||||||
description: values.description || null,
|
|
||||||
});
|
|
||||||
message.success(t('Created successfully'));
|
|
||||||
setRoles((prev) => [...prev, newRole].sort((a, b) => a.id - b.id));
|
|
||||||
|
|
||||||
const currentIds = (userForm.getFieldValue('role_ids') || []) as number[];
|
|
||||||
const nextIds = Array.from(new Set([...currentIds, newRole.id]));
|
|
||||||
userForm.setFieldsValue({ role_ids: nextIds });
|
|
||||||
|
|
||||||
setQuickRoleModalOpen(false);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.errorFields) return;
|
|
||||||
message.error(e.message || t('Operation failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Role ops ---
|
|
||||||
const openCreateRole = () => {
|
|
||||||
setEditingRole(null);
|
|
||||||
setPathRules([]);
|
|
||||||
setRoleUsers([]);
|
|
||||||
roleForm.resetFields();
|
|
||||||
roleForm.setFieldsValue({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
permissions: [],
|
|
||||||
});
|
|
||||||
setRoleDrawerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditRole = async (rec: RoleInfo) => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [detail, rules, usersUsingRole] = await Promise.all([
|
|
||||||
rolesApi.get(rec.id),
|
|
||||||
rolesApi.getPathRules(rec.id),
|
|
||||||
rolesApi.getUsers(rec.id),
|
|
||||||
]);
|
|
||||||
setEditingRole(detail);
|
|
||||||
setPathRules(rules);
|
|
||||||
setRoleUsers(usersUsingRole);
|
|
||||||
roleForm.resetFields();
|
|
||||||
roleForm.setFieldsValue({
|
|
||||||
name: detail.name,
|
|
||||||
description: detail.description || '',
|
|
||||||
permissions: detail.permissions,
|
|
||||||
});
|
|
||||||
setRoleDrawerOpen(true);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Load failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitRole = async () => {
|
|
||||||
try {
|
|
||||||
const values = await roleForm.validateFields();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
if (editingRole) {
|
|
||||||
await rolesApi.update(editingRole.id, {
|
|
||||||
name: values.name.trim(),
|
|
||||||
description: values.description || null,
|
|
||||||
});
|
|
||||||
await rolesApi.setPermissions(editingRole.id, values.permissions || []);
|
|
||||||
message.success(t('Updated successfully'));
|
|
||||||
} else {
|
|
||||||
const newRole = await rolesApi.create({
|
|
||||||
name: values.name.trim(),
|
|
||||||
description: values.description || null,
|
|
||||||
});
|
|
||||||
if (values.permissions?.length) {
|
|
||||||
await rolesApi.setPermissions(newRole.id, values.permissions);
|
|
||||||
}
|
|
||||||
message.success(t('Created successfully'));
|
|
||||||
}
|
|
||||||
|
|
||||||
setRoleDrawerOpen(false);
|
|
||||||
setEditingRole(null);
|
|
||||||
await fetchData();
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.errorFields) return;
|
|
||||||
message.error(e.message || t('Operation failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doDeleteRole = async (rec: RoleInfo) => {
|
|
||||||
try {
|
|
||||||
await rolesApi.remove(rec.id);
|
|
||||||
message.success(t('Deleted'));
|
|
||||||
fetchData();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Delete failed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Path rules ---
|
|
||||||
const openAddRule = () => {
|
|
||||||
setEditingRule(null);
|
|
||||||
ruleForm.resetFields();
|
|
||||||
ruleForm.setFieldsValue({
|
|
||||||
path_pattern: '/',
|
|
||||||
is_regex: false,
|
|
||||||
can_read: true,
|
|
||||||
can_write: false,
|
|
||||||
can_delete: false,
|
|
||||||
can_share: false,
|
|
||||||
priority: 0,
|
|
||||||
});
|
|
||||||
setRuleDrawerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditRule = (rule: PathRuleInfo) => {
|
|
||||||
setEditingRule(rule);
|
|
||||||
ruleForm.resetFields();
|
|
||||||
ruleForm.setFieldsValue({
|
|
||||||
path_pattern: rule.path_pattern,
|
|
||||||
is_regex: rule.is_regex,
|
|
||||||
can_read: rule.can_read,
|
|
||||||
can_write: rule.can_write,
|
|
||||||
can_delete: rule.can_delete,
|
|
||||||
can_share: rule.can_share,
|
|
||||||
priority: rule.priority,
|
|
||||||
});
|
|
||||||
setRuleDrawerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitRule = async () => {
|
|
||||||
if (!editingRole) return;
|
|
||||||
try {
|
|
||||||
const values = await ruleForm.validateFields();
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const ruleData: PathRuleCreate = {
|
|
||||||
path_pattern: values.path_pattern,
|
|
||||||
is_regex: values.is_regex,
|
|
||||||
can_read: values.can_read,
|
|
||||||
can_write: values.can_write,
|
|
||||||
can_delete: values.can_delete,
|
|
||||||
can_share: values.can_share,
|
|
||||||
priority: values.priority,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingRule) {
|
|
||||||
await rolesApi.updatePathRule(editingRule.id, ruleData);
|
|
||||||
message.success(t('Updated successfully'));
|
|
||||||
} else {
|
|
||||||
await rolesApi.addPathRule(editingRole.id, ruleData);
|
|
||||||
message.success(t('Created successfully'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const rules = await rolesApi.getPathRules(editingRole.id);
|
|
||||||
setPathRules(rules);
|
|
||||||
setRuleDrawerOpen(false);
|
|
||||||
setEditingRule(null);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e?.errorFields) return;
|
|
||||||
message.error(e.message || t('Operation failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteRule = async (rule: PathRuleInfo) => {
|
|
||||||
if (!editingRole) return;
|
|
||||||
try {
|
|
||||||
await rolesApi.deletePathRule(rule.id);
|
|
||||||
message.success(t('Deleted'));
|
|
||||||
const rules = await rolesApi.getPathRules(editingRole.id);
|
|
||||||
setPathRules(rules);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || t('Delete failed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const userColumns = [
|
|
||||||
{
|
|
||||||
title: t('Username'),
|
|
||||||
dataIndex: 'username',
|
|
||||||
render: (value: string, rec: UserInfo) => (
|
|
||||||
<Space>
|
|
||||||
{rec.is_admin ? <CrownOutlined style={{ color: '#faad14' }} /> : <UserOutlined />}
|
|
||||||
{value}
|
|
||||||
{rec.is_admin && <Tag color="gold">{t('Admin')}</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' },
|
|
||||||
{ title: t('Full Name'), dataIndex: 'full_name', render: (v: string | null) => v || '-' },
|
|
||||||
{
|
|
||||||
title: t('Status'),
|
|
||||||
dataIndex: 'disabled',
|
|
||||||
width: 100,
|
|
||||||
render: (disabled: boolean, rec: UserInfo) => (
|
|
||||||
<Switch
|
|
||||||
checked={!disabled}
|
|
||||||
size="small"
|
|
||||||
loading={loading}
|
|
||||||
onChange={(checked) => handleToggleDisabled(rec, !checked)}
|
|
||||||
checkedChildren={t('Active')}
|
|
||||||
unCheckedChildren={t('Disabled')}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Last Login'),
|
|
||||||
dataIndex: 'last_login',
|
|
||||||
width: 180,
|
|
||||||
render: (v: string | null) => v ? new Date(v).toLocaleString() : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Actions'),
|
|
||||||
width: 160,
|
|
||||||
render: (_: any, rec: UserInfo) => (
|
|
||||||
<Space size="small">
|
|
||||||
<Button size="small" onClick={() => openEditUser(rec)}>{t('Edit')}</Button>
|
|
||||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDeleteUser(rec)}>
|
|
||||||
<Button size="small" danger>{t('Delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const roleColumns = [
|
|
||||||
{
|
|
||||||
title: t('Role Name'),
|
|
||||||
dataIndex: 'name',
|
|
||||||
render: (value: string, rec: RoleInfo) => (
|
|
||||||
<Space>
|
|
||||||
<LockOutlined />
|
|
||||||
{value}
|
|
||||||
{rec.is_system && <Tag color="blue">{t('System')}</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: t('Description'), dataIndex: 'description', render: (v: string | null) => v || '-' },
|
|
||||||
{
|
|
||||||
title: t('Created At'),
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
width: 180,
|
|
||||||
render: (v: string) => new Date(v).toLocaleString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Actions'),
|
|
||||||
width: 160,
|
|
||||||
render: (_: any, rec: RoleInfo) => (
|
|
||||||
<Space size="small">
|
|
||||||
<Button size="small" onClick={() => openEditRole(rec)}>{t('Edit')}</Button>
|
|
||||||
{!rec.is_system && (
|
|
||||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDeleteRole(rec)}>
|
|
||||||
<Button size="small" danger>{t('Delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ruleColumns = [
|
|
||||||
{
|
|
||||||
title: t('Path Pattern'),
|
|
||||||
dataIndex: 'path_pattern',
|
|
||||||
render: (v: string, rec: PathRuleInfo) => (
|
|
||||||
<Space>
|
|
||||||
<FolderOutlined />
|
|
||||||
<code>{v}</code>
|
|
||||||
{rec.is_regex && <Tag color="purple">Regex</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Permissions'),
|
|
||||||
render: (_: any, rec: PathRuleInfo) => (
|
|
||||||
<Space size={[0, 4]} wrap>
|
|
||||||
{rec.can_read && <Tag color="green">{t('Read')}</Tag>}
|
|
||||||
{rec.can_write && <Tag color="blue">{t('Write')}</Tag>}
|
|
||||||
{rec.can_delete && <Tag color="red">{t('Delete')}</Tag>}
|
|
||||||
{rec.can_share && <Tag color="orange">{t('Share')}</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Priority'),
|
|
||||||
dataIndex: 'priority',
|
|
||||||
width: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Actions'),
|
|
||||||
width: 140,
|
|
||||||
render: (_: any, rec: PathRuleInfo) => (
|
|
||||||
<Space size="small">
|
|
||||||
<Button size="small" onClick={() => openEditRule(rec)}>{t('Edit')}</Button>
|
|
||||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => deleteRule(rec)}>
|
|
||||||
<Button size="small" danger>{t('Delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const roleUserColumns = [
|
|
||||||
{
|
|
||||||
title: t('Username'),
|
|
||||||
dataIndex: 'username',
|
|
||||||
render: (value: string, rec: UserInfo) => (
|
|
||||||
<Space>
|
|
||||||
{rec.is_admin ? <CrownOutlined style={{ color: '#faad14' }} /> : <UserOutlined />}
|
|
||||||
{value}
|
|
||||||
{rec.is_admin && <Tag color="gold">{t('Admin')}</Tag>}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' },
|
|
||||||
{
|
|
||||||
title: t('Status'),
|
|
||||||
dataIndex: 'disabled',
|
|
||||||
width: 90,
|
|
||||||
render: (disabled: boolean) => disabled ? <Tag>{t('Disabled')}</Tag> : <Tag color="green">{t('Active')}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('Actions'),
|
|
||||||
width: 110,
|
|
||||||
render: (_: any, rec: UserInfo) => (
|
|
||||||
<Button size="small" onClick={() => openEditUser(rec)}>{t('Edit')}</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const tabItems = [
|
|
||||||
{
|
|
||||||
key: 'users',
|
|
||||||
label: `${t('Users')} (${filteredUsers.length})`,
|
|
||||||
children: (
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
dataSource={filteredUsers}
|
|
||||||
columns={userColumns as any}
|
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
|
||||||
style={{ marginBottom: 0 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'roles',
|
|
||||||
label: `${t('Roles')} (${filteredRoles.length})`,
|
|
||||||
children: (
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
dataSource={filteredRoles}
|
|
||||||
columns={roleColumns as any}
|
|
||||||
loading={loading}
|
|
||||||
pagination={false}
|
|
||||||
style={{ marginBottom: 0 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageCard
|
|
||||||
title={t('User Management')}
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Input.Search
|
|
||||||
allowClear
|
|
||||||
value={searchText}
|
|
||||||
placeholder={t('Search users or roles')}
|
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
|
||||||
style={{ width: 260 }}
|
|
||||||
/>
|
|
||||||
<Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button>
|
|
||||||
<Button type="primary" onClick={() => { setActiveTab('users'); openCreateUser(); }}>
|
|
||||||
{t('Create User')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => { setActiveTab('roles'); openCreateRole(); }}>
|
|
||||||
{t('Create Role')}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tabs activeKey={activeTab} onChange={(k) => setActiveTab(k as TabKey)} items={tabItems} />
|
|
||||||
|
|
||||||
{/* User editor */}
|
|
||||||
<Drawer
|
|
||||||
title={editingUser ? `${t('Edit')}: ${editingUser.username}` : t('Create User')}
|
|
||||||
width={480}
|
|
||||||
open={userDrawerOpen}
|
|
||||||
onClose={() => { setUserDrawerOpen(false); setEditingUser(null); }}
|
|
||||||
destroyOnHidden
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={() => { setUserDrawerOpen(false); setEditingUser(null); }}>{t('Cancel')}</Button>
|
|
||||||
<Button type="primary" onClick={submitUser} loading={loading}>{t('Submit')}</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Form form={userForm} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
name="username"
|
|
||||||
label={t('Username')}
|
|
||||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Username') }) }]}
|
|
||||||
>
|
|
||||||
<Input placeholder={t('Username')} disabled={!!editingUser} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="password"
|
|
||||||
label={editingUser ? t('New Password (leave empty to keep current)') : t('Password')}
|
|
||||||
rules={editingUser ? [] : [{ required: true, message: t('Please input {label}', { label: t('Password') }) }]}
|
|
||||||
>
|
|
||||||
<Input.Password placeholder={t('Password')} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="email" label={t('Email')}>
|
|
||||||
<Input placeholder={t('Email')} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="full_name" label={t('Full Name')}>
|
|
||||||
<Input placeholder={t('Full Name')} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="role_ids"
|
|
||||||
label={(
|
|
||||||
<Space size={4}>
|
|
||||||
{t('Roles')}
|
|
||||||
<Button type="link" size="small" onClick={openQuickCreateRole}>
|
|
||||||
{t('Quick Create Role')}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
placeholder={t('Select roles')}
|
|
||||||
options={roles.map(r => ({
|
|
||||||
value: r.id,
|
|
||||||
label: r.name + (r.is_system ? ` (${t('System')})` : ''),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="is_admin" label={t('Super Admin')} valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="disabled" label={t('Disabled')} valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
{editingUser && (
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{t('Created by')}: {editingUser.created_by_username || '-'}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={t('Create Role')}
|
|
||||||
open={quickRoleModalOpen}
|
|
||||||
onCancel={() => setQuickRoleModalOpen(false)}
|
|
||||||
okText={t('Submit')}
|
|
||||||
cancelText={t('Cancel')}
|
|
||||||
confirmLoading={loading}
|
|
||||||
onOk={submitQuickRole}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<Form form={quickRoleForm} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label={t('Role Name')}
|
|
||||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
|
|
||||||
>
|
|
||||||
<Input placeholder={t('Role Name')} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="description" label={t('Description')}>
|
|
||||||
<Input.TextArea placeholder={t('Description')} rows={2} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Role editor */}
|
|
||||||
<Drawer
|
|
||||||
title={editingRole ? `${t('Edit')}: ${editingRole.name}` : t('Create Role')}
|
|
||||||
width={600}
|
|
||||||
open={roleDrawerOpen}
|
|
||||||
onClose={() => { setRoleDrawerOpen(false); setEditingRole(null); }}
|
|
||||||
destroyOnHidden
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={() => { setRoleDrawerOpen(false); setEditingRole(null); }}>{t('Cancel')}</Button>
|
|
||||||
<Button type="primary" onClick={submitRole} loading={loading}>{t('Submit')}</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Form form={roleForm} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label={t('Role Name')}
|
|
||||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
|
|
||||||
>
|
|
||||||
<Input placeholder={t('Role Name')} disabled={editingRole?.is_system} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="description" label={t('Description')}>
|
|
||||||
<Input.TextArea placeholder={t('Description')} rows={2} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Divider>{t('System Permissions')}</Divider>
|
|
||||||
<Form.Item name="permissions" label={t('Permissions')}>
|
|
||||||
<Checkbox.Group style={{ width: '100%' }}>
|
|
||||||
<Collapse
|
|
||||||
items={Object.entries(groupedPermissions).map(([category, perms]) => ({
|
|
||||||
key: category,
|
|
||||||
label: t(`permission.category.${category}`) === `permission.category.${category}`
|
|
||||||
? category.charAt(0).toUpperCase() + category.slice(1)
|
|
||||||
: t(`permission.category.${category}`),
|
|
||||||
children: (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
|
||||||
{perms.map(p => (
|
|
||||||
<Checkbox key={p.code} value={p.code}>
|
|
||||||
{p.name}
|
|
||||||
{p.description && (
|
|
||||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
|
||||||
{p.description}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</Checkbox>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Checkbox.Group>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{editingRole && (
|
|
||||||
<>
|
|
||||||
<Divider>{t('Path Rules')}</Divider>
|
|
||||||
<Space style={{ marginBottom: 16 }}>
|
|
||||||
<Button type="primary" size="small" onClick={openAddRule}>
|
|
||||||
{t('Add Path Rule')}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
dataSource={pathRules}
|
|
||||||
columns={ruleColumns as any}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider>{t('Users')}</Divider>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
dataSource={roleUsers}
|
|
||||||
columns={roleUserColumns as any}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
{/* Path rule editor */}
|
|
||||||
<Drawer
|
|
||||||
title={editingRule ? t('Edit Path Rule') : t('Add Path Rule')}
|
|
||||||
width={400}
|
|
||||||
open={ruleDrawerOpen}
|
|
||||||
onClose={() => { setRuleDrawerOpen(false); setEditingRule(null); }}
|
|
||||||
destroyOnHidden
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button onClick={() => { setRuleDrawerOpen(false); setEditingRule(null); }}>{t('Cancel')}</Button>
|
|
||||||
<Button type="primary" onClick={submitRule} loading={loading}>{t('Submit')}</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Form form={ruleForm} layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
name="path_pattern"
|
|
||||||
label={t('Path Pattern')}
|
|
||||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Path Pattern') }) }]}
|
|
||||||
extra={t('Use * for single level, ** for any level. Example: /photos/** matches all files in photos folder.')}
|
|
||||||
>
|
|
||||||
<Input placeholder="/photos/**" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="is_regex" label={t('Is Regex')} valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="priority" label={t('Priority')} extra={t('Higher value = higher priority')}>
|
|
||||||
<InputNumber style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Divider>{t('Permissions')}</Divider>
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
|
||||||
<Form.Item name="can_read" valuePropName="checked" noStyle>
|
|
||||||
<Checkbox>{t('Read')} - {t('Download and preview files')}</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="can_write" valuePropName="checked" noStyle>
|
|
||||||
<Checkbox>{t('Write')} - {t('Upload and modify files')}</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="can_delete" valuePropName="checked" noStyle>
|
|
||||||
<Checkbox>{t('Delete')} - {t('Delete files and folders')}</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="can_share" valuePropName="checked" noStyle>
|
|
||||||
<Checkbox>{t('Share')} - {t('Create share links')}</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
</Space>
|
|
||||||
</Form>
|
|
||||||
</Drawer>
|
|
||||||
</PageCard>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default UsersPage;
|
|
||||||
@@ -123,6 +123,12 @@ export default function LoginPage() {
|
|||||||
{t('Sign In')}
|
{t('Sign In')}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||||
|
<Button type="link" onClick={() => navigate('/register')} style={{ padding: 0 }}>
|
||||||
|
{t('Sign Up')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, Form, Input, Button, Typography, Space, Alert } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useNavigate, Navigate } from 'react-router';
|
||||||
|
import { useI18n } from '../i18n';
|
||||||
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const { isAuthenticated, register, login } = useAuth();
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFinish = async (values: any) => {
|
||||||
|
const username = String(values.username || '').trim();
|
||||||
|
const email = String(values.email || '').trim();
|
||||||
|
const full_name = String(values.full_name || '').trim();
|
||||||
|
const password = String(values.password || '');
|
||||||
|
|
||||||
|
setErr('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await register(username, password, email, full_name || undefined);
|
||||||
|
await login(username, password);
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e.message || t('Register failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))'
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card style={{ width: 420 }}>
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Title level={2} style={{ marginBottom: 8 }}>{t('Create Account')}</Title>
|
||||||
|
<Text type="secondary">{t('Sign up to your Foxel account')}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <Alert message={err} type="error" showIcon />}
|
||||||
|
|
||||||
|
<Form layout="vertical" size="large" onFinish={onFinish}>
|
||||||
|
<Form.Item
|
||||||
|
label={t('Username')}
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: t('Please input username!') }]}
|
||||||
|
>
|
||||||
|
<Input prefix={<UserOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('Email')}
|
||||||
|
name="email"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: t('Please input email!') },
|
||||||
|
{ type: 'email', message: t('Please input a valid email!') },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input prefix={<MailOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('Full Name')}
|
||||||
|
name="full_name"
|
||||||
|
>
|
||||||
|
<Input prefix={<UserOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('Password')}
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: t('Please enter password') }]}
|
||||||
|
>
|
||||||
|
<Input.Password prefix={<LockOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('Confirm Password')}
|
||||||
|
name="confirm"
|
||||||
|
dependencies={['password']}
|
||||||
|
hasFeedback
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: t('Please confirm your password!') },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || getFieldValue('password') === value) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(t('Passwords do not match!')));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password prefix={<LockOutlined />} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginTop: 8 }}>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||||
|
{t('Sign Up')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Text type="secondary">{t('Already have an account?')}</Text>{' '}
|
||||||
|
<Button type="link" style={{ padding: 0 }} onClick={() => navigate('/login')}>
|
||||||
|
{t('Sign In')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -315,7 +315,10 @@ const SetupPage = () => {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('Email')}
|
label={t('Email')}
|
||||||
name="email"
|
name="email"
|
||||||
rules={[{ type: 'email', message: t('Please input a valid email!') }]}
|
rules={[
|
||||||
|
{ required: true, message: t('Please input email!') },
|
||||||
|
{ type: 'email', message: t('Please input a valid email!') },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input size="large" prefix={<UserOutlined />} />
|
<Input size="large" prefix={<UserOutlined />} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { message, Tabs, Space } from 'antd';
|
import { Alert, message, Tabs, Space } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import PageCard from '../../components/PageCard';
|
import PageCard from '../../components/PageCard';
|
||||||
import { getAllConfig, setConfig } from '../../api/config';
|
import { getAllConfig, setConfig } from '../../api/config';
|
||||||
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOutlined, CloudSyncOutlined } from '@ant-design/icons';
|
import { AppstoreOutlined, RobotOutlined, DatabaseOutlined, SkinOutlined, MailOutlined, CloudSyncOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import '../../styles/settings-tabs.css';
|
import '../../styles/settings-tabs.css';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import AppearanceSettingsTab from './components/AppearanceSettingsTab';
|
import AppearanceSettingsTab from './components/AppearanceSettingsTab';
|
||||||
import AppSettingsTab from './components/AppSettingsTab';
|
import AppSettingsTab from './components/AppSettingsTab';
|
||||||
|
import AuthSettingsTab from './components/AuthSettingsTab';
|
||||||
import AiSettingsTab from './components/AiSettingsTab';
|
import AiSettingsTab from './components/AiSettingsTab';
|
||||||
import VectorDbSettingsTab from './components/VectorDbSettingsTab';
|
import VectorDbSettingsTab from './components/VectorDbSettingsTab';
|
||||||
import EmailSettingsTab from './components/EmailSettingsTab';
|
import EmailSettingsTab from './components/EmailSettingsTab';
|
||||||
import ProtocolMappingsTab from './components/ProtocolMappingsTab';
|
import ProtocolMappingsTab from './components/ProtocolMappingsTab';
|
||||||
|
|
||||||
type TabKey = 'appearance' | 'app' | 'email' | 'ai' | 'vector-db' | 'mappings';
|
type TabKey = 'appearance' | 'app' | 'auth' | 'email' | 'ai' | 'vector-db' | 'mappings';
|
||||||
|
|
||||||
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'email', 'ai', 'vector-db', 'mappings'];
|
const TAB_KEYS: TabKey[] = ['appearance', 'app', 'auth', 'email', 'ai', 'vector-db', 'mappings'];
|
||||||
const DEFAULT_TAB: TabKey = 'appearance';
|
const DEFAULT_TAB: TabKey = 'appearance';
|
||||||
|
|
||||||
const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key);
|
const isValidTab = (key?: string): key is TabKey => !!key && (TAB_KEYS as string[]).includes(key);
|
||||||
@@ -45,6 +46,7 @@ const THEME_KEYS = {
|
|||||||
export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSettingsPageProps) {
|
export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSettingsPageProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>(() =>
|
const [activeTab, setActiveTab] = useState<TabKey>(() =>
|
||||||
isValidTab(tabKey) ? tabKey : DEFAULT_TAB
|
isValidTab(tabKey) ? tabKey : DEFAULT_TAB
|
||||||
);
|
);
|
||||||
@@ -52,8 +54,16 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAllConfig().then((data) => setConfigState(data as Record<string, string>));
|
getAllConfig()
|
||||||
}, []);
|
.then((data) => {
|
||||||
|
setLoadError(null);
|
||||||
|
setConfigState(data as Record<string, string>);
|
||||||
|
})
|
||||||
|
.catch((e: any) => {
|
||||||
|
setLoadError(e?.message || t('Load failed'));
|
||||||
|
setConfigState({});
|
||||||
|
});
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const handleSave = async (values: Record<string, unknown>) => {
|
const handleSave = async (values: Record<string, unknown>) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -102,6 +112,14 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
|||||||
onTabNavigate?.(nextKey);
|
onTabNavigate?.(nextKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<PageCard title={t('System Settings')}>
|
||||||
|
<Alert type="error" showIcon message={loadError} />
|
||||||
|
</PageCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return <PageCard title={t('System Settings')}><div>{t('Loading...')}</div></PageCard>;
|
return <PageCard title={t('System Settings')}><div>{t('Loading...')}</div></PageCard>;
|
||||||
}
|
}
|
||||||
@@ -151,6 +169,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'auth',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<UserOutlined style={{ marginRight: 8 }} />
|
||||||
|
{t('Registration Settings')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<AuthSettingsTab
|
||||||
|
config={config}
|
||||||
|
loading={loading}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
label: (
|
label: (
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Alert, Button, Form, Select, Switch, message } from 'antd';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { rolesApi, type RoleInfo } from '../../../api/roles';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
interface AuthSettingsTabProps {
|
||||||
|
config: Record<string, string>;
|
||||||
|
loading: boolean;
|
||||||
|
onSave: (values: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthSettingsTab({
|
||||||
|
config,
|
||||||
|
loading,
|
||||||
|
onSave,
|
||||||
|
}: AuthSettingsTabProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [rolesLoading, setRolesLoading] = useState(false);
|
||||||
|
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
async function loadRoles() {
|
||||||
|
setRolesLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await rolesApi.list();
|
||||||
|
if (!mounted) return;
|
||||||
|
setRoles(list);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('Load failed'));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setRolesLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadRoles();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const initialValues = useMemo(() => {
|
||||||
|
const allowRegister = (config.AUTH_ALLOW_REGISTER || '').trim().toLowerCase() === 'true';
|
||||||
|
const roleIdRaw = (config.AUTH_DEFAULT_REGISTER_ROLE_ID || '').trim();
|
||||||
|
const roleId = roleIdRaw ? Number(roleIdRaw) : undefined;
|
||||||
|
return {
|
||||||
|
AUTH_ALLOW_REGISTER: allowRegister,
|
||||||
|
AUTH_DEFAULT_REGISTER_ROLE_ID: Number.isFinite(roleId) ? roleId : undefined,
|
||||||
|
};
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={initialValues}
|
||||||
|
onFinish={async (vals) => {
|
||||||
|
const allow = !!vals.AUTH_ALLOW_REGISTER;
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
AUTH_ALLOW_REGISTER: allow ? 'true' : 'false',
|
||||||
|
};
|
||||||
|
if (allow) {
|
||||||
|
payload.AUTH_DEFAULT_REGISTER_ROLE_ID = String(vals.AUTH_DEFAULT_REGISTER_ROLE_ID);
|
||||||
|
}
|
||||||
|
await onSave(payload);
|
||||||
|
}}
|
||||||
|
style={{ marginTop: 24 }}
|
||||||
|
key={'auth-settings-' + (config.AUTH_ALLOW_REGISTER ?? '') + '-' + (config.AUTH_DEFAULT_REGISTER_ROLE_ID ?? '')}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={t('Enabling registration allows new users to sign up and assigns them the default role')}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="AUTH_ALLOW_REGISTER"
|
||||||
|
label={t('Enable Registration')}
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item dependencies={['AUTH_ALLOW_REGISTER']} noStyle>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const enabled = !!getFieldValue('AUTH_ALLOW_REGISTER');
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
name="AUTH_DEFAULT_REGISTER_ROLE_ID"
|
||||||
|
label={t('Default Role for New Registrations')}
|
||||||
|
rules={enabled ? [{ required: true, message: t('Please select default role') }] : []}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
size="large"
|
||||||
|
loading={rolesLoading}
|
||||||
|
disabled={!enabled || rolesLoading}
|
||||||
|
placeholder={t('Select roles')}
|
||||||
|
options={roles.map((r) => ({ value: r.id, label: r.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||||
|
{t('Save')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Button, Form, Input, Space, Tabs, message } from 'antd';
|
||||||
|
import PageCard from '../../components/PageCard';
|
||||||
|
import { usersApi, type UserDetail, type UserInfo } from '../../api/users';
|
||||||
|
import {
|
||||||
|
rolesApi,
|
||||||
|
type PathRuleCreate,
|
||||||
|
type PathRuleInfo,
|
||||||
|
type RoleDetail,
|
||||||
|
type RoleInfo,
|
||||||
|
} from '../../api/roles';
|
||||||
|
import { permissionsApi, type PermissionInfo } from '../../api/permissions';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
|
import { RolesTable } from './components/RolesTable';
|
||||||
|
import { RoleEditorDrawer } from './components/RoleEditorDrawer';
|
||||||
|
import { PathRuleEditorDrawer } from './components/PathRuleEditorDrawer';
|
||||||
|
import { QuickCreateRoleModal } from './components/QuickCreateRoleModal';
|
||||||
|
import { UserEditorDrawer } from './components/UserEditorDrawer';
|
||||||
|
import { UsersTable } from './components/UsersTable';
|
||||||
|
import type { RoleDrawerTab } from './types';
|
||||||
|
|
||||||
|
type TabKey = 'users' | 'roles';
|
||||||
|
|
||||||
|
const UsersPage = memo(function UsersPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>('users');
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<UserInfo[]>([]);
|
||||||
|
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||||
|
|
||||||
|
const [userDrawerOpen, setUserDrawerOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
|
||||||
|
const [userForm] = Form.useForm();
|
||||||
|
|
||||||
|
const [roleDrawerOpen, setRoleDrawerOpen] = useState(false);
|
||||||
|
const [editingRole, setEditingRole] = useState<RoleDetail | null>(null);
|
||||||
|
const [roleDrawerTab, setRoleDrawerTab] = useState<RoleDrawerTab>('basic');
|
||||||
|
const [pathRules, setPathRules] = useState<PathRuleInfo[]>([]);
|
||||||
|
const [roleUsers, setRoleUsers] = useState<UserInfo[]>([]);
|
||||||
|
const [roleForm] = Form.useForm();
|
||||||
|
|
||||||
|
const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false);
|
||||||
|
const [editingRule, setEditingRule] = useState<PathRuleInfo | null>(null);
|
||||||
|
const [ruleForm] = Form.useForm();
|
||||||
|
|
||||||
|
const [quickRoleModalOpen, setQuickRoleModalOpen] = useState(false);
|
||||||
|
const [quickRoleForm] = Form.useForm();
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [userList, roleList, permList] = await Promise.all([
|
||||||
|
usersApi.list(),
|
||||||
|
rolesApi.list(),
|
||||||
|
permissionsApi.listAll(),
|
||||||
|
]);
|
||||||
|
setUsers(userList);
|
||||||
|
setRoles(roleList);
|
||||||
|
setPermissions(permList);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('Load failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const normalizedSearch = searchText.trim().toLowerCase();
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
if (!normalizedSearch) return users;
|
||||||
|
return users.filter((u) => {
|
||||||
|
const haystacks = [
|
||||||
|
u.username,
|
||||||
|
u.email ?? '',
|
||||||
|
u.full_name ?? '',
|
||||||
|
].map(v => v.toLowerCase());
|
||||||
|
return haystacks.some(v => v.includes(normalizedSearch));
|
||||||
|
});
|
||||||
|
}, [normalizedSearch, users]);
|
||||||
|
const filteredRoles = useMemo(() => {
|
||||||
|
if (!normalizedSearch) return roles;
|
||||||
|
return roles.filter((r) => {
|
||||||
|
const haystacks = [
|
||||||
|
r.name,
|
||||||
|
r.description ?? '',
|
||||||
|
].map(v => v.toLowerCase());
|
||||||
|
return haystacks.some(v => v.includes(normalizedSearch));
|
||||||
|
});
|
||||||
|
}, [normalizedSearch, roles]);
|
||||||
|
|
||||||
|
const groupedPermissions = useMemo(() => {
|
||||||
|
return permissions.reduce((acc, p) => {
|
||||||
|
if (!acc[p.category]) acc[p.category] = [];
|
||||||
|
acc[p.category].push(p);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, PermissionInfo[]>);
|
||||||
|
}, [permissions]);
|
||||||
|
|
||||||
|
// --- User ops ---
|
||||||
|
const openCreateUser = () => {
|
||||||
|
setEditingUser(null);
|
||||||
|
userForm.resetFields();
|
||||||
|
userForm.setFieldsValue({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
email: '',
|
||||||
|
full_name: '',
|
||||||
|
is_admin: false,
|
||||||
|
disabled: false,
|
||||||
|
role_ids: [],
|
||||||
|
});
|
||||||
|
setUserDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditUser = async (rec: UserInfo) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const detail = await usersApi.get(rec.id);
|
||||||
|
setEditingUser(detail);
|
||||||
|
userForm.resetFields();
|
||||||
|
const roleIds = roles
|
||||||
|
.filter(r => detail.roles.includes(r.name))
|
||||||
|
.map(r => r.id);
|
||||||
|
userForm.setFieldsValue({
|
||||||
|
username: detail.username,
|
||||||
|
password: '',
|
||||||
|
email: detail.email || '',
|
||||||
|
full_name: detail.full_name || '',
|
||||||
|
is_admin: detail.is_admin,
|
||||||
|
disabled: detail.disabled,
|
||||||
|
role_ids: roleIds,
|
||||||
|
});
|
||||||
|
setUserDrawerOpen(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('Load failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitUser = async () => {
|
||||||
|
try {
|
||||||
|
const values = await userForm.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (editingUser) {
|
||||||
|
const updateData: any = {
|
||||||
|
email: values.email || null,
|
||||||
|
full_name: values.full_name || null,
|
||||||
|
is_admin: values.is_admin,
|
||||||
|
disabled: values.disabled,
|
||||||
|
};
|
||||||
|
if (values.password) updateData.password = values.password;
|
||||||
|
await usersApi.update(editingUser.id, updateData);
|
||||||
|
await usersApi.setRoles(editingUser.id, values.role_ids || []);
|
||||||
|
message.success(t('Updated successfully'));
|
||||||
|
} else {
|
||||||
|
await usersApi.create({
|
||||||
|
username: values.username.trim(),
|
||||||
|
password: values.password,
|
||||||
|
email: values.email || null,
|
||||||
|
full_name: values.full_name || null,
|
||||||
|
is_admin: values.is_admin,
|
||||||
|
disabled: values.disabled,
|
||||||
|
role_ids: values.role_ids || [],
|
||||||
|
});
|
||||||
|
message.success(t('Created successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserDrawerOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
await fetchData();
|
||||||
|
|
||||||
|
if (editingRole) {
|
||||||
|
const nextRoleUsers = await rolesApi.getUsers(editingRole.id);
|
||||||
|
setRoleUsers(nextRoleUsers);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e.message || t('Operation failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDeleteUser = async (rec: UserInfo) => {
|
||||||
|
try {
|
||||||
|
await usersApi.remove(rec.id);
|
||||||
|
message.success(t('Deleted'));
|
||||||
|
fetchData();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('Delete failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleDisabled = async (rec: UserInfo, disabled: boolean) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await usersApi.update(rec.id, { disabled });
|
||||||
|
message.success(t('Status updated'));
|
||||||
|
fetchData();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('Update failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Quick create role (for user drawer) ---
|
||||||
|
const openQuickCreateRole = () => {
|
||||||
|
quickRoleForm.resetFields();
|
||||||
|
quickRoleForm.setFieldsValue({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
setQuickRoleModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitQuickRole = async () => {
|
||||||
|
try {
|
||||||
|
const values = await quickRoleForm.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
const newRole = await rolesApi.create({
|
||||||
|
name: values.name.trim(),
|
||||||
|
description: values.description || null,
|
||||||
|
});
|
||||||
|
message.success(t('Created successfully'));
|
||||||
|
setRoles((prev) => [...prev, newRole].sort((a, b) => a.id - b.id));
|
||||||
|
|
||||||
|
const currentIds = (userForm.getFieldValue('role_ids') || []) as number[];
|
||||||
|
const nextIds = Array.from(new Set([...currentIds, newRole.id]));
|
||||||
|
userForm.setFieldsValue({ role_ids: nextIds });
|
||||||
|
|
||||||
|
setQuickRoleModalOpen(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e.message || t('Operation failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Role ops ---
|
||||||
|
const openCreateRole = () => {
|
||||||
|
setEditingRole(null);
|
||||||
|
setRoleDrawerTab('basic');
|
||||||
|
setPathRules([]);
|
||||||
|
setRoleUsers([]);
|
||||||
|
roleForm.resetFields();
|
||||||
|
roleForm.setFieldsValue({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
permissions: [],
|
||||||
|
});
|
||||||
|
setRoleDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditRole = async (rec: RoleInfo) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [detail, rules, usersUsingRole] = await Promise.all([
|
||||||
|
rolesApi.get(rec.id),
|
||||||
|
rolesApi.getPathRules(rec.id),
|
||||||
|
rolesApi.getUsers(rec.id),
|
||||||
|
]);
|
||||||
|
setEditingRole(detail);
|
||||||
|
setRoleDrawerTab('basic');
|
||||||
|
setPathRules(rules);
|
||||||
|
setRoleUsers(usersUsingRole);
|
||||||
|
roleForm.resetFields();
|
||||||
|
roleForm.setFieldsValue({
|
||||||
|
name: detail.name,
|
||||||
|
description: detail.description || '',
|
||||||
|
permissions: detail.permissions,
|
||||||
|
});
|
||||||
|
setRoleDrawerOpen(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('Load failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRole = async () => {
|
||||||
|
try {
|
||||||
|
const values = await roleForm.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (editingRole) {
|
||||||
|
await rolesApi.update(editingRole.id, {
|
||||||
|
name: values.name.trim(),
|
||||||
|
description: values.description || null,
|
||||||
|
});
|
||||||
|
await rolesApi.setPermissions(editingRole.id, values.permissions || []);
|
||||||
|
message.success(t('Updated successfully'));
|
||||||
|
} else {
|
||||||
|
const newRole = await rolesApi.create({
|
||||||
|
name: values.name.trim(),
|
||||||
|
description: values.description || null,
|
||||||
|
});
|
||||||
|
if (values.permissions?.length) {
|
||||||
|
await rolesApi.setPermissions(newRole.id, values.permissions);
|
||||||
|
}
|
||||||
|
message.success(t('Created successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoleDrawerOpen(false);
|
||||||
|
setEditingRole(null);
|
||||||
|
await fetchData();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e.message || t('Operation failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDeleteRole = async (rec: RoleInfo) => {
|
||||||
|
try {
|
||||||
|
await rolesApi.remove(rec.id);
|
||||||
|
message.success(t('Deleted'));
|
||||||
|
fetchData();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('Delete failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Path rules ---
|
||||||
|
const openAddRule = () => {
|
||||||
|
setEditingRule(null);
|
||||||
|
ruleForm.resetFields();
|
||||||
|
ruleForm.setFieldsValue({
|
||||||
|
path_pattern: '/',
|
||||||
|
is_regex: false,
|
||||||
|
can_read: true,
|
||||||
|
can_write: false,
|
||||||
|
can_delete: false,
|
||||||
|
can_share: false,
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
setRuleDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditRule = (rule: PathRuleInfo) => {
|
||||||
|
setEditingRule(rule);
|
||||||
|
ruleForm.resetFields();
|
||||||
|
ruleForm.setFieldsValue({
|
||||||
|
path_pattern: rule.path_pattern,
|
||||||
|
is_regex: rule.is_regex,
|
||||||
|
can_read: rule.can_read,
|
||||||
|
can_write: rule.can_write,
|
||||||
|
can_delete: rule.can_delete,
|
||||||
|
can_share: rule.can_share,
|
||||||
|
priority: rule.priority,
|
||||||
|
});
|
||||||
|
setRuleDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRule = async () => {
|
||||||
|
if (!editingRole) return;
|
||||||
|
try {
|
||||||
|
const values = await ruleForm.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const ruleData: PathRuleCreate = {
|
||||||
|
path_pattern: values.path_pattern,
|
||||||
|
is_regex: values.is_regex,
|
||||||
|
can_read: values.can_read,
|
||||||
|
can_write: values.can_write,
|
||||||
|
can_delete: values.can_delete,
|
||||||
|
can_share: values.can_share,
|
||||||
|
priority: values.priority,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingRule) {
|
||||||
|
await rolesApi.updatePathRule(editingRule.id, ruleData);
|
||||||
|
message.success(t('Updated successfully'));
|
||||||
|
} else {
|
||||||
|
await rolesApi.addPathRule(editingRole.id, ruleData);
|
||||||
|
message.success(t('Created successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = await rolesApi.getPathRules(editingRole.id);
|
||||||
|
setPathRules(rules);
|
||||||
|
setRuleDrawerOpen(false);
|
||||||
|
setEditingRule(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e.message || t('Operation failed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRule = async (rule: PathRuleInfo) => {
|
||||||
|
if (!editingRole) return;
|
||||||
|
try {
|
||||||
|
await rolesApi.deletePathRule(rule.id);
|
||||||
|
message.success(t('Deleted'));
|
||||||
|
const rules = await rolesApi.getPathRules(editingRole.id);
|
||||||
|
setPathRules(rules);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || t('Delete failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeUserDrawer = () => {
|
||||||
|
setUserDrawerOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRoleDrawer = () => {
|
||||||
|
setRoleDrawerOpen(false);
|
||||||
|
setEditingRole(null);
|
||||||
|
setRoleDrawerTab('basic');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRuleDrawer = () => {
|
||||||
|
setRuleDrawerOpen(false);
|
||||||
|
setEditingRule(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeQuickRoleModal = () => {
|
||||||
|
setQuickRoleModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
label: `${t('Users')} (${filteredUsers.length})`,
|
||||||
|
children: (
|
||||||
|
<UsersTable
|
||||||
|
data={filteredUsers}
|
||||||
|
loading={loading}
|
||||||
|
onEdit={openEditUser}
|
||||||
|
onDelete={doDeleteUser}
|
||||||
|
onToggleDisabled={handleToggleDisabled}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roles',
|
||||||
|
label: `${t('Roles')} (${filteredRoles.length})`,
|
||||||
|
children: (
|
||||||
|
<RolesTable
|
||||||
|
data={filteredRoles}
|
||||||
|
loading={loading}
|
||||||
|
onEdit={openEditRole}
|
||||||
|
onDelete={doDeleteRole}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageCard
|
||||||
|
title={t('User Management')}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
value={searchText}
|
||||||
|
placeholder={t('Search users or roles')}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
style={{ width: 260 }}
|
||||||
|
/>
|
||||||
|
<Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button>
|
||||||
|
<Button type="primary" onClick={() => { setActiveTab('users'); openCreateUser(); }}>
|
||||||
|
{t('Create User')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => { setActiveTab('roles'); openCreateRole(); }}>
|
||||||
|
{t('Create Role')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tabs activeKey={activeTab} onChange={(k) => setActiveTab(k as TabKey)} items={tabItems} />
|
||||||
|
|
||||||
|
<UserEditorDrawer
|
||||||
|
open={userDrawerOpen}
|
||||||
|
loading={loading}
|
||||||
|
editingUser={editingUser}
|
||||||
|
form={userForm}
|
||||||
|
roles={roles}
|
||||||
|
onClose={closeUserDrawer}
|
||||||
|
onSubmit={submitUser}
|
||||||
|
onOpenQuickCreateRole={openQuickCreateRole}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QuickCreateRoleModal
|
||||||
|
open={quickRoleModalOpen}
|
||||||
|
loading={loading}
|
||||||
|
form={quickRoleForm}
|
||||||
|
onCancel={closeQuickRoleModal}
|
||||||
|
onOk={submitQuickRole}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RoleEditorDrawer
|
||||||
|
open={roleDrawerOpen}
|
||||||
|
loading={loading}
|
||||||
|
editingRole={editingRole}
|
||||||
|
form={roleForm}
|
||||||
|
activeTab={roleDrawerTab}
|
||||||
|
onTabChange={setRoleDrawerTab}
|
||||||
|
groupedPermissions={groupedPermissions}
|
||||||
|
pathRules={pathRules}
|
||||||
|
roleUsers={roleUsers}
|
||||||
|
onAddPathRule={openAddRule}
|
||||||
|
onEditPathRule={openEditRule}
|
||||||
|
onDeletePathRule={deleteRule}
|
||||||
|
onEditUser={openEditUser}
|
||||||
|
onClose={closeRoleDrawer}
|
||||||
|
onSubmit={submitRole}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PathRuleEditorDrawer
|
||||||
|
open={ruleDrawerOpen}
|
||||||
|
loading={loading}
|
||||||
|
editingRule={editingRule}
|
||||||
|
form={ruleForm}
|
||||||
|
onClose={closeRuleDrawer}
|
||||||
|
onSubmit={submitRule}
|
||||||
|
/>
|
||||||
|
</PageCard>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default UsersPage;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Button, Checkbox, Divider, Drawer, Form, Input, InputNumber, Space, Switch } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd/es/form';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import type { PathRuleInfo } from '../../../api/roles';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
export interface PathRuleEditorDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
editingRule: PathRuleInfo | null;
|
||||||
|
form: FormInstance;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PathRuleEditorDrawer = memo(function PathRuleEditorDrawer({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
editingRule,
|
||||||
|
form,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: PathRuleEditorDrawerProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={editingRule ? t('Edit Path Rule') : t('Add Path Rule')}
|
||||||
|
width={400}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
destroyOnHidden
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onClose}>{t('Cancel')}</Button>
|
||||||
|
<Button type="primary" onClick={onSubmit} loading={loading}>{t('Submit')}</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="path_pattern"
|
||||||
|
label={t('Path Pattern')}
|
||||||
|
rules={[{ required: true, message: t('Please input {label}', { label: t('Path Pattern') }) }]}
|
||||||
|
extra={t('Use * for single level, ** for any level. Example: /photos/** matches all files in photos folder.')}
|
||||||
|
>
|
||||||
|
<Input placeholder="/photos/**" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="is_regex" label={t('Is Regex')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="priority" label={t('Priority')} extra={t('Higher value = higher priority')}>
|
||||||
|
<InputNumber style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Divider>{t('Permissions')}</Divider>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="can_read" valuePropName="checked" noStyle>
|
||||||
|
<Checkbox>{t('Read')} - {t('Download and preview files')}</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="can_write" valuePropName="checked" noStyle>
|
||||||
|
<Checkbox>{t('Write')} - {t('Upload and modify files')}</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="can_delete" valuePropName="checked" noStyle>
|
||||||
|
<Checkbox>{t('Delete')} - {t('Delete files and folders')}</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="can_share" valuePropName="checked" noStyle>
|
||||||
|
<Checkbox>{t('Share')} - {t('Create share links')}</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Form, Input, Modal } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd/es/form';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
export interface QuickCreateRoleModalProps {
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
form: FormInstance;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOk: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickCreateRoleModal = memo(function QuickCreateRoleModal({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
form,
|
||||||
|
onCancel,
|
||||||
|
onOk,
|
||||||
|
}: QuickCreateRoleModalProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('Create Role')}
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
okText={t('Submit')}
|
||||||
|
cancelText={t('Cancel')}
|
||||||
|
confirmLoading={loading}
|
||||||
|
onOk={onOk}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={t('Role Name')}
|
||||||
|
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('Role Name')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label={t('Description')}>
|
||||||
|
<Input.TextArea placeholder={t('Description')} rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { CrownOutlined, FolderOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Collapse,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Popconfirm,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import type { FormInstance } from 'antd/es/form';
|
||||||
|
import type { TableColumnsType, TabsProps } from 'antd';
|
||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import type { PathRuleInfo, RoleDetail } from '../../../api/roles';
|
||||||
|
import type { UserInfo } from '../../../api/users';
|
||||||
|
import type { PermissionInfo } from '../../../api/permissions';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
import { RulePermissionIcons } from './RulePermissionIcons';
|
||||||
|
import type { RoleDrawerTab } from '../types';
|
||||||
|
|
||||||
|
export interface RoleEditorDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
editingRole: RoleDetail | null;
|
||||||
|
form: FormInstance;
|
||||||
|
activeTab: RoleDrawerTab;
|
||||||
|
onTabChange: (tab: RoleDrawerTab) => void;
|
||||||
|
groupedPermissions: Record<string, PermissionInfo[]>;
|
||||||
|
pathRules: PathRuleInfo[];
|
||||||
|
roleUsers: UserInfo[];
|
||||||
|
onAddPathRule: () => void;
|
||||||
|
onEditPathRule: (rule: PathRuleInfo) => void;
|
||||||
|
onDeletePathRule: (rule: PathRuleInfo) => void;
|
||||||
|
onEditUser: (user: UserInfo) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoleEditorDrawer = memo(function RoleEditorDrawer({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
editingRole,
|
||||||
|
form,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
groupedPermissions,
|
||||||
|
pathRules,
|
||||||
|
roleUsers,
|
||||||
|
onAddPathRule,
|
||||||
|
onEditPathRule,
|
||||||
|
onDeletePathRule,
|
||||||
|
onEditUser,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: RoleEditorDrawerProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const ruleColumns: TableColumnsType<PathRuleInfo> = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: t('Path Pattern'),
|
||||||
|
dataIndex: 'path_pattern',
|
||||||
|
render: (v: string, rec: PathRuleInfo) => (
|
||||||
|
<Space>
|
||||||
|
<FolderOutlined />
|
||||||
|
<code>{v}</code>
|
||||||
|
{rec.is_regex && <Tag color="purple">{t('Regex')}</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Permissions'),
|
||||||
|
render: (_: any, rec: PathRuleInfo) => (
|
||||||
|
<RulePermissionIcons
|
||||||
|
canRead={rec.can_read}
|
||||||
|
canWrite={rec.can_write}
|
||||||
|
canDelete={rec.can_delete}
|
||||||
|
canShare={rec.can_share}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: t('Priority'), dataIndex: 'priority', width: 80 },
|
||||||
|
{
|
||||||
|
title: t('Actions'),
|
||||||
|
width: 140,
|
||||||
|
render: (_: any, rec: PathRuleInfo) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button size="small" onClick={() => onEditPathRule(rec)}>{t('Edit')}</Button>
|
||||||
|
<Popconfirm title={t('Confirm delete?')} onConfirm={() => onDeletePathRule(rec)}>
|
||||||
|
<Button size="small" danger>{t('Delete')}</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [onDeletePathRule, onEditPathRule, t]);
|
||||||
|
|
||||||
|
const roleUserColumns: TableColumnsType<UserInfo> = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: t('Username'),
|
||||||
|
dataIndex: 'username',
|
||||||
|
render: (value: string, rec: UserInfo) => (
|
||||||
|
<Space>
|
||||||
|
{rec.is_admin ? <CrownOutlined style={{ color: '#faad14' }} /> : <UserOutlined />}
|
||||||
|
{value}
|
||||||
|
{rec.is_admin && <Tag color="gold">{t('Admin')}</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' },
|
||||||
|
{
|
||||||
|
title: t('Status'),
|
||||||
|
dataIndex: 'disabled',
|
||||||
|
width: 90,
|
||||||
|
render: (disabled: boolean) => disabled ? <Tag>{t('Disabled')}</Tag> : <Tag color="green">{t('Active')}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Actions'),
|
||||||
|
width: 110,
|
||||||
|
render: (_: any, rec: UserInfo) => (
|
||||||
|
<Button size="small" onClick={() => onEditUser(rec)}>{t('Edit')}</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [onEditUser, t]);
|
||||||
|
|
||||||
|
const tabItems: TabsProps['items'] = useMemo(() => {
|
||||||
|
const items: TabsProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'basic',
|
||||||
|
label: t('Basic Info'),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label={t('Role Name')}
|
||||||
|
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('Role Name')} disabled={editingRole?.is_system} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label={t('Description')}>
|
||||||
|
<Input.TextArea placeholder={t('Description')} rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'permissions',
|
||||||
|
label: t('System Permissions'),
|
||||||
|
children: (
|
||||||
|
<Form.Item name="permissions" label={t('Permissions')}>
|
||||||
|
<Checkbox.Group style={{ width: '100%' }}>
|
||||||
|
<Collapse
|
||||||
|
items={Object.entries(groupedPermissions).map(([category, perms]) => ({
|
||||||
|
key: category,
|
||||||
|
label: t(`permission.category.${category}`) === `permission.category.${category}`
|
||||||
|
? category.charAt(0).toUpperCase() + category.slice(1)
|
||||||
|
: t(`permission.category.${category}`),
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
{perms.map(p => (
|
||||||
|
<Checkbox key={p.code} value={p.code}>
|
||||||
|
{p.name}
|
||||||
|
{p.description && (
|
||||||
|
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||||
|
{p.description}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (editingRole) {
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
key: 'path_rules',
|
||||||
|
label: `${t('Path Rules')} (${pathRules.length})`,
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" size="small" onClick={onAddPathRule}>
|
||||||
|
{t('Add Path Rule')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={pathRules}
|
||||||
|
columns={ruleColumns}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
label: `${t('Users')} (${roleUsers.length})`,
|
||||||
|
children: (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={roleUsers}
|
||||||
|
columns={roleUserColumns}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [editingRole, groupedPermissions, loading, pathRules, roleUserColumns, roleUsers, ruleColumns, onAddPathRule, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={editingRole ? `${t('Edit')}: ${editingRole.name}` : t('Create Role')}
|
||||||
|
width={600}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
destroyOnHidden
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onClose}>{t('Cancel')}</Button>
|
||||||
|
<Button type="primary" onClick={onSubmit} loading={loading}>{t('Submit')}</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={(k) => onTabChange(k as RoleDrawerTab)}
|
||||||
|
destroyInactiveTabPane={false}
|
||||||
|
items={tabItems}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { LockOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Popconfirm, Space, Table, Tag } from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import type { RoleInfo } from '../../../api/roles';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
export interface RolesTableProps {
|
||||||
|
data: RoleInfo[];
|
||||||
|
loading: boolean;
|
||||||
|
onEdit: (role: RoleInfo) => void;
|
||||||
|
onDelete: (role: RoleInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RolesTable = memo(function RolesTable({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: RolesTableProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const columns: TableColumnsType<RoleInfo> = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: t('Role Name'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (value: string, rec: RoleInfo) => (
|
||||||
|
<Space>
|
||||||
|
<LockOutlined />
|
||||||
|
{value}
|
||||||
|
{rec.is_system && <Tag color="blue">{t('System')}</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: t('Description'), dataIndex: 'description', render: (v: string | null) => v || '-' },
|
||||||
|
{
|
||||||
|
title: t('Created At'),
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (v: string) => new Date(v).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Actions'),
|
||||||
|
width: 160,
|
||||||
|
render: (_: any, rec: RoleInfo) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button size="small" onClick={() => onEdit(rec)}>{t('Edit')}</Button>
|
||||||
|
{!rec.is_system && (
|
||||||
|
<Popconfirm title={t('Confirm delete?')} onConfirm={() => onDelete(rec)}>
|
||||||
|
<Button size="small" danger>{t('Delete')}</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [onDelete, onEdit, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={data}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { DeleteOutlined, EditOutlined, EyeOutlined, ShareAltOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Space, Tooltip } from 'antd';
|
||||||
|
import { memo, type ReactNode } from 'react';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
interface IconButtonProps {
|
||||||
|
enabled: boolean;
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({ enabled, title, icon, color }: IconButtonProps) {
|
||||||
|
const button = (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={icon}
|
||||||
|
disabled={!enabled}
|
||||||
|
style={enabled ? { color } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Tooltip title={title}>
|
||||||
|
{enabled ? button : <span>{button}</span>}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RulePermissionIconsProps {
|
||||||
|
canRead: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canShare: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RulePermissionIcons = memo(function RulePermissionIcons({
|
||||||
|
canRead,
|
||||||
|
canWrite,
|
||||||
|
canDelete,
|
||||||
|
canShare,
|
||||||
|
}: RulePermissionIconsProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Space size={6} wrap>
|
||||||
|
<IconButton enabled={canRead} title={t('Read')} icon={<EyeOutlined />} color="var(--ant-color-success)" />
|
||||||
|
<IconButton enabled={canWrite} title={t('Write')} icon={<EditOutlined />} color="var(--ant-color-primary)" />
|
||||||
|
<IconButton enabled={canDelete} title={t('Delete')} icon={<DeleteOutlined />} color="var(--ant-color-error)" />
|
||||||
|
<IconButton enabled={canShare} title={t('Share')} icon={<ShareAltOutlined />} color="var(--ant-color-warning)" />
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Button, Drawer, Form, Input, Select, Space, Switch, Typography } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd/es/form';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import type { RoleInfo } from '../../../api/roles';
|
||||||
|
import type { UserDetail } from '../../../api/users';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
export interface UserEditorDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
editingUser: UserDetail | null;
|
||||||
|
form: FormInstance;
|
||||||
|
roles: RoleInfo[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onOpenQuickCreateRole: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserEditorDrawer = memo(function UserEditorDrawer({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
editingUser,
|
||||||
|
form,
|
||||||
|
roles,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
onOpenQuickCreateRole,
|
||||||
|
}: UserEditorDrawerProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={editingUser ? `${t('Edit')}: ${editingUser.username}` : t('Create User')}
|
||||||
|
width={480}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
destroyOnHidden
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onClose}>{t('Cancel')}</Button>
|
||||||
|
<Button type="primary" onClick={onSubmit} loading={loading}>{t('Submit')}</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
label={t('Username')}
|
||||||
|
rules={[{ required: true, message: t('Please input {label}', { label: t('Username') }) }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('Username')} disabled={!!editingUser} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label={editingUser ? t('New Password (leave empty to keep current)') : t('Password')}
|
||||||
|
rules={editingUser ? [] : [{ required: true, message: t('Please input {label}', { label: t('Password') }) }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder={t('Password')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label={t('Email')}>
|
||||||
|
<Input placeholder={t('Email')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="full_name" label={t('Full Name')}>
|
||||||
|
<Input placeholder={t('Full Name')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="role_ids"
|
||||||
|
label={(
|
||||||
|
<Space size={4}>
|
||||||
|
{t('Roles')}
|
||||||
|
<Button type="link" size="small" onClick={onOpenQuickCreateRole}>
|
||||||
|
{t('Quick Create Role')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder={t('Select roles')}
|
||||||
|
options={roles.map(r => ({
|
||||||
|
value: r.id,
|
||||||
|
label: r.name + (r.is_system ? ` (${t('System')})` : ''),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="is_admin" label={t('Super Admin')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="disabled" label={t('Disabled')} valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
{editingUser && (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('Created by')}: {editingUser.created_by_username || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { CrownOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Popconfirm, Space, Switch, Table, Tag } from 'antd';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import type { UserInfo } from '../../../api/users';
|
||||||
|
import { useI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
export interface UsersTableProps {
|
||||||
|
data: UserInfo[];
|
||||||
|
loading: boolean;
|
||||||
|
onEdit: (user: UserInfo) => void;
|
||||||
|
onDelete: (user: UserInfo) => void;
|
||||||
|
onToggleDisabled: (user: UserInfo, disabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UsersTable = memo(function UsersTable({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggleDisabled,
|
||||||
|
}: UsersTableProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const columns: TableColumnsType<UserInfo> = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: t('Username'),
|
||||||
|
dataIndex: 'username',
|
||||||
|
render: (value: string, rec: UserInfo) => (
|
||||||
|
<Space>
|
||||||
|
{rec.is_admin ? <CrownOutlined style={{ color: '#faad14' }} /> : <UserOutlined />}
|
||||||
|
{value}
|
||||||
|
{rec.is_admin && <Tag color="gold">{t('Admin')}</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' },
|
||||||
|
{ title: t('Full Name'), dataIndex: 'full_name', render: (v: string | null) => v || '-' },
|
||||||
|
{
|
||||||
|
title: t('Status'),
|
||||||
|
dataIndex: 'disabled',
|
||||||
|
width: 100,
|
||||||
|
render: (disabled: boolean, rec: UserInfo) => (
|
||||||
|
<Switch
|
||||||
|
checked={!disabled}
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
onChange={(checked) => onToggleDisabled(rec, !checked)}
|
||||||
|
checkedChildren={t('Active')}
|
||||||
|
unCheckedChildren={t('Disabled')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Last Login'),
|
||||||
|
dataIndex: 'last_login',
|
||||||
|
width: 180,
|
||||||
|
render: (v: string | null) => v ? new Date(v).toLocaleString() : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Actions'),
|
||||||
|
width: 160,
|
||||||
|
render: (_: any, rec: UserInfo) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button size="small" onClick={() => onEdit(rec)}>{t('Edit')}</Button>
|
||||||
|
<Popconfirm title={t('Confirm delete?')} onConfirm={() => onDelete(rec)}>
|
||||||
|
<Button size="small" danger>{t('Delete')}</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [loading, onDelete, onEdit, onToggleDisabled, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={data}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type RoleDrawerTab = 'basic' | 'permissions' | 'path_rules' | 'users';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.t
|
|||||||
import AuditLogsPage from '../pages/AuditLogsPage.tsx';
|
import AuditLogsPage from '../pages/AuditLogsPage.tsx';
|
||||||
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
||||||
import PluginsPage from '../pages/PluginsPage.tsx';
|
import PluginsPage from '../pages/PluginsPage.tsx';
|
||||||
import UsersPage from '../pages/AdminPage/UsersPage.tsx';
|
import UsersPage from '../pages/UsersPage/UsersPage.tsx';
|
||||||
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
|
import { AppWindowsProvider, useAppWindows } from '../contexts/AppWindowsContext';
|
||||||
import { AppWindowsLayer } from '../apps/AppWindowsLayer';
|
import { AppWindowsLayer } from '../apps/AppWindowsLayer';
|
||||||
import AiAgentWidget from '../components/AiAgentWidget';
|
import AiAgentWidget from '../components/AiAgentWidget';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Navigate, Routes, Route, useLocation } from 'react-router';
|
|||||||
import type { RouteObject } from 'react-router';
|
import type { RouteObject } from 'react-router';
|
||||||
import LayoutShell from './LayoutShell.tsx';
|
import LayoutShell from './LayoutShell.tsx';
|
||||||
import LoginPage from '../pages/LoginPage.tsx';
|
import LoginPage from '../pages/LoginPage.tsx';
|
||||||
|
import RegisterPage from '../pages/RegisterPage.tsx';
|
||||||
import SetupPage from '../pages/SetupPage.tsx';
|
import SetupPage from '../pages/SetupPage.tsx';
|
||||||
import PublicSharePage from '../pages/PublicSharePage';
|
import PublicSharePage from '../pages/PublicSharePage';
|
||||||
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
|
import ForgotPasswordPage from '../pages/ForgotPasswordPage';
|
||||||
@@ -13,6 +14,7 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: '/', element: <Navigate to="/files" replace /> },
|
{ path: '/', element: <Navigate to="/files" replace /> },
|
||||||
{ path: '/:navKey/*', element: <LayoutShell /> },
|
{ path: '/:navKey/*', element: <LayoutShell /> },
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <LoginPage /> },
|
||||||
|
{ path: '/register', element: <RegisterPage /> },
|
||||||
{ path: '/share/:token', element: <PublicSharePage /> },
|
{ path: '/share/:token', element: <PublicSharePage /> },
|
||||||
{ path: '/setup', element: <SetupPage /> },
|
{ path: '/setup', element: <SetupPage /> },
|
||||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user