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:
shiyu
2026-02-01 19:25:17 +08:00
parent 4014a4dd74
commit c5e4b3ef43
29 changed files with 1631 additions and 1323 deletions
+3 -3
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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 },
+4
View File
@@ -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;
+2 -2
View File
@@ -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);
}; };
+2 -2
View File
@@ -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];
+11
View File
@@ -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",
+13
View File
@@ -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": "数值越大优先级越高",
+1 -1
View File
@@ -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' }
] ]
-435
View File
@@ -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;
-863
View File
@@ -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;
+6
View File
@@ -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>
+136
View File
@@ -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>
);
}
+4 -1
View File
@@ -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>
);
}
+534
View File
@@ -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 }}
/>
);
});
+2
View File
@@ -0,0 +1,2 @@
export type RoleDrawerTab = 'basic' | 'permissions' | 'path_rules' | 'users';
+1 -1
View File
@@ -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
View File
@@ -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 /> },