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

View File

@@ -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<any> => {
register: async (username: string, password: string, email: string, full_name?: string): Promise<any> => {
return request('/auth/register', {
method: 'POST',
json: { username, password, email, full_name },

View File

@@ -15,6 +15,10 @@ export async function getAllConfig() {
return request<Record<string, string>>('/config/all');
}
export async function getPublicConfig() {
return request<Record<string, string>>('/config/public');
}
export interface SystemStatus {
version: string;
title: string;

View File

@@ -6,7 +6,7 @@ interface AuthContextType {
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<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;
refreshUser: () => Promise<void>;
}
@@ -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);
};

View File

@@ -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];

View File

@@ -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",

View File

@@ -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": "数值越大优先级越高",

View File

@@ -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' }
]

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;

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;

View File

@@ -123,6 +123,12 @@ export default function LoginPage() {
{t('Sign In')}
</Button>
</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>
</Space>
</div>

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>
);
}

View File

@@ -315,7 +315,10 @@ const SetupPage = () => {
<Form.Item
label={t('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 />} />
</Form.Item>

View File

@@ -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<Record<string, string> | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabKey>(() =>
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<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>) => {
setLoading(true);
@@ -102,6 +112,14 @@ export default function SystemSettingsPage({ tabKey, onTabNavigate }: SystemSett
onTabNavigate?.(nextKey);
};
if (loadError) {
return (
<PageCard title={t('System Settings')}>
<Alert type="error" showIcon message={loadError} />
</PageCard>
);
}
if (!config) {
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',
label: (

View File

@@ -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>
);
}

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;

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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 }}
/>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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 }}
/>
);
});

View File

@@ -0,0 +1,2 @@
export type RoleDrawerTab = 'basic' | 'permissions' | 'path_rules' | 'users';

View File

@@ -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';

View File

@@ -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: <Navigate to="/files" replace /> },
{ path: '/:navKey/*', element: <LayoutShell /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: '/share/:token', element: <PublicSharePage /> },
{ path: '/setup', element: <SetupPage /> },
{ path: '/forgot-password', element: <ForgotPasswordPage /> },