From c5e4b3ef4321df72ab27384c345e0ccce294c984 Mon Sep 17 00:00:00 2001 From: shiyu Date: Sun, 1 Feb 2026 19:25:17 +0800 Subject: [PATCH] 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. --- domain/auth/api.py | 6 +- domain/auth/service.py | 51 +- domain/auth/types.py | 2 +- domain/config/api.py | 32 + web/src/api/auth.ts | 4 +- web/src/api/config.ts | 4 + web/src/contexts/AuthContext.tsx | 4 +- web/src/contexts/ThemeContext.tsx | 4 +- web/src/i18n/locales/en.json | 11 + web/src/i18n/locales/zh.json | 13 + web/src/layout/nav.ts | 2 +- web/src/pages/AdminPage/RolesPage.tsx | 435 --------- web/src/pages/AdminPage/UsersPage.tsx | 863 ------------------ web/src/pages/LoginPage.tsx | 6 + web/src/pages/RegisterPage.tsx | 136 +++ web/src/pages/SetupPage.tsx | 5 +- .../SystemSettingsPage/SystemSettingsPage.tsx | 46 +- .../components/AuthSettingsTab.tsx | 112 +++ web/src/pages/UsersPage/UsersPage.tsx | 534 +++++++++++ .../components/PathRuleEditorDrawer.tsx | 73 ++ .../components/QuickCreateRoleModal.tsx | 48 + .../UsersPage/components/RoleEditorDrawer.tsx | 250 +++++ .../pages/UsersPage/components/RolesTable.tsx | 69 ++ .../components/RulePermissionIcons.tsx | 53 ++ .../UsersPage/components/UserEditorDrawer.tsx | 100 ++ .../pages/UsersPage/components/UsersTable.tsx | 85 ++ web/src/pages/UsersPage/types.ts | 2 + web/src/router/LayoutShell.tsx | 2 +- web/src/router/index.tsx | 2 + 29 files changed, 1631 insertions(+), 1323 deletions(-) delete mode 100644 web/src/pages/AdminPage/RolesPage.tsx delete mode 100644 web/src/pages/AdminPage/UsersPage.tsx create mode 100644 web/src/pages/RegisterPage.tsx create mode 100644 web/src/pages/SystemSettingsPage/components/AuthSettingsTab.tsx create mode 100644 web/src/pages/UsersPage/UsersPage.tsx create mode 100644 web/src/pages/UsersPage/components/PathRuleEditorDrawer.tsx create mode 100644 web/src/pages/UsersPage/components/QuickCreateRoleModal.tsx create mode 100644 web/src/pages/UsersPage/components/RoleEditorDrawer.tsx create mode 100644 web/src/pages/UsersPage/components/RolesTable.tsx create mode 100644 web/src/pages/UsersPage/components/RulePermissionIcons.tsx create mode 100644 web/src/pages/UsersPage/components/UserEditorDrawer.tsx create mode 100644 web/src/pages/UsersPage/components/UsersTable.tsx create mode 100644 web/src/pages/UsersPage/types.ts diff --git a/domain/auth/api.py b/domain/auth/api.py index 49ef77c..0f8ee5f 100644 --- a/domain/auth/api.py +++ b/domain/auth/api.py @@ -18,16 +18,16 @@ from .types import ( router = APIRouter(prefix="/api/auth", tags=["auth"]) -@router.post("/register", summary="注册第一个管理员用户") +@router.post("/register", summary="注册用户(首个用户为管理员)") @audit( action=AuditAction.REGISTER, - description="注册管理员", + description="注册用户", body_fields=["username", "email", "full_name"], redact_fields=["password"], ) async def register(request: Request, data: RegisterRequest): user = await AuthService.register_user(data) - return success({"username": user.username}, msg="初始用户注册成功") + return success({"username": user.username}, msg="注册成功") @router.post("/login") diff --git a/domain/auth/service.py b/domain/auth/service.py index 23840db..aa665ba 100644 --- a/domain/auth/service.py +++ b/domain/auth/service.py @@ -12,7 +12,7 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jwt.exceptions import InvalidTokenError from domain.config import ConfigService -from models.database import UserAccount +from models.database import Role, UserAccount, UserRole from .types import ( PasswordResetConfirm, PasswordResetRequest, @@ -161,21 +161,60 @@ class AuthService: @classmethod async def register_user(cls, payload: RegisterRequest): - if await cls.has_users(): - raise HTTPException(status_code=403, detail="系统已初始化,不允许注册新用户") + has_users = await cls.has_users() + 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) if exists: 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) - # 第一个用户自动成为超级管理员 + + # 第一个用户自动成为超级管理员(不受开放注册开关影响) + 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( username=payload.username, - email=payload.email, + email=normalized_email, full_name=payload.full_name, hashed_password=hashed, disabled=False, - is_admin=True, # 第一个用户是超级管理员 + is_admin=False, ) + await UserRole.create(user_id=user.id, role_id=default_role_id) return user @classmethod diff --git a/domain/auth/types.py b/domain/auth/types.py index 7ee8d55..0c79c2e 100644 --- a/domain/auth/types.py +++ b/domain/auth/types.py @@ -26,7 +26,7 @@ class UserInDB(User): class RegisterRequest(BaseModel): username: str password: str - email: str | None = None + email: str full_name: str | None = None diff --git a/domain/config/api.py b/domain/config/api.py index ca3b8f1..688caf0 100644 --- a/domain/config/api.py +++ b/domain/config/api.py @@ -5,11 +5,21 @@ from fastapi import APIRouter, Depends, Form, Request from api.response import success from domain.audit import AuditAction, audit 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 .types import ConfigItem 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("/") @audit(action=AuditAction.READ, description="获取配置") @@ -18,6 +28,9 @@ async def get_config( current_user: Annotated[User, Depends(get_current_active_user)], key: str, ): + await PermissionService.require_system_permission( + current_user.id, SystemPermission.CONFIG_EDIT + ) value = await ConfigService.get(key) return success(ConfigItem(key=key, value=value).model_dump()) @@ -30,6 +43,9 @@ async def set_config( key: str = Form(...), value: str = Form(""), ): + await PermissionService.require_system_permission( + current_user.id, SystemPermission.CONFIG_EDIT + ) await ConfigService.set(key, value) return success(ConfigItem(key=key, value=value).model_dump()) @@ -40,9 +56,25 @@ async def get_all_config( request: Request, 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() 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") @audit(action=AuditAction.READ, description="获取系统状态") diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index f437023..5666311 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -8,7 +8,7 @@ export interface LoginPayload { export interface RegisterPayload { username: string; password: string; - email?: string; + email: string; full_name?: string; } @@ -43,7 +43,7 @@ export interface PasswordResetConfirmPayload { } export const authApi = { - register: async (username: string, password: string, email?: string, full_name?: string): Promise => { + register: async (username: string, password: string, email: string, full_name?: string): Promise => { return request('/auth/register', { method: 'POST', json: { username, password, email, full_name }, diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 32af646..b7e7730 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -15,6 +15,10 @@ export async function getAllConfig() { return request>('/config/all'); } +export async function getPublicConfig() { + return request>('/config/public'); +} + export interface SystemStatus { version: string; title: string; diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 0ec8ff6..a288169 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -6,7 +6,7 @@ interface AuthContextType { isAuthenticated: boolean; login: (username: string, password: string) => Promise; logout: () => void; - register: (username: string, password: string, email?: string, full_name?: string) => Promise; + register: (username: string, password: string, email: string, full_name?: string) => Promise; user: MeResponse | null; refreshUser: () => Promise; } @@ -36,7 +36,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { 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); }; diff --git a/web/src/contexts/ThemeContext.tsx b/web/src/contexts/ThemeContext.tsx index 7d4cc8e..9f33581 100644 --- a/web/src/contexts/ThemeContext.tsx +++ b/web/src/contexts/ThemeContext.tsx @@ -3,7 +3,7 @@ import { ConfigProvider, theme as antdTheme } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import enUS from 'antd/locale/en_US'; import type { ThemeConfig } from 'antd/es/config-provider/context'; -import { getAllConfig } from '../api/config'; +import { getPublicConfig } from '../api/config'; import { useAuth } from './AuthContext'; import baseTheme from '../theme'; import { useI18n } from '../i18n'; @@ -149,7 +149,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { return; } try { - const cfg = await getAllConfig(); + const cfg = await getPublicConfig(); const mode = (cfg[CONFIG_KEYS.MODE] as ThemeMode) || 'light'; const primary = (cfg[CONFIG_KEYS.PRIMARY] as string) || null; const radiusStr = cfg[CONFIG_KEYS.RADIUS]; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 062165f..5be3fe7 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -8,6 +8,7 @@ "Adapters": "Adapters", "Plugins": "App Center", "System Settings": "System Settings", + "Registration Settings": "Registration Settings", "Backup & Restore": "Backup & Restore", "System Logs": "System Logs", "Audit Logs": "Audit Logs", @@ -15,6 +16,16 @@ "Search files / tags / types": "Search files / tags / types", "Log Out": "Log Out", "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", "Account Settings": "Account Settings", "Language": "Language", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 38be588..d2e2db0 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -31,6 +31,7 @@ "Adapters": "存储挂载", "Plugins": "应用中心", "System Settings": "系统设置", + "Registration Settings": "注册设置", "Backup & Restore": "备份恢复", "System Logs": "系统日志", "Audit Logs": "审计日志", @@ -38,6 +39,16 @@ "Search files / tags / types": "搜索文件 / 标签 / 类型", "Log Out": "退出登录", "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": "个人资料", "Account Settings": "账户设置", "Language": "语言", @@ -758,6 +769,7 @@ "Create User": "创建用户", "Create Role": "创建角色", "Edit": "编辑", + "Submit": "提交", "Super Admin": "超级管理员", "Disabled": "已禁用", "Active": "已启用", @@ -773,6 +785,7 @@ "Add Path Rule": "添加路径规则", "Edit Path Rule": "编辑路径规则", "Path Pattern": "路径模式", + "Regex": "正则", "Is Regex": "正则表达式", "Priority": "优先级", "Higher value = higher priority": "数值越大优先级越高", diff --git a/web/src/layout/nav.ts b/web/src/layout/nav.ts index 5e97a0a..7f6d6fe 100644 --- a/web/src/layout/nav.ts +++ b/web/src/layout/nav.ts @@ -44,7 +44,7 @@ export const navGroups: NavGroup[] = [ title: 'System', children: [ { 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: 'audit', icon: React.createElement(BugOutlined), label: 'Audit Logs' } ] diff --git a/web/src/pages/AdminPage/RolesPage.tsx b/web/src/pages/AdminPage/RolesPage.tsx deleted file mode 100644 index 332e041..0000000 --- a/web/src/pages/AdminPage/RolesPage.tsx +++ /dev/null @@ -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([]); - const [permissions, setPermissions] = useState([]); - const [open, setOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [pathRules, setPathRules] = useState([]); - const [form] = Form.useForm(); - const [ruleForm] = Form.useForm(); - const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false); - const [editingRule, setEditingRule] = useState(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); - - const columns = [ - { - title: t('Role Name'), - dataIndex: 'name', - render: (value: string, rec: RoleInfo) => ( - - - {value} - {rec.is_system && {t('System')}} - - ), - }, - { 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) => ( - - - {!rec.is_system && ( - doDelete(rec)}> - - - )} - - ), - }, - ]; - - const ruleColumns = [ - { - title: t('Path Pattern'), - dataIndex: 'path_pattern', - render: (v: string, rec: PathRuleInfo) => ( - - - {v} - {rec.is_regex && Regex} - - ), - }, - { - title: t('Permissions'), - render: (_: any, rec: PathRuleInfo) => ( - - {rec.can_read && {t('Read')}} - {rec.can_write && {t('Write')}} - {rec.can_delete && {t('Delete')}} - {rec.can_share && {t('Share')}} - - ), - }, - { - title: t('Priority'), - dataIndex: 'priority', - width: 80, - }, - { - title: t('Actions'), - width: 140, - render: (_: any, rec: PathRuleInfo) => ( - - - deleteRule(rec)}> - - - - ), - }, - ]; - - return ( - - - - - } - > - - - {/* 角色编辑抽屉 */} - { setOpen(false); setEditing(null); }} - destroyOnHidden - extra={ - - - - - } - > -
- - - - - - - - {t('System Permissions')} - - - ({ - key: category, - label: t(`permission.category.${category}`) === `permission.category.${category}` - ? category.charAt(0).toUpperCase() + category.slice(1) - : t(`permission.category.${category}`), - children: ( - - {perms.map(p => ( - - {p.name} - {p.description && ( - - {p.description} - - )} - - ))} - - ), - }))} - /> - - - - {editing && ( - <> - {t('Path Rules')} - - - -
- - )} - - - - {/* 路径规则编辑抽屉 */} - { setRuleDrawerOpen(false); setEditingRule(null); }} - destroyOnHidden - extra={ - - - - - } - > -
- - - - - - - - - - {t('Permissions')} - - - {t('Read')} - {t('Download and preview files')} - - - {t('Write')} - {t('Upload and modify files')} - - - {t('Delete')} - {t('Delete files and folders')} - - - {t('Share')} - {t('Create share links')} - - - -
- - ); -}); - -export default RolesPage; diff --git a/web/src/pages/AdminPage/UsersPage.tsx b/web/src/pages/AdminPage/UsersPage.tsx deleted file mode 100644 index 011655a..0000000 --- a/web/src/pages/AdminPage/UsersPage.tsx +++ /dev/null @@ -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('users'); - const [searchText, setSearchText] = useState(''); - - const [users, setUsers] = useState([]); - const [roles, setRoles] = useState([]); - const [permissions, setPermissions] = useState([]); - - const [userDrawerOpen, setUserDrawerOpen] = useState(false); - const [editingUser, setEditingUser] = useState(null); - const [userForm] = Form.useForm(); - - const [roleDrawerOpen, setRoleDrawerOpen] = useState(false); - const [editingRole, setEditingRole] = useState(null); - const [pathRules, setPathRules] = useState([]); - const [roleUsers, setRoleUsers] = useState([]); - const [roleForm] = Form.useForm(); - - const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false); - const [editingRule, setEditingRule] = useState(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); - }, [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) => ( - - {rec.is_admin ? : } - {value} - {rec.is_admin && {t('Admin')}} - - ), - }, - { 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) => ( - 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) => ( - - - doDeleteUser(rec)}> - - - - ), - }, - ]; - - const roleColumns = [ - { - title: t('Role Name'), - dataIndex: 'name', - render: (value: string, rec: RoleInfo) => ( - - - {value} - {rec.is_system && {t('System')}} - - ), - }, - { 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) => ( - - - {!rec.is_system && ( - doDeleteRole(rec)}> - - - )} - - ), - }, - ]; - - const ruleColumns = [ - { - title: t('Path Pattern'), - dataIndex: 'path_pattern', - render: (v: string, rec: PathRuleInfo) => ( - - - {v} - {rec.is_regex && Regex} - - ), - }, - { - title: t('Permissions'), - render: (_: any, rec: PathRuleInfo) => ( - - {rec.can_read && {t('Read')}} - {rec.can_write && {t('Write')}} - {rec.can_delete && {t('Delete')}} - {rec.can_share && {t('Share')}} - - ), - }, - { - title: t('Priority'), - dataIndex: 'priority', - width: 80, - }, - { - title: t('Actions'), - width: 140, - render: (_: any, rec: PathRuleInfo) => ( - - - deleteRule(rec)}> - - - - ), - }, - ]; - - const roleUserColumns = [ - { - title: t('Username'), - dataIndex: 'username', - render: (value: string, rec: UserInfo) => ( - - {rec.is_admin ? : } - {value} - {rec.is_admin && {t('Admin')}} - - ), - }, - { title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' }, - { - title: t('Status'), - dataIndex: 'disabled', - width: 90, - render: (disabled: boolean) => disabled ? {t('Disabled')} : {t('Active')}, - }, - { - title: t('Actions'), - width: 110, - render: (_: any, rec: UserInfo) => ( - - ), - }, - ]; - - const tabItems = [ - { - key: 'users', - label: `${t('Users')} (${filteredUsers.length})`, - children: ( -
- ), - }, - { - key: 'roles', - label: `${t('Roles')} (${filteredRoles.length})`, - children: ( -
- ), - }, - ]; - - return ( - - setSearchText(e.target.value)} - style={{ width: 260 }} - /> - - - - - } - > - setActiveTab(k as TabKey)} items={tabItems} /> - - {/* User editor */} - { setUserDrawerOpen(false); setEditingUser(null); }} - destroyOnHidden - extra={ - - - - - } - > -
- - - - - - - - - - - - - - {t('Roles')} - - - )} - > - - - - - - - - - {/* Role editor */} - { setRoleDrawerOpen(false); setEditingRole(null); }} - destroyOnHidden - extra={ - - - - - } - > -
- - - - - - - - {t('System Permissions')} - - - ({ - key: category, - label: t(`permission.category.${category}`) === `permission.category.${category}` - ? category.charAt(0).toUpperCase() + category.slice(1) - : t(`permission.category.${category}`), - children: ( - - {perms.map(p => ( - - {p.name} - {p.description && ( - - {p.description} - - )} - - ))} - - ), - }))} - /> - - - - {editingRole && ( - <> - {t('Path Rules')} - - - -
- - {t('Users')} -
- - )} - - - - {/* Path rule editor */} - { setRuleDrawerOpen(false); setEditingRule(null); }} - destroyOnHidden - extra={ - - - - - } - > -
- - - - - - - - - - {t('Permissions')} - - - {t('Read')} - {t('Download and preview files')} - - - {t('Write')} - {t('Upload and modify files')} - - - {t('Delete')} - {t('Delete files and folders')} - - - {t('Share')} - {t('Create share links')} - - - -
- - ); -}); - -export default UsersPage; diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index cce37d0..d40002d 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -123,6 +123,12 @@ export default function LoginPage() { {t('Sign In')} + + + + diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..b4843b1 --- /dev/null +++ b/web/src/pages/RegisterPage.tsx @@ -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 ; + } + + 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 ( +
+
+ +
+ + + +
+ {t('Create Account')} + {t('Sign up to your Foxel account')} +
+ + {err && } + +
+ + } /> + + + + } /> + + + + } /> + + + + } /> + + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error(t('Passwords do not match!'))); + }, + }), + ]} + > + } /> + + + + + + + +
+ {t('Already have an account?')}{' '} + +
+
+
+
+ ); +} + diff --git a/web/src/pages/SetupPage.tsx b/web/src/pages/SetupPage.tsx index a8d8af8..1eb6123 100644 --- a/web/src/pages/SetupPage.tsx +++ b/web/src/pages/SetupPage.tsx @@ -315,7 +315,10 @@ const SetupPage = () => { } /> diff --git a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx index be31dc9..533e10c 100644 --- a/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx +++ b/web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx @@ -1,21 +1,22 @@ -import { message, Tabs, Space } from 'antd'; +import { Alert, message, Tabs, Space } from 'antd'; import { useEffect, useState } from 'react'; import PageCard from '../../components/PageCard'; 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 '../../styles/settings-tabs.css'; import { useI18n } from '../../i18n'; import AppearanceSettingsTab from './components/AppearanceSettingsTab'; import AppSettingsTab from './components/AppSettingsTab'; +import AuthSettingsTab from './components/AuthSettingsTab'; import AiSettingsTab from './components/AiSettingsTab'; import VectorDbSettingsTab from './components/VectorDbSettingsTab'; import EmailSettingsTab from './components/EmailSettingsTab'; 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 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) { const [loading, setLoading] = useState(false); const [config, setConfigState] = useState | null>(null); + const [loadError, setLoadError] = useState(null); const [activeTab, setActiveTab] = useState(() => isValidTab(tabKey) ? tabKey : DEFAULT_TAB ); @@ -52,8 +54,16 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett const { t } = useI18n(); useEffect(() => { - getAllConfig().then((data) => setConfigState(data as Record)); - }, []); + getAllConfig() + .then((data) => { + setLoadError(null); + setConfigState(data as Record); + }) + .catch((e: any) => { + setLoadError(e?.message || t('Load failed')); + setConfigState({}); + }); + }, [t]); const handleSave = async (values: Record) => { setLoading(true); @@ -102,6 +112,14 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett onTabNavigate?.(nextKey); }; + if (loadError) { + return ( + + + + ); + } + if (!config) { return
{t('Loading...')}
; } @@ -151,6 +169,22 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett /> ), }, + { + key: 'auth', + label: ( + + + {t('Registration Settings')} + + ), + children: ( + + ), + }, { key: 'email', label: ( diff --git a/web/src/pages/SystemSettingsPage/components/AuthSettingsTab.tsx b/web/src/pages/SystemSettingsPage/components/AuthSettingsTab.tsx new file mode 100644 index 0000000..4fd63c9 --- /dev/null +++ b/web/src/pages/SystemSettingsPage/components/AuthSettingsTab.tsx @@ -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; + loading: boolean; + onSave: (values: Record) => Promise; +} + +export default function AuthSettingsTab({ + config, + loading, + onSave, +}: AuthSettingsTabProps) { + const { t } = useI18n(); + const [rolesLoading, setRolesLoading] = useState(false); + const [roles, setRoles] = useState([]); + + 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 ( +
{ + const allow = !!vals.AUTH_ALLOW_REGISTER; + const payload: Record = { + 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 ?? '')} + > + + + + + + + + {({ getFieldValue }) => { + const enabled = !!getFieldValue('AUTH_ALLOW_REGISTER'); + return ( + + + + + + + + + + {t('Permissions')} + + + {t('Read')} - {t('Download and preview files')} + + + {t('Write')} - {t('Upload and modify files')} + + + {t('Delete')} - {t('Delete files and folders')} + + + {t('Share')} - {t('Create share links')} + + + + + ); +}); + diff --git a/web/src/pages/UsersPage/components/QuickCreateRoleModal.tsx b/web/src/pages/UsersPage/components/QuickCreateRoleModal.tsx new file mode 100644 index 0000000..aad8a90 --- /dev/null +++ b/web/src/pages/UsersPage/components/QuickCreateRoleModal.tsx @@ -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 ( + +
+ + + + + + + +
+ ); +}); + diff --git a/web/src/pages/UsersPage/components/RoleEditorDrawer.tsx b/web/src/pages/UsersPage/components/RoleEditorDrawer.tsx new file mode 100644 index 0000000..fde186e --- /dev/null +++ b/web/src/pages/UsersPage/components/RoleEditorDrawer.tsx @@ -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; + 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 = useMemo(() => [ + { + title: t('Path Pattern'), + dataIndex: 'path_pattern', + render: (v: string, rec: PathRuleInfo) => ( + + + {v} + {rec.is_regex && {t('Regex')}} + + ), + }, + { + title: t('Permissions'), + render: (_: any, rec: PathRuleInfo) => ( + + ), + }, + { title: t('Priority'), dataIndex: 'priority', width: 80 }, + { + title: t('Actions'), + width: 140, + render: (_: any, rec: PathRuleInfo) => ( + + + onDeletePathRule(rec)}> + + + + ), + }, + ], [onDeletePathRule, onEditPathRule, t]); + + const roleUserColumns: TableColumnsType = useMemo(() => [ + { + title: t('Username'), + dataIndex: 'username', + render: (value: string, rec: UserInfo) => ( + + {rec.is_admin ? : } + {value} + {rec.is_admin && {t('Admin')}} + + ), + }, + { title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' }, + { + title: t('Status'), + dataIndex: 'disabled', + width: 90, + render: (disabled: boolean) => disabled ? {t('Disabled')} : {t('Active')}, + }, + { + title: t('Actions'), + width: 110, + render: (_: any, rec: UserInfo) => ( + + ), + }, + ], [onEditUser, t]); + + const tabItems: TabsProps['items'] = useMemo(() => { + const items: TabsProps['items'] = [ + { + key: 'basic', + label: t('Basic Info'), + children: ( + <> + + + + + + + + ), + }, + { + key: 'permissions', + label: t('System Permissions'), + children: ( + + + ({ + key: category, + label: t(`permission.category.${category}`) === `permission.category.${category}` + ? category.charAt(0).toUpperCase() + category.slice(1) + : t(`permission.category.${category}`), + children: ( + + {perms.map(p => ( + + {p.name} + {p.description && ( + + {p.description} + + )} + + ))} + + ), + }))} + /> + + + ), + }, + ]; + + if (editingRole) { + items.push( + { + key: 'path_rules', + label: `${t('Path Rules')} (${pathRules.length})`, + children: ( + + + + +
+ + ), + }, + { + key: 'users', + label: `${t('Users')} (${roleUsers.length})`, + children: ( +
+ ), + }, + ); + } + + return items; + }, [editingRole, groupedPermissions, loading, pathRules, roleUserColumns, roleUsers, ruleColumns, onAddPathRule, t]); + + return ( + + + + + } + > +
+ onTabChange(k as RoleDrawerTab)} + destroyInactiveTabPane={false} + items={tabItems} + /> + +
+ ); +}); diff --git a/web/src/pages/UsersPage/components/RolesTable.tsx b/web/src/pages/UsersPage/components/RolesTable.tsx new file mode 100644 index 0000000..a24bb29 --- /dev/null +++ b/web/src/pages/UsersPage/components/RolesTable.tsx @@ -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 = useMemo(() => [ + { + title: t('Role Name'), + dataIndex: 'name', + render: (value: string, rec: RoleInfo) => ( + + + {value} + {rec.is_system && {t('System')}} + + ), + }, + { 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) => ( + + + {!rec.is_system && ( + onDelete(rec)}> + + + )} + + ), + }, + ], [onDelete, onEdit, t]); + + return ( +
+ ); +}); + diff --git a/web/src/pages/UsersPage/components/RulePermissionIcons.tsx b/web/src/pages/UsersPage/components/RulePermissionIcons.tsx new file mode 100644 index 0000000..3d32cbc --- /dev/null +++ b/web/src/pages/UsersPage/components/RulePermissionIcons.tsx @@ -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 = ( + + + + } + > + + + + + + + + + + + + + + + {t('Roles')} + + + )} + > +
+ ); +}); + diff --git a/web/src/pages/UsersPage/types.ts b/web/src/pages/UsersPage/types.ts new file mode 100644 index 0000000..9f9a3ef --- /dev/null +++ b/web/src/pages/UsersPage/types.ts @@ -0,0 +1,2 @@ +export type RoleDrawerTab = 'basic' | 'permissions' | 'path_rules' | 'users'; + diff --git a/web/src/router/LayoutShell.tsx b/web/src/router/LayoutShell.tsx index 8d99084..550ab4b 100644 --- a/web/src/router/LayoutShell.tsx +++ b/web/src/router/LayoutShell.tsx @@ -14,7 +14,7 @@ import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.t import AuditLogsPage from '../pages/AuditLogsPage.tsx'; import BackupPage from '../pages/SystemSettingsPage/BackupPage.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 { AppWindowsLayer } from '../apps/AppWindowsLayer'; import AiAgentWidget from '../components/AiAgentWidget'; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 39e7d98..5369c45 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -2,6 +2,7 @@ import { Navigate, Routes, Route, useLocation } from 'react-router'; import type { RouteObject } from 'react-router'; import LayoutShell from './LayoutShell.tsx'; import LoginPage from '../pages/LoginPage.tsx'; +import RegisterPage from '../pages/RegisterPage.tsx'; import SetupPage from '../pages/SetupPage.tsx'; import PublicSharePage from '../pages/PublicSharePage'; import ForgotPasswordPage from '../pages/ForgotPasswordPage'; @@ -13,6 +14,7 @@ export const routes: RouteObject[] = [ { path: '/', element: }, { path: '/:navKey/*', element: }, { path: '/login', element: }, + { path: '/register', element: }, { path: '/share/:token', element: }, { path: '/setup', element: }, { path: '/forgot-password', element: },