mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-08-07 23:03:28 +08:00
feat: add user and role management pages with authentication settings
- Implemented AuthSettingsTab for managing authentication settings including user registration and default roles. - Created UsersPage for managing users and roles, including user creation, editing, and deletion functionalities. - Added components for user and role management: UserEditorDrawer, RoleEditorDrawer, UsersTable, RolesTable, and PathRuleEditorDrawer. - Introduced QuickCreateRoleModal for quick role creation within user management. - Implemented permission management within roles, including path rules and user assignments. - Enhanced user experience with loading states and error handling in API interactions.
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "数值越大优先级越高",
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Space, Drawer, Form, Input, Switch, message,
|
||||
Tag, Popconfirm, Checkbox, Collapse, Typography, InputNumber, Divider
|
||||
} from 'antd';
|
||||
import { LockOutlined, FolderOutlined } from '@ant-design/icons';
|
||||
import PageCard from '../../components/PageCard';
|
||||
import { rolesApi, type PathRuleCreate, type PathRuleInfo, type RoleDetail, type RoleInfo } from '../../api/roles';
|
||||
import { permissionsApi, type PermissionInfo } from '../../api/permissions';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
const RolesPage = memo(function RolesPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<RoleDetail | null>(null);
|
||||
const [pathRules, setPathRules] = useState<PathRuleInfo[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
const [ruleForm] = Form.useForm();
|
||||
const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<PathRuleInfo | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [roleList, permList] = await Promise.all([
|
||||
rolesApi.list(),
|
||||
permissionsApi.listAll(),
|
||||
]);
|
||||
setRoles(roleList);
|
||||
setPermissions(permList);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setPathRules([]);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: [],
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = async (rec: RoleInfo) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [detail, rules] = await Promise.all([
|
||||
rolesApi.get(rec.id),
|
||||
rolesApi.getPathRules(rec.id),
|
||||
]);
|
||||
setEditing(detail);
|
||||
setPathRules(rules);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
name: detail.name,
|
||||
description: detail.description || '',
|
||||
permissions: detail.permissions,
|
||||
});
|
||||
setOpen(true);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
if (editing) {
|
||||
// 更新角色
|
||||
await rolesApi.update(editing.id, {
|
||||
name: values.name.trim(),
|
||||
description: values.description || null,
|
||||
});
|
||||
// 更新权限
|
||||
await rolesApi.setPermissions(editing.id, values.permissions || []);
|
||||
message.success(t('Updated successfully'));
|
||||
} else {
|
||||
// 创建角色
|
||||
const newRole = await rolesApi.create({
|
||||
name: values.name.trim(),
|
||||
description: values.description || null,
|
||||
});
|
||||
// 设置权限
|
||||
if (values.permissions?.length) {
|
||||
await rolesApi.setPermissions(newRole.id, values.permissions);
|
||||
}
|
||||
message.success(t('Created successfully'));
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e.message || t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async (rec: RoleInfo) => {
|
||||
try {
|
||||
await rolesApi.remove(rec.id);
|
||||
message.success(t('Deleted'));
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Delete failed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 路径规则管理
|
||||
const openAddRule = () => {
|
||||
setEditingRule(null);
|
||||
ruleForm.resetFields();
|
||||
ruleForm.setFieldsValue({
|
||||
path_pattern: '/',
|
||||
is_regex: false,
|
||||
can_read: true,
|
||||
can_write: false,
|
||||
can_delete: false,
|
||||
can_share: false,
|
||||
priority: 0,
|
||||
});
|
||||
setRuleDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEditRule = (rule: PathRuleInfo) => {
|
||||
setEditingRule(rule);
|
||||
ruleForm.resetFields();
|
||||
ruleForm.setFieldsValue({
|
||||
path_pattern: rule.path_pattern,
|
||||
is_regex: rule.is_regex,
|
||||
can_read: rule.can_read,
|
||||
can_write: rule.can_write,
|
||||
can_delete: rule.can_delete,
|
||||
can_share: rule.can_share,
|
||||
priority: rule.priority,
|
||||
});
|
||||
setRuleDrawerOpen(true);
|
||||
};
|
||||
|
||||
const submitRule = async () => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
const values = await ruleForm.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const ruleData: PathRuleCreate = {
|
||||
path_pattern: values.path_pattern,
|
||||
is_regex: values.is_regex,
|
||||
can_read: values.can_read,
|
||||
can_write: values.can_write,
|
||||
can_delete: values.can_delete,
|
||||
can_share: values.can_share,
|
||||
priority: values.priority,
|
||||
};
|
||||
|
||||
if (editingRule) {
|
||||
await rolesApi.updatePathRule(editingRule.id, ruleData);
|
||||
message.success(t('Updated successfully'));
|
||||
} else {
|
||||
await rolesApi.addPathRule(editing.id, ruleData);
|
||||
message.success(t('Created successfully'));
|
||||
}
|
||||
|
||||
// 刷新规则列表
|
||||
const rules = await rolesApi.getPathRules(editing.id);
|
||||
setPathRules(rules);
|
||||
setRuleDrawerOpen(false);
|
||||
setEditingRule(null);
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e.message || t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (rule: PathRuleInfo) => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
await rolesApi.deletePathRule(rule.id);
|
||||
message.success(t('Deleted'));
|
||||
const rules = await rolesApi.getPathRules(editing.id);
|
||||
setPathRules(rules);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Delete failed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 按分类分组权限
|
||||
const groupedPermissions = permissions.reduce((acc, p) => {
|
||||
if (!acc[p.category]) acc[p.category] = [];
|
||||
acc[p.category].push(p);
|
||||
return acc;
|
||||
}, {} as Record<string, PermissionInfo[]>);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('Role Name'),
|
||||
dataIndex: 'name',
|
||||
render: (value: string, rec: RoleInfo) => (
|
||||
<Space>
|
||||
<LockOutlined />
|
||||
{value}
|
||||
{rec.is_system && <Tag color="blue">{t('System')}</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: t('Description'), dataIndex: 'description', render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: t('Created At'),
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 160,
|
||||
render: (_: any, rec: RoleInfo) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEdit(rec)}>{t('Edit')}</Button>
|
||||
{!rec.is_system && (
|
||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const ruleColumns = [
|
||||
{
|
||||
title: t('Path Pattern'),
|
||||
dataIndex: 'path_pattern',
|
||||
render: (v: string, rec: PathRuleInfo) => (
|
||||
<Space>
|
||||
<FolderOutlined />
|
||||
<code>{v}</code>
|
||||
{rec.is_regex && <Tag color="purple">Regex</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Permissions'),
|
||||
render: (_: any, rec: PathRuleInfo) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{rec.can_read && <Tag color="green">{t('Read')}</Tag>}
|
||||
{rec.can_write && <Tag color="blue">{t('Write')}</Tag>}
|
||||
{rec.can_delete && <Tag color="red">{t('Delete')}</Tag>}
|
||||
{rec.can_share && <Tag color="orange">{t('Share')}</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Priority'),
|
||||
dataIndex: 'priority',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 140,
|
||||
render: (_: any, rec: PathRuleInfo) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEditRule(rec)}>{t('Edit')}</Button>
|
||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => deleteRule(rec)}>
|
||||
<Button size="small" danger>{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title={t('Role Management')}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button type="primary" onClick={openCreate}>{t('Create Role')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={roles}
|
||||
columns={columns as any}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
|
||||
{/* 角色编辑抽屉 */}
|
||||
<Drawer
|
||||
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Role')}
|
||||
width={600}
|
||||
open={open}
|
||||
onClose={() => { setOpen(false); setEditing(null); }}
|
||||
destroyOnHidden
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('Role Name')}
|
||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
|
||||
>
|
||||
<Input placeholder={t('Role Name')} disabled={editing?.is_system} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label={t('Description')}>
|
||||
<Input.TextArea placeholder={t('Description')} rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('System Permissions')}</Divider>
|
||||
<Form.Item name="permissions" label={t('Permissions')}>
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
<Collapse
|
||||
items={Object.entries(groupedPermissions).map(([category, perms]) => ({
|
||||
key: category,
|
||||
label: t(`permission.category.${category}`) === `permission.category.${category}`
|
||||
? category.charAt(0).toUpperCase() + category.slice(1)
|
||||
: t(`permission.category.${category}`),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{perms.map(p => (
|
||||
<Checkbox key={p.code} value={p.code}>
|
||||
{p.name}
|
||||
{p.description && (
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{p.description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
|
||||
{editing && (
|
||||
<>
|
||||
<Divider>{t('Path Rules')}</Divider>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" size="small" onClick={openAddRule}>
|
||||
{t('Add Path Rule')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={pathRules}
|
||||
columns={ruleColumns as any}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
{/* 路径规则编辑抽屉 */}
|
||||
<Drawer
|
||||
title={editingRule ? t('Edit Path Rule') : t('Add Path Rule')}
|
||||
width={400}
|
||||
open={ruleDrawerOpen}
|
||||
onClose={() => { setRuleDrawerOpen(false); setEditingRule(null); }}
|
||||
destroyOnHidden
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setRuleDrawerOpen(false); setEditingRule(null); }}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={submitRule} loading={loading}>{t('Submit')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={ruleForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="path_pattern"
|
||||
label={t('Path Pattern')}
|
||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Path Pattern') }) }]}
|
||||
extra={t('Use * for single level, ** for any level. Example: /photos/** matches all files in photos folder.')}
|
||||
>
|
||||
<Input placeholder="/photos/**" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_regex" label={t('Is Regex')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="priority" label={t('Priority')} extra={t('Higher value = higher priority')}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Divider>{t('Permissions')}</Divider>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Form.Item name="can_read" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t('Read')} - {t('Download and preview files')}</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="can_write" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t('Write')} - {t('Upload and modify files')}</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="can_delete" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t('Delete')} - {t('Delete files and folders')}</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="can_share" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t('Share')} - {t('Create share links')}</Checkbox>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</PageCard>
|
||||
);
|
||||
});
|
||||
|
||||
export default RolesPage;
|
||||
@@ -1,863 +0,0 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Divider,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
CrownOutlined,
|
||||
FolderOutlined,
|
||||
LockOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import PageCard from '../../components/PageCard';
|
||||
import { usersApi, type UserDetail, type UserInfo } from '../../api/users';
|
||||
import {
|
||||
rolesApi,
|
||||
type PathRuleCreate,
|
||||
type PathRuleInfo,
|
||||
type RoleDetail,
|
||||
type RoleInfo,
|
||||
} from '../../api/roles';
|
||||
import { permissionsApi, type PermissionInfo } from '../../api/permissions';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
type TabKey = 'users' | 'roles';
|
||||
|
||||
const UsersPage = memo(function UsersPage() {
|
||||
const { t } = useI18n();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('users');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const [users, setUsers] = useState<UserInfo[]>([]);
|
||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||
|
||||
const [userDrawerOpen, setUserDrawerOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
|
||||
const [userForm] = Form.useForm();
|
||||
|
||||
const [roleDrawerOpen, setRoleDrawerOpen] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<RoleDetail | null>(null);
|
||||
const [pathRules, setPathRules] = useState<PathRuleInfo[]>([]);
|
||||
const [roleUsers, setRoleUsers] = useState<UserInfo[]>([]);
|
||||
const [roleForm] = Form.useForm();
|
||||
|
||||
const [ruleDrawerOpen, setRuleDrawerOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<PathRuleInfo | null>(null);
|
||||
const [ruleForm] = Form.useForm();
|
||||
|
||||
const [quickRoleModalOpen, setQuickRoleModalOpen] = useState(false);
|
||||
const [quickRoleForm] = Form.useForm();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [userList, roleList, permList] = await Promise.all([
|
||||
usersApi.list(),
|
||||
rolesApi.list(),
|
||||
permissionsApi.listAll(),
|
||||
]);
|
||||
setUsers(userList);
|
||||
setRoles(roleList);
|
||||
setPermissions(permList);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const normalizedSearch = searchText.trim().toLowerCase();
|
||||
const filteredUsers = useMemo(() => {
|
||||
if (!normalizedSearch) return users;
|
||||
return users.filter((u) => {
|
||||
const haystacks = [
|
||||
u.username,
|
||||
u.email ?? '',
|
||||
u.full_name ?? '',
|
||||
].map(v => v.toLowerCase());
|
||||
return haystacks.some(v => v.includes(normalizedSearch));
|
||||
});
|
||||
}, [normalizedSearch, users]);
|
||||
const filteredRoles = useMemo(() => {
|
||||
if (!normalizedSearch) return roles;
|
||||
return roles.filter((r) => {
|
||||
const haystacks = [
|
||||
r.name,
|
||||
r.description ?? '',
|
||||
].map(v => v.toLowerCase());
|
||||
return haystacks.some(v => v.includes(normalizedSearch));
|
||||
});
|
||||
}, [normalizedSearch, roles]);
|
||||
|
||||
const groupedPermissions = useMemo(() => {
|
||||
return permissions.reduce((acc, p) => {
|
||||
if (!acc[p.category]) acc[p.category] = [];
|
||||
acc[p.category].push(p);
|
||||
return acc;
|
||||
}, {} as Record<string, PermissionInfo[]>);
|
||||
}, [permissions]);
|
||||
|
||||
// --- User ops ---
|
||||
const openCreateUser = () => {
|
||||
setEditingUser(null);
|
||||
userForm.resetFields();
|
||||
userForm.setFieldsValue({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
full_name: '',
|
||||
is_admin: false,
|
||||
disabled: false,
|
||||
role_ids: [],
|
||||
});
|
||||
setUserDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEditUser = async (rec: UserInfo) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const detail = await usersApi.get(rec.id);
|
||||
setEditingUser(detail);
|
||||
userForm.resetFields();
|
||||
const roleIds = roles
|
||||
.filter(r => detail.roles.includes(r.name))
|
||||
.map(r => r.id);
|
||||
userForm.setFieldsValue({
|
||||
username: detail.username,
|
||||
password: '',
|
||||
email: detail.email || '',
|
||||
full_name: detail.full_name || '',
|
||||
is_admin: detail.is_admin,
|
||||
disabled: detail.disabled,
|
||||
role_ids: roleIds,
|
||||
});
|
||||
setUserDrawerOpen(true);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitUser = async () => {
|
||||
try {
|
||||
const values = await userForm.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
if (editingUser) {
|
||||
const updateData: any = {
|
||||
email: values.email || null,
|
||||
full_name: values.full_name || null,
|
||||
is_admin: values.is_admin,
|
||||
disabled: values.disabled,
|
||||
};
|
||||
if (values.password) updateData.password = values.password;
|
||||
await usersApi.update(editingUser.id, updateData);
|
||||
await usersApi.setRoles(editingUser.id, values.role_ids || []);
|
||||
message.success(t('Updated successfully'));
|
||||
} else {
|
||||
await usersApi.create({
|
||||
username: values.username.trim(),
|
||||
password: values.password,
|
||||
email: values.email || null,
|
||||
full_name: values.full_name || null,
|
||||
is_admin: values.is_admin,
|
||||
disabled: values.disabled,
|
||||
role_ids: values.role_ids || [],
|
||||
});
|
||||
message.success(t('Created successfully'));
|
||||
}
|
||||
|
||||
setUserDrawerOpen(false);
|
||||
setEditingUser(null);
|
||||
await fetchData();
|
||||
|
||||
if (editingRole) {
|
||||
const nextRoleUsers = await rolesApi.getUsers(editingRole.id);
|
||||
setRoleUsers(nextRoleUsers);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e.message || t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDeleteUser = async (rec: UserInfo) => {
|
||||
try {
|
||||
await usersApi.remove(rec.id);
|
||||
message.success(t('Deleted'));
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Delete failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDisabled = async (rec: UserInfo, disabled: boolean) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await usersApi.update(rec.id, { disabled });
|
||||
message.success(t('Status updated'));
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Update failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Quick create role (for user drawer) ---
|
||||
const openQuickCreateRole = () => {
|
||||
quickRoleForm.resetFields();
|
||||
quickRoleForm.setFieldsValue({
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
setQuickRoleModalOpen(true);
|
||||
};
|
||||
|
||||
const submitQuickRole = async () => {
|
||||
try {
|
||||
const values = await quickRoleForm.validateFields();
|
||||
setLoading(true);
|
||||
const newRole = await rolesApi.create({
|
||||
name: values.name.trim(),
|
||||
description: values.description || null,
|
||||
});
|
||||
message.success(t('Created successfully'));
|
||||
setRoles((prev) => [...prev, newRole].sort((a, b) => a.id - b.id));
|
||||
|
||||
const currentIds = (userForm.getFieldValue('role_ids') || []) as number[];
|
||||
const nextIds = Array.from(new Set([...currentIds, newRole.id]));
|
||||
userForm.setFieldsValue({ role_ids: nextIds });
|
||||
|
||||
setQuickRoleModalOpen(false);
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e.message || t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Role ops ---
|
||||
const openCreateRole = () => {
|
||||
setEditingRole(null);
|
||||
setPathRules([]);
|
||||
setRoleUsers([]);
|
||||
roleForm.resetFields();
|
||||
roleForm.setFieldsValue({
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: [],
|
||||
});
|
||||
setRoleDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEditRole = async (rec: RoleInfo) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [detail, rules, usersUsingRole] = await Promise.all([
|
||||
rolesApi.get(rec.id),
|
||||
rolesApi.getPathRules(rec.id),
|
||||
rolesApi.getUsers(rec.id),
|
||||
]);
|
||||
setEditingRole(detail);
|
||||
setPathRules(rules);
|
||||
setRoleUsers(usersUsingRole);
|
||||
roleForm.resetFields();
|
||||
roleForm.setFieldsValue({
|
||||
name: detail.name,
|
||||
description: detail.description || '',
|
||||
permissions: detail.permissions,
|
||||
});
|
||||
setRoleDrawerOpen(true);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitRole = async () => {
|
||||
try {
|
||||
const values = await roleForm.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
if (editingRole) {
|
||||
await rolesApi.update(editingRole.id, {
|
||||
name: values.name.trim(),
|
||||
description: values.description || null,
|
||||
});
|
||||
await rolesApi.setPermissions(editingRole.id, values.permissions || []);
|
||||
message.success(t('Updated successfully'));
|
||||
} else {
|
||||
const newRole = await rolesApi.create({
|
||||
name: values.name.trim(),
|
||||
description: values.description || null,
|
||||
});
|
||||
if (values.permissions?.length) {
|
||||
await rolesApi.setPermissions(newRole.id, values.permissions);
|
||||
}
|
||||
message.success(t('Created successfully'));
|
||||
}
|
||||
|
||||
setRoleDrawerOpen(false);
|
||||
setEditingRole(null);
|
||||
await fetchData();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e.message || t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDeleteRole = async (rec: RoleInfo) => {
|
||||
try {
|
||||
await rolesApi.remove(rec.id);
|
||||
message.success(t('Deleted'));
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Delete failed'));
|
||||
}
|
||||
};
|
||||
|
||||
// --- Path rules ---
|
||||
const openAddRule = () => {
|
||||
setEditingRule(null);
|
||||
ruleForm.resetFields();
|
||||
ruleForm.setFieldsValue({
|
||||
path_pattern: '/',
|
||||
is_regex: false,
|
||||
can_read: true,
|
||||
can_write: false,
|
||||
can_delete: false,
|
||||
can_share: false,
|
||||
priority: 0,
|
||||
});
|
||||
setRuleDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEditRule = (rule: PathRuleInfo) => {
|
||||
setEditingRule(rule);
|
||||
ruleForm.resetFields();
|
||||
ruleForm.setFieldsValue({
|
||||
path_pattern: rule.path_pattern,
|
||||
is_regex: rule.is_regex,
|
||||
can_read: rule.can_read,
|
||||
can_write: rule.can_write,
|
||||
can_delete: rule.can_delete,
|
||||
can_share: rule.can_share,
|
||||
priority: rule.priority,
|
||||
});
|
||||
setRuleDrawerOpen(true);
|
||||
};
|
||||
|
||||
const submitRule = async () => {
|
||||
if (!editingRole) return;
|
||||
try {
|
||||
const values = await ruleForm.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const ruleData: PathRuleCreate = {
|
||||
path_pattern: values.path_pattern,
|
||||
is_regex: values.is_regex,
|
||||
can_read: values.can_read,
|
||||
can_write: values.can_write,
|
||||
can_delete: values.can_delete,
|
||||
can_share: values.can_share,
|
||||
priority: values.priority,
|
||||
};
|
||||
|
||||
if (editingRule) {
|
||||
await rolesApi.updatePathRule(editingRule.id, ruleData);
|
||||
message.success(t('Updated successfully'));
|
||||
} else {
|
||||
await rolesApi.addPathRule(editingRole.id, ruleData);
|
||||
message.success(t('Created successfully'));
|
||||
}
|
||||
|
||||
const rules = await rolesApi.getPathRules(editingRole.id);
|
||||
setPathRules(rules);
|
||||
setRuleDrawerOpen(false);
|
||||
setEditingRule(null);
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e.message || t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (rule: PathRuleInfo) => {
|
||||
if (!editingRole) return;
|
||||
try {
|
||||
await rolesApi.deletePathRule(rule.id);
|
||||
message.success(t('Deleted'));
|
||||
const rules = await rolesApi.getPathRules(editingRole.id);
|
||||
setPathRules(rules);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Delete failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const userColumns = [
|
||||
{
|
||||
title: t('Username'),
|
||||
dataIndex: 'username',
|
||||
render: (value: string, rec: UserInfo) => (
|
||||
<Space>
|
||||
{rec.is_admin ? <CrownOutlined style={{ color: '#faad14' }} /> : <UserOutlined />}
|
||||
{value}
|
||||
{rec.is_admin && <Tag color="gold">{t('Admin')}</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' },
|
||||
{ title: t('Full Name'), dataIndex: 'full_name', render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: t('Status'),
|
||||
dataIndex: 'disabled',
|
||||
width: 100,
|
||||
render: (disabled: boolean, rec: UserInfo) => (
|
||||
<Switch
|
||||
checked={!disabled}
|
||||
size="small"
|
||||
loading={loading}
|
||||
onChange={(checked) => handleToggleDisabled(rec, !checked)}
|
||||
checkedChildren={t('Active')}
|
||||
unCheckedChildren={t('Disabled')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Last Login'),
|
||||
dataIndex: 'last_login',
|
||||
width: 180,
|
||||
render: (v: string | null) => v ? new Date(v).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 160,
|
||||
render: (_: any, rec: UserInfo) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEditUser(rec)}>{t('Edit')}</Button>
|
||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDeleteUser(rec)}>
|
||||
<Button size="small" danger>{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const roleColumns = [
|
||||
{
|
||||
title: t('Role Name'),
|
||||
dataIndex: 'name',
|
||||
render: (value: string, rec: RoleInfo) => (
|
||||
<Space>
|
||||
<LockOutlined />
|
||||
{value}
|
||||
{rec.is_system && <Tag color="blue">{t('System')}</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: t('Description'), dataIndex: 'description', render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: t('Created At'),
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 160,
|
||||
render: (_: any, rec: RoleInfo) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEditRole(rec)}>{t('Edit')}</Button>
|
||||
{!rec.is_system && (
|
||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDeleteRole(rec)}>
|
||||
<Button size="small" danger>{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const ruleColumns = [
|
||||
{
|
||||
title: t('Path Pattern'),
|
||||
dataIndex: 'path_pattern',
|
||||
render: (v: string, rec: PathRuleInfo) => (
|
||||
<Space>
|
||||
<FolderOutlined />
|
||||
<code>{v}</code>
|
||||
{rec.is_regex && <Tag color="purple">Regex</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Permissions'),
|
||||
render: (_: any, rec: PathRuleInfo) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{rec.can_read && <Tag color="green">{t('Read')}</Tag>}
|
||||
{rec.can_write && <Tag color="blue">{t('Write')}</Tag>}
|
||||
{rec.can_delete && <Tag color="red">{t('Delete')}</Tag>}
|
||||
{rec.can_share && <Tag color="orange">{t('Share')}</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Priority'),
|
||||
dataIndex: 'priority',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 140,
|
||||
render: (_: any, rec: PathRuleInfo) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEditRule(rec)}>{t('Edit')}</Button>
|
||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => deleteRule(rec)}>
|
||||
<Button size="small" danger>{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const roleUserColumns = [
|
||||
{
|
||||
title: t('Username'),
|
||||
dataIndex: 'username',
|
||||
render: (value: string, rec: UserInfo) => (
|
||||
<Space>
|
||||
{rec.is_admin ? <CrownOutlined style={{ color: '#faad14' }} /> : <UserOutlined />}
|
||||
{value}
|
||||
{rec.is_admin && <Tag color="gold">{t('Admin')}</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: t('Email'), dataIndex: 'email', render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: t('Status'),
|
||||
dataIndex: 'disabled',
|
||||
width: 90,
|
||||
render: (disabled: boolean) => disabled ? <Tag>{t('Disabled')}</Tag> : <Tag color="green">{t('Active')}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 110,
|
||||
render: (_: any, rec: UserInfo) => (
|
||||
<Button size="small" onClick={() => openEditUser(rec)}>{t('Edit')}</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'users',
|
||||
label: `${t('Users')} (${filteredUsers.length})`,
|
||||
children: (
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={filteredUsers}
|
||||
columns={userColumns as any}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'roles',
|
||||
label: `${t('Roles')} (${filteredRoles.length})`,
|
||||
children: (
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={filteredRoles}
|
||||
columns={roleColumns as any}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title={t('User Management')}
|
||||
extra={
|
||||
<Space>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={searchText}
|
||||
placeholder={t('Search users or roles')}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Button onClick={fetchData} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button type="primary" onClick={() => { setActiveTab('users'); openCreateUser(); }}>
|
||||
{t('Create User')}
|
||||
</Button>
|
||||
<Button onClick={() => { setActiveTab('roles'); openCreateRole(); }}>
|
||||
{t('Create Role')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={(k) => setActiveTab(k as TabKey)} items={tabItems} />
|
||||
|
||||
{/* User editor */}
|
||||
<Drawer
|
||||
title={editingUser ? `${t('Edit')}: ${editingUser.username}` : t('Create User')}
|
||||
width={480}
|
||||
open={userDrawerOpen}
|
||||
onClose={() => { setUserDrawerOpen(false); setEditingUser(null); }}
|
||||
destroyOnHidden
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setUserDrawerOpen(false); setEditingUser(null); }}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={submitUser} loading={loading}>{t('Submit')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={userForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={t('Username')}
|
||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Username') }) }]}
|
||||
>
|
||||
<Input placeholder={t('Username')} disabled={!!editingUser} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={editingUser ? t('New Password (leave empty to keep current)') : t('Password')}
|
||||
rules={editingUser ? [] : [{ required: true, message: t('Please input {label}', { label: t('Password') }) }]}
|
||||
>
|
||||
<Input.Password placeholder={t('Password')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label={t('Email')}>
|
||||
<Input placeholder={t('Email')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="full_name" label={t('Full Name')}>
|
||||
<Input placeholder={t('Full Name')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="role_ids"
|
||||
label={(
|
||||
<Space size={4}>
|
||||
{t('Roles')}
|
||||
<Button type="link" size="small" onClick={openQuickCreateRole}>
|
||||
{t('Quick Create Role')}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={t('Select roles')}
|
||||
options={roles.map(r => ({
|
||||
value: r.id,
|
||||
label: r.name + (r.is_system ? ` (${t('System')})` : ''),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="is_admin" label={t('Super Admin')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="disabled" label={t('Disabled')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
{editingUser && (
|
||||
<Typography.Text type="secondary">
|
||||
{t('Created by')}: {editingUser.created_by_username || '-'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={t('Create Role')}
|
||||
open={quickRoleModalOpen}
|
||||
onCancel={() => setQuickRoleModalOpen(false)}
|
||||
okText={t('Submit')}
|
||||
cancelText={t('Cancel')}
|
||||
confirmLoading={loading}
|
||||
onOk={submitQuickRole}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={quickRoleForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('Role Name')}
|
||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
|
||||
>
|
||||
<Input placeholder={t('Role Name')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label={t('Description')}>
|
||||
<Input.TextArea placeholder={t('Description')} rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Role editor */}
|
||||
<Drawer
|
||||
title={editingRole ? `${t('Edit')}: ${editingRole.name}` : t('Create Role')}
|
||||
width={600}
|
||||
open={roleDrawerOpen}
|
||||
onClose={() => { setRoleDrawerOpen(false); setEditingRole(null); }}
|
||||
destroyOnHidden
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setRoleDrawerOpen(false); setEditingRole(null); }}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={submitRole} loading={loading}>{t('Submit')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={roleForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('Role Name')}
|
||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Role Name') }) }]}
|
||||
>
|
||||
<Input placeholder={t('Role Name')} disabled={editingRole?.is_system} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label={t('Description')}>
|
||||
<Input.TextArea placeholder={t('Description')} rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>{t('System Permissions')}</Divider>
|
||||
<Form.Item name="permissions" label={t('Permissions')}>
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
<Collapse
|
||||
items={Object.entries(groupedPermissions).map(([category, perms]) => ({
|
||||
key: category,
|
||||
label: t(`permission.category.${category}`) === `permission.category.${category}`
|
||||
? category.charAt(0).toUpperCase() + category.slice(1)
|
||||
: t(`permission.category.${category}`),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{perms.map(p => (
|
||||
<Checkbox key={p.code} value={p.code}>
|
||||
{p.name}
|
||||
{p.description && (
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{p.description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
|
||||
{editingRole && (
|
||||
<>
|
||||
<Divider>{t('Path Rules')}</Divider>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" size="small" onClick={openAddRule}>
|
||||
{t('Add Path Rule')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={pathRules}
|
||||
columns={ruleColumns as any}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Divider>{t('Users')}</Divider>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={roleUsers}
|
||||
columns={roleUserColumns as any}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
{/* Path rule editor */}
|
||||
<Drawer
|
||||
title={editingRule ? t('Edit Path Rule') : t('Add Path Rule')}
|
||||
width={400}
|
||||
open={ruleDrawerOpen}
|
||||
onClose={() => { setRuleDrawerOpen(false); setEditingRule(null); }}
|
||||
destroyOnHidden
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setRuleDrawerOpen(false); setEditingRule(null); }}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={submitRule} loading={loading}>{t('Submit')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={ruleForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="path_pattern"
|
||||
label={t('Path Pattern')}
|
||||
rules={[{ required: true, message: t('Please input {label}', { label: t('Path Pattern') }) }]}
|
||||
extra={t('Use * for single level, ** for any level. Example: /photos/** matches all files in photos folder.')}
|
||||
>
|
||||
<Input placeholder="/photos/**" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_regex" label={t('Is Regex')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="priority" label={t('Priority')} extra={t('Higher value = higher priority')}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Divider>{t('Permissions')}</Divider>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Form.Item name="can_read" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t('Read')} - {t('Download and preview files')}</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="can_write" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t('Write')} - {t('Upload and modify files')}</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="can_delete" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t('Delete')} - {t('Delete files and folders')}</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="can_share" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t('Share')} - {t('Create share links')}</Checkbox>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</PageCard>
|
||||
);
|
||||
});
|
||||
|
||||
export default UsersPage;
|
||||
@@ -123,6 +123,12 @@ export default function LoginPage() {
|
||||
{t('Sign In')}
|
||||
</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>
|
||||
|
||||
136
web/src/pages/RegisterPage.tsx
Normal file
136
web/src/pages/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: (
|
||||
|
||||
112
web/src/pages/SystemSettingsPage/components/AuthSettingsTab.tsx
Normal file
112
web/src/pages/SystemSettingsPage/components/AuthSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
534
web/src/pages/UsersPage/UsersPage.tsx
Normal file
534
web/src/pages/UsersPage/UsersPage.tsx
Normal 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;
|
||||
73
web/src/pages/UsersPage/components/PathRuleEditorDrawer.tsx
Normal file
73
web/src/pages/UsersPage/components/PathRuleEditorDrawer.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
||||
48
web/src/pages/UsersPage/components/QuickCreateRoleModal.tsx
Normal file
48
web/src/pages/UsersPage/components/QuickCreateRoleModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
||||
250
web/src/pages/UsersPage/components/RoleEditorDrawer.tsx
Normal file
250
web/src/pages/UsersPage/components/RoleEditorDrawer.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
69
web/src/pages/UsersPage/components/RolesTable.tsx
Normal file
69
web/src/pages/UsersPage/components/RolesTable.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
53
web/src/pages/UsersPage/components/RulePermissionIcons.tsx
Normal file
53
web/src/pages/UsersPage/components/RulePermissionIcons.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
||||
100
web/src/pages/UsersPage/components/UserEditorDrawer.tsx
Normal file
100
web/src/pages/UsersPage/components/UserEditorDrawer.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
||||
85
web/src/pages/UsersPage/components/UsersTable.tsx
Normal file
85
web/src/pages/UsersPage/components/UsersTable.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
2
web/src/pages/UsersPage/types.ts
Normal file
2
web/src/pages/UsersPage/types.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type RoleDrawerTab = 'basic' | 'permissions' | 'path_rules' | 'users';
|
||||
|
||||
@@ -14,7 +14,7 @@ import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.t
|
||||
import AuditLogsPage from '../pages/AuditLogsPage.tsx';
|
||||
import 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';
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
Reference in New Issue
Block a user